{
    "version": 1,
    "categories": [
        {
            "id": "admin",
            "label": "Admin"
        },
        {
            "id": "dashboard",
            "label": "Dashboard"
        },
        {
            "id": "login",
            "label": "Login"
        },
        {
            "id": "users",
            "label": "Users"
        },
        {
            "id": "security",
            "label": "Security"
        },
        {
            "id": "performance",
            "label": "Performance"
        },
        {
            "id": "disable-features",
            "label": "Disable Features"
        },
        {
            "id": "comments",
            "label": "Comments"
        },
        {
            "id": "content",
            "label": "Content"
        },
        {
            "id": "archive",
            "label": "Archives"
        },
        {
            "id": "media",
            "label": "Media"
        },
        {
            "id": "seo",
            "label": "SEO"
        },
        {
            "id": "rss",
            "label": "RSS Feeds"
        },
        {
            "id": "search",
            "label": "Search"
        },
        {
            "id": "widgets",
            "label": "Widgets"
        },
        {
            "id": "gutenberg",
            "label": "Block Editor"
        },
        {
            "id": "woocommerce",
            "label": "WooCommerce"
        },
        {
            "id": "redirects",
            "label": "Redirects"
        },
        {
            "id": "email",
            "label": "Email"
        },
        {
            "id": "developer",
            "label": "Developer"
        },
        {
            "id": "analytics",
            "label": "Analytics & Tracking"
        },
        {
            "id": "frontend",
            "label": "Frontend & Design"
        },
        {
            "id": "post-types",
            "label": "Custom Post Types & Fields"
        },
        {
            "id": "forms",
            "label": "Forms"
        },
        {
            "id": "page-builders",
            "label": "Page Builders & Themes"
        },
        {
            "id": "integrations",
            "label": "Plugin Integrations"
        },
        {
            "id": "edd",
            "label": "Easy Digital Downloads"
        },
        {
            "id": "memberships",
            "label": "Memberships"
        },
        {
            "id": "learning",
            "label": "Courses & Community"
        },
        {
            "id": "multilingual",
            "label": "Multilingual"
        },
        {
            "id": "multisite",
            "label": "Multisite"
        },
        {
            "id": "accessibility",
            "label": "Accessibility"
        },
        {
            "id": "privacy",
            "label": "Privacy & Cookies"
        }
    ],
    "snippets": [
        {
            "id": "hide-admin-bar-for-subscribers",
            "version": 1,
            "title": "Hide the Admin Bar for Subscribers",
            "description": "Hides the toolbar on the front of the site for anyone who cannot edit posts, such as subscribers and shop customers. Authors, editors and administrators keep it.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "toolbar",
                "subscribers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'show_admin_bar', function ( $show ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return false;\n    }\n\n    return $show;\n} );",
            "requires": [],
            "notes": "Change edit_posts to another capability (for example manage_options) to hide the bar for more roles."
        },
        {
            "id": "custom-admin-footer-text",
            "version": 1,
            "title": "Custom Admin Footer Text",
            "description": "Replaces the \"Thank you for creating with WordPress\" line at the bottom of every admin screen with your own message.",
            "category": "admin",
            "tags": [
                "footer",
                "branding",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'admin_footer_text', function () {\n    $message = 'Thanks for managing %s. Need a hand? Contact your site administrator.';\n\n    return sprintf( esc_html( $message ), '<strong>' . esc_html( get_bloginfo( 'name' ) ) . '</strong>' );\n} );",
            "requires": [],
            "notes": "Edit $message. %s is replaced with the site name."
        },
        {
            "id": "remove-admin-footer-version",
            "version": 1,
            "title": "Remove the WordPress Version From the Admin Footer",
            "description": "Removes the \"Version x.y\" text from the right side of the admin footer. Useful for client sites and screenshots.",
            "category": "admin",
            "tags": [
                "footer",
                "version",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'update_footer', '__return_empty_string', 11 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-admin-menu-items-for-non-admins",
            "version": 1,
            "title": "Hide Admin Menu Items From Non-Administrators",
            "description": "Removes selected menu entries (Tools and Comments by default) for every user who is not an administrator, to keep the admin menu focused.",
            "category": "admin",
            "tags": [
                "menu",
                "roles",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    // Menu slugs to hide. Examples: 'edit-comments.php', 'tools.php', 'upload.php', 'edit.php?post_type=page'.\n    $hidden = array( 'tools.php', 'edit-comments.php' );\n\n    foreach ( $hidden as $slug ) {\n        remove_menu_page( $slug );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Hiding a menu item does not remove the permission; users can still open the screen by URL if their role allows it."
        },
        {
            "id": "duplicate-posts-and-pages",
            "version": 1,
            "title": "Duplicate Posts and Pages",
            "description": "Adds a \"Duplicate\" link to the row actions of posts, pages and custom post types. It creates a draft copy with the same content, taxonomies and custom fields.",
            "category": "admin",
            "tags": [
                "duplicate",
                "clone",
                "copy",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_duplicate_link = function ( $actions, $post ) {\n    if ( 'trash' === $post->post_status || 'product' === $post->post_type || ! current_user_can( 'edit_post', $post->ID ) ) {\n        return $actions;\n    }\n\n    $url = wp_nonce_url(\n        add_query_arg( array( 'action' => 'wpide_duplicate_post', 'post' => $post->ID ), admin_url( 'admin.php' ) ),\n        'wpide_duplicate_post_' . $post->ID\n    );\n\n    $actions['wpide_duplicate'] = '<a href=\"' . esc_url( $url ) . '\">Duplicate</a>';\n\n    return $actions;\n};\n\nadd_filter( 'post_row_actions', $wpide_duplicate_link, 10, 2 );\nadd_filter( 'page_row_actions', $wpide_duplicate_link, 10, 2 );\n\nadd_action( 'admin_action_wpide_duplicate_post', function () {\n    $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;\n    check_admin_referer( 'wpide_duplicate_post_' . $post_id );\n\n    $post = get_post( $post_id );\n    $type = $post ? get_post_type_object( $post->post_type ) : null;\n\n    if ( ! $post || ! $type || ! current_user_can( 'edit_post', $post_id ) || ! current_user_can( $type->cap->create_posts ) ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to do that.' ) );\n    }\n\n    $new_id = wp_insert_post( wp_slash( array(\n        'post_title'     => $post->post_title . ' (copy)',\n        'post_content'   => $post->post_content,\n        'post_excerpt'   => $post->post_excerpt,\n        'post_status'    => 'draft',\n        'post_type'      => $post->post_type,\n        'post_author'    => get_current_user_id(),\n        'post_parent'    => $post->post_parent,\n        'menu_order'     => $post->menu_order,\n        'comment_status' => $post->comment_status,\n        'ping_status'    => $post->ping_status,\n        'post_password'  => $post->post_password,\n    ) ), true );\n\n    if ( is_wp_error( $new_id ) ) {\n        wp_die( esc_html( $new_id->get_error_message() ) );\n    }\n\n    foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {\n        $terms = wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) );\n\n        if ( ! is_wp_error( $terms ) && $terms ) {\n            wp_set_object_terms( $new_id, $terms, $taxonomy );\n        }\n    }\n\n    $skip = array( '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date' );\n\n    foreach ( get_post_meta( $post_id ) as $key => $values ) {\n        if ( in_array( $key, $skip, true ) ) {\n            continue;\n        }\n\n        foreach ( $values as $value ) {\n            add_post_meta( $new_id, $key, wp_slash( maybe_unserialize( $value ) ) );\n        }\n    }\n\n    wp_safe_redirect( admin_url( 'post.php?action=edit&post=' . $new_id ) );\n    exit;\n} );",
            "requires": [],
            "notes": "WooCommerce products are skipped because WooCommerce has its own Duplicate action."
        },
        {
            "id": "post-id-column",
            "version": 1,
            "title": "Show the ID Column in Admin Lists",
            "description": "Adds an ID column to the posts, pages and custom post type list tables so you can find IDs without hovering over links.",
            "category": "admin",
            "tags": [
                "columns",
                "id",
                "list-table"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        add_filter( \"manage_{$post_type}_posts_columns\", function ( $columns ) {\n            $columns['wpide_id'] = 'ID';\n\n            return $columns;\n        } );\n\n        add_action( \"manage_{$post_type}_posts_custom_column\", function ( $column, $post_id ) {\n            if ( 'wpide_id' === $column ) {\n                echo (int) $post_id;\n            }\n        }, 10, 2 );\n    }\n} );\n\nadd_action( 'admin_head', function () {\n    echo '<style>.fixed .column-wpide_id{width:64px;}</style>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "featured-image-column",
            "version": 1,
            "title": "Featured Image Column in Admin Lists",
            "description": "Shows a small thumbnail of the featured image next to the title in post lists, for every post type that supports featured images.",
            "category": "admin",
            "tags": [
                "columns",
                "thumbnail",
                "featured-image"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        if ( in_array( $post_type, array( 'attachment', 'product' ), true ) || ! post_type_supports( $post_type, 'thumbnail' ) ) {\n            continue;\n        }\n\n        add_filter( \"manage_{$post_type}_posts_columns\", function ( $columns ) {\n            $first = array_slice( $columns, 0, 1, true );\n\n            return array_merge( $first, array( 'wpide_thumb' => 'Image' ), array_slice( $columns, 1, null, true ) );\n        } );\n\n        add_action( \"manage_{$post_type}_posts_custom_column\", function ( $column, $post_id ) {\n            if ( 'wpide_thumb' !== $column ) {\n                return;\n            }\n\n            $thumbnail = get_the_post_thumbnail( $post_id, array( 56, 56 ) );\n            echo $thumbnail ? $thumbnail : '&mdash;';\n        }, 10, 2 );\n    }\n} );\n\nadd_action( 'admin_head', function () {\n    echo '<style>.fixed .column-wpide_thumb{width:64px;}.column-wpide_thumb img{max-width:56px;height:auto;border-radius:3px;}</style>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "word-count-column",
            "version": 1,
            "title": "Word Count Column for Posts",
            "description": "Adds a Words column to the Posts list showing the approximate length of each post.",
            "category": "admin",
            "tags": [
                "columns",
                "word-count",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_post_posts_columns', function ( $columns ) {\n    $columns['wpide_words'] = 'Words';\n\n    return $columns;\n} );\n\nadd_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_words' !== $column ) {\n        return;\n    }\n\n    $text = wp_strip_all_tags( strip_shortcodes( (string) get_post_field( 'post_content', $post_id ) ) );\n    echo esc_html( number_format_i18n( str_word_count( $text ) ) );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "last-modified-column",
            "version": 1,
            "title": "Sortable Last Modified Column",
            "description": "Adds a sortable \"Last Modified\" column to the Posts and Pages lists so you can see which content was edited most recently.",
            "category": "admin",
            "tags": [
                "columns",
                "modified",
                "sortable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_modified_column = function ( $columns ) {\n    $columns['wpide_modified'] = 'Last Modified';\n\n    return $columns;\n};\n\n$wpide_modified_value = function ( $column, $post_id ) {\n    if ( 'wpide_modified' === $column ) {\n        echo esc_html( get_the_modified_date( '', $post_id ) . ' ' . get_the_modified_time( '', $post_id ) );\n    }\n};\n\n$wpide_modified_sortable = function ( $columns ) {\n    $columns['wpide_modified'] = 'modified';\n\n    return $columns;\n};\n\nadd_filter( 'manage_posts_columns', $wpide_modified_column );\nadd_filter( 'manage_pages_columns', $wpide_modified_column );\nadd_action( 'manage_posts_custom_column', $wpide_modified_value, 10, 2 );\nadd_action( 'manage_pages_custom_column', $wpide_modified_value, 10, 2 );\nadd_filter( 'manage_edit-post_sortable_columns', $wpide_modified_sortable );\nadd_filter( 'manage_edit-page_sortable_columns', $wpide_modified_sortable );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-update-notices-for-non-admins",
            "version": 1,
            "title": "Hide Core Update Notices From Non-Administrators",
            "description": "Stops the \"WordPress x.y is available\" banner from showing to users who cannot update WordPress, so editors and authors are not distracted by it.",
            "category": "admin",
            "tags": [
                "updates",
                "notices",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_head', function () {\n    if ( current_user_can( 'update_core' ) ) {\n        return;\n    }\n\n    remove_action( 'admin_notices', 'update_nag', 3 );\n    remove_action( 'network_admin_notices', 'update_nag', 3 );\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "force-admin-color-scheme",
            "version": 1,
            "title": "Set One Admin Color Scheme for Everyone",
            "description": "Applies the same admin color scheme to all users and hides the color picker on the profile screen. Handy for telling staging and production apart.",
            "category": "admin",
            "tags": [
                "color-scheme",
                "branding",
                "profile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'get_user_option_admin_color', function () {\n    // Built-in schemes: fresh, light, modern, blue, coffee, ectoplasm, midnight, ocean, sunrise.\n    return 'modern';\n} );\n\nadd_action( 'admin_init', function () {\n    remove_action( 'admin_color_scheme_picker', 'admin_color_scheme_picker' );\n} );",
            "requires": [],
            "notes": "Change modern to any built-in scheme name."
        },
        {
            "id": "remove-admin-help-tabs",
            "version": 1,
            "title": "Remove the Help Tab From Admin Screens",
            "description": "Removes the contextual Help tab in the top right corner of admin screens.",
            "category": "admin",
            "tags": [
                "help",
                "clean-up",
                "screen"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_head', function () {\n    $screen = get_current_screen();\n\n    if ( $screen ) {\n        $screen->remove_help_tabs();\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-screen-options-for-non-admins",
            "version": 1,
            "title": "Hide Screen Options for Non-Administrators",
            "description": "Hides the Screen Options tab for everyone except administrators, so other users cannot rearrange or hide admin panels.",
            "category": "admin",
            "tags": [
                "screen-options",
                "roles",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'screen_options_show_screen', function ( $show ) {\n    return current_user_can( 'manage_options' ) ? $show : false;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "environment-badge-in-admin-bar",
            "version": 1,
            "title": "Environment Badge in the Admin Bar",
            "description": "Shows a colored LOCAL, DEVELOPMENT, STAGING or PRODUCTION badge in the admin bar for administrators, based on the WP_ENVIRONMENT_TYPE setting.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "staging",
                "environment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $environment = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production';\n    $colors      = array(\n        'local'       => '#2271b1',\n        'development' => '#2271b1',\n        'staging'     => '#b26200',\n        'production'  => '#d63638',\n    );\n    $color = isset( $colors[ $environment ] ) ? $colors[ $environment ] : '#50575e';\n\n    $admin_bar->add_node( array(\n        'id'     => 'wpide-environment',\n        'parent' => 'top-secondary',\n        'title'  => '<span style=\"background:' . esc_attr( $color ) . ';color:#fff;padding:3px 8px;border-radius:3px;font-weight:600;\">' . esc_html( strtoupper( $environment ) ) . '</span>',\n    ) );\n}, 100 );",
            "requires": [],
            "notes": "Set the environment in wp-config.php, for example: define( 'WP_ENVIRONMENT_TYPE', 'staging' );"
        },
        {
            "id": "replace-howdy-greeting",
            "version": 1,
            "title": "Replace the \"Howdy\" Greeting",
            "description": "Changes the \"Howdy, name\" text in the top right of the admin bar to a greeting of your choice.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "greeting",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    $node = $admin_bar->get_node( 'my-account' );\n\n    if ( ! $node || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $user     = wp_get_current_user();\n    $greeting = 'Welcome back,';\n\n    $admin_bar->add_node( array(\n        'id'    => 'my-account',\n        'title' => esc_html( $greeting ) . ' <span class=\"display-name\">' . esc_html( $user->display_name ) . '</span>' . get_avatar( $user->ID, 26 ),\n    ) );\n}, 9999 );",
            "requires": [],
            "notes": "Edit $greeting."
        },
        {
            "id": "remove-wordpress-logo-from-admin-bar",
            "version": 1,
            "title": "Remove the WordPress Logo From the Admin Bar",
            "description": "Removes the WordPress logo menu (About WordPress, documentation and support links) from the left side of the admin bar.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "logo",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    $admin_bar->remove_node( 'wp-logo' );\n}, 999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "highlight-post-status-rows",
            "version": 1,
            "title": "Color Post Rows by Status",
            "description": "Tints draft, pending, scheduled and private rows in admin list tables so unpublished content stands out at a glance.",
            "category": "admin",
            "tags": [
                "css",
                "list-table",
                "status"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": ".wp-list-table tr.status-draft { background-color: #fcf9e8; }\n.wp-list-table tr.status-pending { background-color: #fcf0f1; }\n.wp-list-table tr.status-future { background-color: #f0f6fc; }\n.wp-list-table tr.status-private { background-color: #f6f7f7; }",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-dashboard-widgets",
            "version": 1,
            "title": "Remove Default Dashboard Widgets",
            "description": "Cleans up the Dashboard by removing the Quick Draft, WordPress Events and News, and Activity widgets.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widgets",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    // Widget ID => context. Delete a line to keep that widget.\n    $widgets = array(\n        'dashboard_quick_press' => 'side',\n        'dashboard_primary'     => 'side',\n        'dashboard_activity'    => 'normal',\n    );\n\n    foreach ( $widgets as $id => $context ) {\n        remove_meta_box( $id, 'dashboard', $context );\n    }\n}, 99 );",
            "requires": [],
            "notes": "Other core widget IDs: dashboard_right_now (At a Glance), dashboard_site_health (Site Health Status)."
        },
        {
            "id": "remove-dashboard-welcome-panel",
            "version": 1,
            "title": "Remove the Dashboard Welcome Panel",
            "description": "Removes the large \"Welcome to WordPress\" panel from the top of the Dashboard for all users.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "welcome",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    remove_action( 'welcome_panel', 'wp_welcome_panel' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "custom-dashboard-welcome-widget",
            "version": 1,
            "title": "Custom Dashboard Welcome Widget",
            "description": "Adds a Dashboard widget with your own message, such as instructions for clients or contact details for support.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "clients"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_welcome_widget', 'Welcome', function () {\n        $email = (string) get_option( 'admin_email' );\n\n        echo '<p>Welcome to your website dashboard. Use the menu on the left to add pages, write posts and upload media.</p>';\n        echo '<p>Questions? Email <a href=\"mailto:' . esc_attr( $email ) . '\">' . esc_html( $email ) . '</a>.</p>';\n    } );\n} );",
            "requires": [],
            "notes": "Edit the widget title and the two paragraphs."
        },
        {
            "id": "scheduled-posts-dashboard-widget",
            "version": 1,
            "title": "Scheduled Posts Dashboard Widget",
            "description": "Adds a Dashboard widget that lists the next ten scheduled posts of any type with their publish dates and edit links.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "scheduled",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_scheduled_posts', 'Scheduled Content', function () {\n        $posts = get_posts( array(\n            'post_type'   => 'any',\n            'post_status' => 'future',\n            'numberposts' => 10,\n            'orderby'     => 'date',\n            'order'       => 'ASC',\n        ) );\n\n        if ( ! $posts ) {\n            echo '<p>Nothing is scheduled right now.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $posts as $post ) {\n            $title = get_the_title( $post );\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> &ndash; %3$s</li>',\n                esc_url( (string) get_edit_post_link( $post->ID ) ),\n                esc_html( '' !== $title ? $title : '(no title)' ),\n                esc_html( get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "custom-post-types-at-a-glance",
            "version": 1,
            "title": "Show Custom Post Types in \"At a Glance\"",
            "description": "Adds published counts for public custom post types (portfolio items, products, events and so on) to the At a Glance Dashboard widget.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "custom-post-types",
                "counts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'dashboard_glance_items', function ( $items ) {\n    $post_types = get_post_types( array( 'public' => true, '_builtin' => false ), 'objects' );\n\n    foreach ( $post_types as $type ) {\n        $counts    = wp_count_posts( $type->name );\n        $published = isset( $counts->publish ) ? (int) $counts->publish : 0;\n        $label     = number_format_i18n( $published ) . ' ' . ( 1 === $published ? $type->labels->singular_name : $type->labels->name );\n\n        if ( current_user_can( $type->cap->edit_posts ) ) {\n            $items[] = sprintf( '<a class=\"%1$s-count\" href=\"%2$s\">%3$s</a>', esc_attr( $type->name ), esc_url( admin_url( 'edit.php?post_type=' . $type->name ) ), esc_html( $label ) );\n        } else {\n            $items[] = sprintf( '<span class=\"%1$s-count\">%2$s</span>', esc_attr( $type->name ), esc_html( $label ) );\n        }\n    }\n\n    return $items;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-site-health-dashboard-widget",
            "version": 1,
            "title": "Remove the Site Health Dashboard Widget",
            "description": "Removes the Site Health Status widget from the Dashboard. The Site Health screen under Tools still works.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "site-health"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'dashboard_site_health', 'dashboard', 'normal' );\n}, 99 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "single-column-dashboard",
            "version": 1,
            "title": "Single Column Dashboard Layout",
            "description": "Forces the Dashboard into a single column for every user, which suits sites with only one or two widgets.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'screen_layout_columns', function ( $columns ) {\n    $columns['dashboard'] = 1;\n\n    return $columns;\n} );\n\nadd_filter( 'get_user_option_screen_layout_dashboard', function () {\n    return 1;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "login-page-logo",
            "version": 1,
            "title": "Use Your Logo on the Login Page",
            "description": "Replaces the WordPress logo on the login screen with your theme logo, your Site Icon, or an image URL you provide.",
            "category": "login",
            "tags": [
                "login",
                "logo",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    $logo_url = ''; // Optional: paste a full image URL here.\n\n    if ( '' === $logo_url ) {\n        $logo_id = (int) get_theme_mod( 'custom_logo' );\n        $logo_url = $logo_id ? (string) wp_get_attachment_image_url( $logo_id, 'medium' ) : '';\n    }\n\n    if ( '' === $logo_url && has_site_icon() ) {\n        $logo_url = get_site_icon_url( 192 );\n    }\n\n    if ( '' === $logo_url ) {\n        return;\n    }\n\n    printf(\n        '<style>.login h1 a{background-image:url(\"%s\");background-size:contain;background-position:center;width:100%%;max-width:240px;height:100px;}</style>',\n        esc_url( $logo_url )\n    );\n} );",
            "requires": [],
            "notes": "Order of preference: the URL in $logo_url, the Customizer logo, then the Site Icon."
        },
        {
            "id": "login-logo-links-to-site",
            "version": 1,
            "title": "Link the Login Logo to Your Site",
            "description": "Makes the logo on the login screen link to your homepage and use your site name as its label, instead of pointing to WordPress.org.",
            "category": "login",
            "tags": [
                "login",
                "logo",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_headerurl', function () {\n    return home_url( '/' );\n} );\n\nadd_filter( 'login_headertext', function () {\n    return get_bloginfo( 'name' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "generic-login-error-message",
            "version": 1,
            "title": "Generic Login Error Message",
            "description": "Shows the same error for a wrong username and a wrong password, so the login form does not reveal which usernames exist.",
            "category": "login",
            "tags": [
                "login",
                "errors",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_errors', function () {\n    return '<strong>Error:</strong> The username, email address or password you entered is incorrect.';\n} );",
            "requires": [],
            "notes": "This replaces every login error, including messages about blocked cookies."
        },
        {
            "id": "login-redirect-by-role",
            "version": 1,
            "title": "Send Non-Editors to the Homepage After Login",
            "description": "Users who cannot edit posts (subscribers, customers) land on the homepage after logging in instead of their admin profile. Staff still go to the dashboard.",
            "category": "login",
            "tags": [
                "login",
                "redirect",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $redirect_to;\n    }\n\n    // Respect explicit front-end destinations such as a \"log in to comment\" link.\n    if ( $requested && false === strpos( $requested, admin_url() ) ) {\n        return $redirect_to;\n    }\n\n    return home_url( '/' );\n}, 10, 3 );",
            "requires": [],
            "notes": "Replace home_url( '/' ) with any URL, for example home_url( '/account/' )."
        },
        {
            "id": "logout-redirect-to-homepage",
            "version": 1,
            "title": "Redirect to the Homepage After Logout",
            "description": "Sends users to your homepage after they log out instead of showing the \"You are now logged out\" login screen.",
            "category": "login",
            "tags": [
                "logout",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'logout_redirect', function ( $redirect_to, $requested ) {\n    return $requested ? $redirect_to : home_url( '/' );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remember-me-checked-by-default",
            "version": 1,
            "title": "Check \"Remember Me\" by Default",
            "description": "Pre-ticks the \"Remember Me\" box on the login form so users stay logged in longer without having to remember to tick it.",
            "category": "login",
            "tags": [
                "login",
                "remember-me"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_footer', function () {\n    echo \"<script>(function(){var box=document.getElementById('rememberme');if(box){box.checked=true;}})();</script>\\n\";\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "longer-remember-me-session",
            "version": 1,
            "title": "Keep \"Remember Me\" Logins for 30 Days",
            "description": "Extends the login cookie to 30 days when \"Remember Me\" is ticked. Sessions without it keep the normal two-day lifetime.",
            "category": "login",
            "tags": [
                "login",
                "session",
                "cookie"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'auth_cookie_expiration', function ( $expiration, $user_id, $remember ) {\n    return $remember ? 30 * DAY_IN_SECONDS : $expiration;\n}, 10, 3 );",
            "requires": [],
            "notes": "Change 30 to the number of days you want."
        },
        {
            "id": "login-with-username-only",
            "version": 1,
            "title": "Log In With Username Only",
            "description": "Stops WordPress from accepting an email address in the login form, so only usernames work.",
            "category": "login",
            "tags": [
                "login",
                "username",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_filter( 'authenticate', 'wp_authenticate_email_password', 20 );\n\nadd_filter( 'gettext', function ( $translation, $text ) {\n    if ( 'Username or Email Address' === $text ) {\n        return 'Username';\n    }\n\n    return $translation;\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "login-with-email-only",
            "version": 1,
            "title": "Log In With Email Address Only",
            "description": "Accepts only an email address and password on the login form. Usernames are rejected, which makes guessing logins harder.",
            "category": "login",
            "tags": [
                "login",
                "email",
                "username"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_filter( 'authenticate', 'wp_authenticate_username_password', 20 );\n\nadd_filter( 'gettext', function ( $translation, $text ) {\n    if ( 'Username or Email Address' === $text ) {\n        return 'Email Address';\n    }\n\n    return $translation;\n}, 10, 2 );",
            "requires": [],
            "notes": "Do not combine with \"Log In With Username Only\", or nobody will be able to log in."
        },
        {
            "id": "hide-login-language-switcher",
            "version": 1,
            "title": "Hide the Login Language Switcher",
            "description": "Removes the language dropdown below the login form on sites that have extra languages installed.",
            "category": "login",
            "tags": [
                "login",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_display_language_dropdown', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-password-reset-for-administrators",
            "version": 1,
            "title": "Block Password Resets for Administrators",
            "description": "Disables the \"Lost your password?\" flow for administrator accounts, which closes off reset-email based takeovers of the most powerful accounts.",
            "category": "login",
            "tags": [
                "password",
                "reset",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'allow_password_reset', function ( $allow, $user_id ) {\n    return user_can( $user_id, 'manage_options' ) ? false : $allow;\n}, 10, 2 );",
            "requires": [],
            "notes": "Administrators can still change their password from their profile, and another administrator or WP-CLI can reset it."
        },
        {
            "id": "login-page-brand-colors",
            "version": 1,
            "title": "Brand Colors on the Login Page",
            "description": "Restyles the login screen with your own background, button and link colors.",
            "category": "login",
            "tags": [
                "login",
                "branding",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    $background = '#f3f4f6';\n    $accent     = '#1d4ed8';\n    $text       = '#111827';\n\n    printf(\n        '<style>\n        body.login{background:%1$s;}\n        .login #nav a,.login #backtoblog a{color:%3$s;}\n        .login #nav a:hover,.login #backtoblog a:hover{color:%2$s;}\n        .wp-core-ui .button-primary{background:%2$s;border-color:%2$s;}\n        .wp-core-ui .button-primary:hover,.wp-core-ui .button-primary:focus{background:%3$s;border-color:%3$s;}\n        .login input[type=text]:focus,.login input[type=password]:focus{border-color:%2$s;box-shadow:0 0 0 1px %2$s;}\n        </style>',\n        esc_attr( $background ),\n        esc_attr( $accent ),\n        esc_attr( $text )\n    );\n} );",
            "requires": [],
            "notes": "Edit the three color variables."
        },
        {
            "id": "last-login-column",
            "version": 1,
            "title": "Track Last Login and Show It in the Users List",
            "description": "Records when each user last logged in and adds a sortable \"Last Login\" column to the Users screen.",
            "category": "users",
            "tags": [
                "users",
                "login",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_login', function ( $user_login, $user ) {\n    update_user_meta( $user->ID, 'wpide_last_login', time() );\n}, 10, 2 );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_last_login'] = 'Last Login';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_last_login' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_last_login', true );\n\n    return $time ? esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $time ) ) : '&mdash;';\n}, 10, 3 );\n\nadd_filter( 'manage_users_sortable_columns', function ( $columns ) {\n    $columns['wpide_last_login'] = 'wpide_last_login';\n\n    return $columns;\n} );\n\nadd_action( 'pre_get_users', function ( $query ) {\n    if ( is_admin() && 'wpide_last_login' === $query->get( 'orderby' ) ) {\n        $query->set( 'meta_key', 'wpide_last_login' );\n        $query->set( 'orderby', 'meta_value_num' );\n    }\n} );",
            "requires": [],
            "notes": "Logins are only recorded from the moment the snippet is activated."
        },
        {
            "id": "registration-date-column",
            "version": 1,
            "title": "Registration Date Column in the Users List",
            "description": "Adds a sortable \"Registered\" column to the Users screen showing when each account was created.",
            "category": "users",
            "tags": [
                "users",
                "columns",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_registered'] = 'Registered';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_registered' !== $column ) {\n        return $output;\n    }\n\n    $user = get_userdata( $user_id );\n\n    return $user ? esc_html( get_date_from_gmt( $user->user_registered, get_option( 'date_format' ) ) ) : $output;\n}, 10, 3 );\n\nadd_filter( 'manage_users_sortable_columns', function ( $columns ) {\n    $columns['wpide_registered'] = 'registered';\n\n    return $columns;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "user-id-column",
            "version": 1,
            "title": "User ID Column in the Users List",
            "description": "Adds an ID column to the Users screen, which is handy when a plugin or shortcode asks for a user ID.",
            "category": "users",
            "tags": [
                "users",
                "columns",
                "id"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $ordered = array();\n\n    foreach ( $columns as $key => $label ) {\n        $ordered[ $key ] = $label;\n\n        if ( 'cb' === $key ) {\n            $ordered['wpide_user_id'] = 'ID';\n        }\n    }\n\n    if ( ! isset( $ordered['wpide_user_id'] ) ) {\n        $ordered['wpide_user_id'] = 'ID';\n    }\n\n    return $ordered;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    return 'wpide_user_id' === $column ? (string) (int) $user_id : $output;\n}, 10, 3 );\n\nadd_action( 'admin_head-users.php', function () {\n    echo '<style>.fixed .column-wpide_user_id{width:56px;}</style>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "allow-contributors-to-upload",
            "version": 1,
            "title": "Allow Contributors to Upload Media",
            "description": "Lets users who can write posts but not upload files (the Contributor role) add images to their drafts. The role itself is not changed in the database.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "uploads",
                "contributors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'user_has_cap', function ( $allcaps ) {\n    if ( ! empty( $allcaps['edit_posts'] ) && empty( $allcaps['upload_files'] ) ) {\n        $allcaps['upload_files'] = true;\n    }\n\n    return $allcaps;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-administrators-from-user-list",
            "version": 1,
            "title": "Hide Administrators From Other Users",
            "description": "Removes administrator accounts from the Users screen for anyone who is not an administrator, for example shop managers on a WooCommerce site.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'pre_get_users', function ( $query ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $excluded   = (array) $query->get( 'role__not_in' );\n    $excluded[] = 'administrator';\n\n    $query->set( 'role__not_in', array_unique( $excluded ) );\n} );",
            "requires": [],
            "notes": "The role counts above the list are not changed."
        },
        {
            "id": "social-profile-fields",
            "version": 1,
            "title": "Social Profile Fields for Users",
            "description": "Adds LinkedIn, X and Instagram fields to the Contact Info section of user profiles, ready to show in author boxes.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'user_contactmethods', function ( $methods ) {\n    $methods['linkedin']  = 'LinkedIn profile URL';\n    $methods['x_profile'] = 'X (Twitter) profile URL';\n    $methods['instagram'] = 'Instagram profile URL';\n\n    return $methods;\n} );",
            "requires": [],
            "notes": "Read a value in your theme with get_the_author_meta( 'linkedin' )."
        },
        {
            "id": "redirect-after-registration",
            "version": 1,
            "title": "Redirect New Users After Registration",
            "description": "Sends people to a page of your choice (for example a welcome page) after they register through wp-login.php.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'registration_redirect', function () {\n    return home_url( '/welcome/' );\n} );",
            "requires": [],
            "notes": "Change /welcome/ to the path of your page."
        },
        {
            "id": "minimum-password-length",
            "version": 1,
            "title": "Require a Minimum Password Length",
            "description": "Rejects new passwords shorter than 12 characters on the profile screen, the Add New User screen and the password reset form.",
            "category": "users",
            "tags": [
                "users",
                "password",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_min_password_length = 12;\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_min_password_length ) {\n    if ( ! empty( $user->user_pass ) && strlen( $user->user_pass ) < $wpide_min_password_length ) {\n        $errors->add( 'pass', sprintf( '<strong>Error:</strong> Passwords must be at least %d characters long.', $wpide_min_password_length ) );\n    }\n}, 10, 3 );\n\nadd_action( 'validate_password_reset', function ( $errors ) use ( $wpide_min_password_length ) {\n    $password = isset( $_POST['pass1'] ) && is_string( $_POST['pass1'] ) ? wp_unslash( $_POST['pass1'] ) : '';\n\n    if ( '' !== $password && strlen( $password ) < $wpide_min_password_length ) {\n        $errors->add( 'pass', sprintf( '<strong>Error:</strong> Passwords must be at least %d characters long.', $wpide_min_password_length ) );\n    }\n} );",
            "requires": [],
            "notes": "Change 12 to your preferred minimum. WooCommerce account forms use their own strength meter."
        },
        {
            "id": "disable-xmlrpc",
            "version": 1,
            "title": "Disable XML-RPC",
            "description": "Turns off the XML-RPC interface (xmlrpc.php), a common target for brute-force and pingback abuse. Use it when no app or service needs XML-RPC.",
            "category": "security",
            "tags": [
                "security",
                "xml-rpc",
                "pingback"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'xmlrpc_enabled', '__return_false' );\n\nadd_filter( 'xmlrpc_methods', function () {\n    return array();\n}, 999 );\n\nadd_filter( 'wp_headers', function ( $headers ) {\n    unset( $headers['X-Pingback'] );\n\n    return $headers;\n} );\n\nremove_action( 'wp_head', 'rsd_link' );",
            "requires": [],
            "notes": "The WordPress mobile apps and some services (for example Jetpack) rely on XML-RPC."
        },
        {
            "id": "hide-wordpress-version",
            "version": 1,
            "title": "Hide the WordPress Version Number",
            "description": "Removes the generator meta tag and the version from feeds, so the exact WordPress version is not advertised in your page source.",
            "category": "security",
            "tags": [
                "security",
                "version",
                "generator"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_action( 'wp_head', 'wp_generator' );\nadd_filter( 'the_generator', '__return_empty_string' );",
            "requires": [],
            "notes": "Hiding the version is not a replacement for keeping WordPress updated."
        },
        {
            "id": "remove-core-version-from-assets",
            "version": 1,
            "title": "Remove the Core Version From Script and Style URLs",
            "description": "Strips ?ver= from front-end CSS and JavaScript URLs when it equals the WordPress version, hiding it from the page source. Plugin and theme versions are left alone.",
            "category": "security",
            "tags": [
                "security",
                "version",
                "assets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_strip_core_version = function ( $src ) {\n    if ( ! is_string( $src ) || false === strpos( $src, 'ver=' ) ) {\n        return $src;\n    }\n\n    $query = (string) wp_parse_url( $src, PHP_URL_QUERY );\n    parse_str( $query, $args );\n\n    if ( isset( $args['ver'] ) && get_bloginfo( 'version' ) === $args['ver'] ) {\n        $src = remove_query_arg( 'ver', $src );\n    }\n\n    return $src;\n};\n\nadd_filter( 'style_loader_src', $wpide_strip_core_version, 9999 );\nadd_filter( 'script_loader_src', $wpide_strip_core_version, 9999 );",
            "requires": [],
            "notes": "After a WordPress update, browsers may keep cached core files a little longer."
        },
        {
            "id": "block-user-enumeration",
            "version": 1,
            "title": "Block User Enumeration",
            "description": "Stops visitors from discovering usernames through ?author=N URLs, the public REST API users endpoint, the users sitemap and oEmbed data.",
            "category": "security",
            "tags": [
                "security",
                "users",
                "enumeration",
                "rest-api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_user_logged_in() && isset( $_GET['author'] ) ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n}, 1 );\n\nadd_filter( 'rest_endpoints', function ( $endpoints ) {\n    if ( is_user_logged_in() ) {\n        return $endpoints;\n    }\n\n    foreach ( array_keys( $endpoints ) as $route ) {\n        if ( 0 === strpos( $route, '/wp/v2/users' ) ) {\n            unset( $endpoints[ $route ] );\n        }\n    }\n\n    return $endpoints;\n} );\n\nadd_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {\n    return 'users' === $name ? false : $provider;\n}, 10, 2 );\n\nadd_filter( 'oembed_response_data', function ( $data ) {\n    unset( $data['author_name'], $data['author_url'] );\n\n    return $data;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "rest-api-logged-in-only",
            "version": 1,
            "title": "Restrict the REST API to Logged-In Users",
            "description": "Returns an authentication error for REST API requests from visitors who are not logged in, with an allowlist for routes your site needs publicly.",
            "category": "security",
            "tags": [
                "security",
                "rest-api",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'rest_authentication_errors', function ( $result ) {\n    if ( true === $result || is_wp_error( $result ) || is_user_logged_in() ) {\n        return $result;\n    }\n\n    // Route prefixes that stay public. Add the namespaces your forms, shop or plugins use.\n    $public = array( '/contact-form-7/', '/wc/store/', '/oembed/' );\n    $route  = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? (string) $GLOBALS['wp']->query_vars['rest_route'] : '';\n\n    foreach ( $public as $prefix ) {\n        if ( 0 === strpos( $route, $prefix ) ) {\n            return $result;\n        }\n    }\n\n    return new WP_Error( 'rest_login_required', 'The REST API is only available to logged-in users.', array( 'status' => rest_authorization_required_code() ) );\n} );",
            "requires": [],
            "notes": "Test forms, search and checkout after enabling. Plugins that call the REST API for visitors need their namespace in $public."
        },
        {
            "id": "disable-theme-plugin-file-editor",
            "version": 1,
            "title": "Disable the Theme and Plugin File Editors",
            "description": "Turns off the built-in Theme File Editor and Plugin File Editor screens, so a hijacked admin account cannot inject code through them.",
            "category": "security",
            "tags": [
                "security",
                "file-editor",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'DISALLOW_FILE_EDIT' ) ) {\n    define( 'DISALLOW_FILE_EDIT', true );\n}",
            "requires": [],
            "notes": "This only affects the WordPress core editors under Appearance and Plugins."
        },
        {
            "id": "security-headers",
            "version": 1,
            "title": "Send Basic Security Headers",
            "description": "Adds common HTTP security headers to front-end responses: nosniff, same-origin framing, a strict referrer policy and a restrictive permissions policy.",
            "category": "security",
            "tags": [
                "security",
                "headers",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'send_headers', function () {\n    if ( headers_sent() ) {\n        return;\n    }\n\n    header( 'X-Content-Type-Options: nosniff' );\n    header( 'X-Frame-Options: SAMEORIGIN' );\n    header( 'Referrer-Policy: strict-origin-when-cross-origin' );\n    header( 'Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=()' );\n    header_remove( 'X-Powered-By' );\n} );",
            "requires": [],
            "notes": "If you embed your own pages in iframes on another domain, remove the X-Frame-Options line. Headers set by your server or CDN may override these."
        },
        {
            "id": "hsts-header",
            "version": 1,
            "title": "Enable HTTP Strict Transport Security (HSTS)",
            "description": "Tells browsers to always use HTTPS for your domain for one year. Only enable it once the whole site, including subdomains you use, works over HTTPS.",
            "category": "security",
            "tags": [
                "security",
                "https",
                "hsts",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'send_headers', function () {\n    if ( is_ssl() && ! headers_sent() ) {\n        header( 'Strict-Transport-Security: max-age=31536000' );\n    }\n} );",
            "requires": [],
            "notes": "Browsers remember this header for the whole max-age, even if you disable the snippet later."
        },
        {
            "id": "disable-application-passwords",
            "version": 1,
            "title": "Disable Application Passwords",
            "description": "Removes the Application Passwords feature, which lets external apps authenticate to the REST API. Disable it if you do not use it.",
            "category": "security",
            "tags": [
                "security",
                "application-passwords",
                "rest-api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_is_application_passwords_available', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "limit-login-attempts",
            "version": 1,
            "title": "Limit Failed Login Attempts",
            "description": "Temporarily blocks an IP address after 5 failed logins within 15 minutes. A lightweight brute-force guard with no settings screen.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_login_limit = array(\n    'attempts' => 5,\n    'window'   => 15 * MINUTE_IN_SECONDS,\n);\n\n$wpide_login_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';\n\n    return 'wpide_login_fail_' . md5( $ip );\n};\n\nadd_filter( 'authenticate', function ( $user, $username ) use ( $wpide_login_limit, $wpide_login_key ) {\n    if ( empty( $username ) ) {\n        return $user;\n    }\n\n    if ( (int) get_transient( $wpide_login_key() ) >= $wpide_login_limit['attempts'] ) {\n        return new WP_Error(\n            'wpide_too_many_attempts',\n            sprintf( '<strong>Error:</strong> Too many failed login attempts. Please try again in %d minutes.', (int) ceil( $wpide_login_limit['window'] / MINUTE_IN_SECONDS ) )\n        );\n    }\n\n    return $user;\n}, 99, 2 );\n\nadd_action( 'wp_login_failed', function ( $username, $error = null ) use ( $wpide_login_limit, $wpide_login_key ) {\n    if ( is_wp_error( $error ) && 'wpide_too_many_attempts' === $error->get_error_code() ) {\n        return;\n    }\n\n    $key = $wpide_login_key();\n    set_transient( $key, (int) get_transient( $key ) + 1, $wpide_login_limit['window'] );\n}, 10, 2 );\n\nadd_action( 'wp_login', function () use ( $wpide_login_key ) {\n    delete_transient( $wpide_login_key() );\n} );",
            "requires": [],
            "notes": "Behind a proxy or CDN, REMOTE_ADDR may be the proxy address; configure your server to pass the real visitor IP. Do not combine with another login limiter."
        },
        {
            "id": "administrator-login-alert",
            "version": 1,
            "title": "Email Alert When an Administrator Logs In",
            "description": "Sends an email to the site admin address every time an administrator account logs in, with the time, IP address and browser.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "email",
                "alerts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_login', function ( $user_login, $user ) {\n    if ( ! user_can( $user, 'manage_options' ) ) {\n        return;\n    }\n\n    $ip      = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';\n    $browser = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : 'unknown';\n    $site    = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n\n    $subject = sprintf( '[%s] Administrator login: %s', $site, $user->user_login );\n    $message = sprintf(\n        \"The administrator account \\\"%s\\\" just logged in to %s.\\n\\nTime: %s\\nIP address: %s\\nBrowser: %s\\n\\nIf you do not recognize this login, change the password and review your users.\",\n        $user->user_login,\n        home_url( '/' ),\n        wp_date( 'Y-m-d H:i:s T' ),\n        $ip,\n        $browser\n    );\n\n    wp_mail( get_option( 'admin_email' ), $subject, $message );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-plugin-theme-installs",
            "version": 1,
            "title": "Prevent Installing New Plugins and Themes",
            "description": "Removes the ability to install or upload new plugins and themes for every user, while still allowing updates of what is already installed.",
            "category": "security",
            "tags": [
                "security",
                "plugins",
                "themes",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap ) {\n    if ( in_array( $cap, array( 'install_plugins', 'install_themes', 'upload_plugins', 'upload_themes' ), true ) ) {\n        return array( 'do_not_allow' );\n    }\n\n    return $caps;\n}, 10, 2 );",
            "requires": [],
            "notes": "Deactivate the snippet when you need to install something."
        },
        {
            "id": "restrict-unfiltered-html",
            "version": 1,
            "title": "Restrict Unfiltered HTML to Administrators",
            "description": "Only administrators may save raw HTML such as scripts and iframes in posts. Editors get the same safe HTML filtering as authors.",
            "category": "security",
            "tags": [
                "security",
                "html",
                "roles",
                "editors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap, $user_id ) {\n    if ( 'unfiltered_html' === $cap && ! user_can( $user_id, 'manage_options' ) ) {\n        return array( 'do_not_allow' );\n    }\n\n    return $caps;\n}, 10, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-emojis",
            "version": 1,
            "title": "Disable the Emoji Script and Styles",
            "description": "Removes the emoji detection script, inline styles and DNS prefetch that WordPress adds to every page. Browsers still display emoji natively.",
            "category": "performance",
            "tags": [
                "performance",
                "emoji",
                "scripts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'wp_head', 'print_emoji_detection_script', 7 );\n    remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );\n    remove_action( 'wp_print_styles', 'print_emoji_styles' );\n    remove_action( 'admin_print_styles', 'print_emoji_styles' );\n    remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );\n    remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );\n    remove_filter( 'the_content_feed', 'wp_staticize_emoji' );\n    remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );\n    remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );\n} );\n\nadd_filter( 'tiny_mce_plugins', function ( $plugins ) {\n    return is_array( $plugins ) ? array_diff( $plugins, array( 'wpemoji' ) ) : $plugins;\n} );\n\nadd_filter( 'wp_resource_hints', function ( $urls, $relation_type ) {\n    if ( 'dns-prefetch' !== $relation_type ) {\n        return $urls;\n    }\n\n    return array_filter( $urls, function ( $url ) {\n        return ! is_string( $url ) || false === strpos( $url, 's.w.org/images/core/emoji' );\n    } );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-oembed-discovery",
            "version": 1,
            "title": "Disable oEmbed Discovery and the Embed Script",
            "description": "Stops your site from acting as an oEmbed provider: removes the discovery links, the embed REST route and the wp-embed script. Embedding YouTube and similar services keeps working.",
            "category": "performance",
            "tags": [
                "performance",
                "oembed",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );\n    remove_action( 'wp_head', 'wp_oembed_add_host_js' );\n    remove_action( 'rest_api_init', 'wp_oembed_register_route' );\n}, 9999 );\n\nadd_filter( 'embed_oembed_discover', '__return_false' );\n\nadd_action( 'wp_footer', function () {\n    wp_deregister_script( 'wp-embed' );\n} );",
            "requires": [],
            "notes": "Other WordPress sites will show a plain link instead of a rich card when they embed your posts."
        },
        {
            "id": "remove-jquery-migrate",
            "version": 1,
            "title": "Remove jQuery Migrate on the Front End",
            "description": "Drops the jQuery Migrate compatibility script from public pages. Only use it when your theme and plugins work with current jQuery.",
            "category": "performance",
            "tags": [
                "performance",
                "jquery",
                "scripts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_default_scripts', function ( $scripts ) {\n    if ( is_admin() || empty( $scripts->registered['jquery'] ) ) {\n        return;\n    }\n\n    $scripts->registered['jquery']->deps = array_diff( $scripts->registered['jquery']->deps, array( 'jquery-migrate' ) );\n} );",
            "requires": [],
            "notes": "Check the browser console for jQuery errors after enabling."
        },
        {
            "id": "control-heartbeat",
            "version": 1,
            "title": "Slow Down the Heartbeat API",
            "description": "Reduces admin AJAX traffic by running the Heartbeat API every 60 seconds instead of every 15 to 60, and turns it off on the front end.",
            "category": "performance",
            "tags": [
                "performance",
                "heartbeat",
                "ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'heartbeat_settings', function ( $settings ) {\n    $settings['interval'] = 60;\n\n    return $settings;\n} );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    wp_deregister_script( 'heartbeat' );\n}, 100 );",
            "requires": [],
            "notes": "Post locking and login expiry warnings still work, just with slower updates. Remove the wp_enqueue_scripts part if a front-end editor needs Heartbeat."
        },
        {
            "id": "limit-post-revisions",
            "version": 1,
            "title": "Limit Post Revisions",
            "description": "Keeps only the five most recent revisions of each post, which stops the database from growing with old copies.",
            "category": "performance",
            "tags": [
                "performance",
                "revisions",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_revisions_to_keep', function ( $num, $post ) {\n    return 5;\n}, 10, 2 );",
            "requires": [],
            "notes": "Return 0 to disable revisions or -1 to keep all. Existing extra revisions are removed the next time a post is saved."
        },
        {
            "id": "autosave-interval",
            "version": 1,
            "title": "Change the Autosave Interval",
            "description": "Autosaves drafts every two minutes instead of every minute, in both the block editor and the classic editor.",
            "category": "performance",
            "tags": [
                "performance",
                "autosave",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_autosave_seconds = 120;\n\nif ( ! defined( 'AUTOSAVE_INTERVAL' ) ) {\n    define( 'AUTOSAVE_INTERVAL', $wpide_autosave_seconds );\n}\n\nadd_filter( 'block_editor_settings_all', function ( $settings ) use ( $wpide_autosave_seconds ) {\n    $settings['autosaveInterval'] = $wpide_autosave_seconds;\n\n    return $settings;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "defer-selected-scripts",
            "version": 1,
            "title": "Defer Selected Scripts",
            "description": "Adds the defer attribute to the front-end scripts you list by handle, so they no longer block page rendering.",
            "category": "performance",
            "tags": [
                "performance",
                "javascript",
                "defer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'script_loader_tag', function ( $tag, $handle ) {\n    // Script handles that are safe to defer.\n    $handles = array( 'comment-reply' );\n\n    if ( ! in_array( $handle, $handles, true ) || preg_match( '/\\s(defer|async)[\\s>=]/', $tag ) ) {\n        return $tag;\n    }\n\n    return str_replace( ' src=', ' defer src=', $tag );\n}, 10, 2 );",
            "requires": [],
            "notes": "Only defer scripts that no inline script depends on immediately. Find handles with a plugin such as Query Monitor."
        },
        {
            "id": "remove-dashicons-for-visitors",
            "version": 1,
            "title": "Remove Dashicons for Logged-Out Visitors",
            "description": "Stops the Dashicons icon font from loading on the front end for visitors, saving a request. Logged-in users still get it for the admin bar.",
            "category": "performance",
            "tags": [
                "performance",
                "dashicons",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! is_user_logged_in() ) {\n        wp_dequeue_style( 'dashicons' );\n    }\n}, 100 );",
            "requires": [],
            "notes": "If your theme uses Dashicons, they are still loaded as a dependency."
        },
        {
            "id": "clean-up-wp-head",
            "version": 1,
            "title": "Clean Up the Page Head",
            "description": "Removes rarely used tags from the page head: RSD, Windows Live Writer manifest, shortlink, adjacent post links and the REST API discovery link.",
            "category": "performance",
            "tags": [
                "performance",
                "head",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_action( 'wp_head', 'rsd_link' );\nremove_action( 'wp_head', 'wlwmanifest_link' );\nremove_action( 'wp_head', 'wp_shortlink_wp_head', 10 );\nremove_action( 'template_redirect', 'wp_shortlink_header', 11 );\nremove_action( 'wp_head', 'adjacent_posts_rel_link_wp_head', 10 );\nremove_action( 'wp_head', 'rest_output_link_wp_head', 10 );\nremove_action( 'template_redirect', 'rest_output_link_header', 11 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-block-library-css",
            "version": 1,
            "title": "Remove Block Styles on Classic Themes",
            "description": "Stops the block library, classic theme and global styles stylesheets from loading on the front end. For classic themes and page builders that do not use blocks.",
            "category": "performance",
            "tags": [
                "performance",
                "css",
                "blocks",
                "classic-theme"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( function_exists( 'wp_is_block_theme' ) && wp_is_block_theme() ) {\n        return;\n    }\n\n    wp_dequeue_style( 'wp-block-library' );\n    wp_dequeue_style( 'wp-block-library-theme' );\n    wp_dequeue_style( 'classic-theme-styles' );\n    wp_dequeue_style( 'global-styles' );\n}, 100 );",
            "requires": [],
            "notes": "Content made with blocks will lose its default styling. Block themes are skipped automatically."
        },
        {
            "id": "preconnect-resource-hints",
            "version": 1,
            "title": "Preconnect to Third-Party Origins",
            "description": "Adds preconnect hints so the browser opens connections to external hosts (Google Fonts by default) earlier.",
            "category": "performance",
            "tags": [
                "performance",
                "preconnect",
                "fonts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_resource_hints', function ( $urls, $relation_type ) {\n    if ( 'preconnect' !== $relation_type ) {\n        return $urls;\n    }\n\n    // Origins your pages load files from.\n    $origins = array( 'https://fonts.googleapis.com', 'https://fonts.gstatic.com' );\n\n    foreach ( $origins as $origin ) {\n        $urls[] = array( 'href' => $origin, 'crossorigin' => 'anonymous' );\n    }\n\n    return $urls;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only list origins that every page actually uses."
        },
        {
            "id": "lazy-load-threshold",
            "version": 1,
            "title": "Do Not Lazy-Load the First Images",
            "description": "Tells WordPress to skip lazy loading for the first three images on a page, which usually improves Largest Contentful Paint.",
            "category": "performance",
            "tags": [
                "performance",
                "lazy-load",
                "images",
                "lcp"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_omit_loading_attr_threshold', function () {\n    return 3;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "prerender-on-hover",
            "version": 1,
            "title": "Prerender Links on Hover",
            "description": "Upgrades WordPress speculative loading (6.8+) from prefetch to prerender with moderate eagerness, so pages open almost instantly after a hover.",
            "category": "performance",
            "tags": [
                "performance",
                "speculative-loading",
                "prerender"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_speculation_rules_configuration', function ( $config ) {\n    if ( is_array( $config ) ) {\n        $config['mode']      = 'prerender';\n        $config['eagerness'] = 'moderate';\n    }\n\n    return $config;\n} );",
            "requires": [],
            "notes": "Prerendering runs page scripts early; analytics tools that are not prerender-aware may count extra page views."
        },
        {
            "id": "disable-comments-completely",
            "version": 1,
            "title": "Disable Comments Completely",
            "description": "Turns comments off everywhere: closes comments and pings, hides existing comments, and removes the Comments menu, admin bar item and comment feed links.",
            "category": "disable-features",
            "tags": [
                "comments",
                "disable",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    foreach ( get_post_types() as $post_type ) {\n        if ( post_type_supports( $post_type, 'comments' ) ) {\n            remove_post_type_support( $post_type, 'comments' );\n            remove_post_type_support( $post_type, 'trackbacks' );\n        }\n    }\n}, 100 );\n\nadd_filter( 'comments_open', '__return_false', 20 );\nadd_filter( 'pings_open', '__return_false', 20 );\nadd_filter( 'comments_array', '__return_empty_array', 20 );\nadd_filter( 'feed_links_show_comments_feed', '__return_false' );\n\nadd_action( 'admin_menu', function () {\n    remove_menu_page( 'edit-comments.php' );\n} );\n\nadd_action( 'admin_init', function () {\n    global $pagenow;\n\n    if ( 'edit-comments.php' === $pagenow ) {\n        wp_safe_redirect( admin_url() );\n        exit;\n    }\n} );\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) {\n    $admin_bar->remove_node( 'comments' );\n}, 999 );\n\nadd_filter( 'wp_headers', function ( $headers ) {\n    unset( $headers['X-Pingback'] );\n\n    return $headers;\n} );",
            "requires": [],
            "notes": "Existing comments stay in the database and come back if you deactivate the snippet."
        },
        {
            "id": "disable-all-automatic-updates",
            "version": 1,
            "title": "Disable All Automatic Updates",
            "description": "Turns off every background update: WordPress core, plugins, themes and translations. For sites where updates are deployed by hand or through version control.",
            "category": "disable-features",
            "tags": [
                "updates",
                "auto-updates",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'automatic_updater_disabled', '__return_true' );",
            "requires": [],
            "notes": "This also stops automatic security releases. Make sure you have another update process."
        },
        {
            "id": "disable-plugin-theme-auto-updates",
            "version": 1,
            "title": "Disable Plugin and Theme Auto-Updates",
            "description": "Prevents plugins and themes from updating automatically and hides the auto-update toggles. Core security releases keep installing.",
            "category": "disable-features",
            "tags": [
                "updates",
                "auto-updates",
                "plugins",
                "themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'auto_update_plugin', '__return_false' );\nadd_filter( 'auto_update_theme', '__return_false' );\nadd_filter( 'plugins_auto_update_enabled', '__return_false' );\nadd_filter( 'themes_auto_update_enabled', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-rss-feeds",
            "version": 1,
            "title": "Disable RSS Feeds",
            "description": "Redirects every feed URL to the homepage and removes feed links from the page head. Useful for brochure sites and web apps that do not publish posts.",
            "category": "disable-features",
            "tags": [
                "rss",
                "feeds",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_feed_redirect = function () {\n    wp_safe_redirect( home_url( '/' ), 301 );\n    exit;\n};\n\nforeach ( array( 'do_feed', 'do_feed_rdf', 'do_feed_rss', 'do_feed_rss2', 'do_feed_atom', 'do_feed_rss2_comments', 'do_feed_atom_comments' ) as $wpide_feed_hook ) {\n    add_action( $wpide_feed_hook, $wpide_feed_redirect, 1 );\n}\n\nremove_action( 'wp_head', 'feed_links', 2 );\nremove_action( 'wp_head', 'feed_links_extra', 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-site-search",
            "version": 1,
            "title": "Disable Site Search",
            "description": "Turns search off: search URLs return a 404 page, the search form is emptied and the classic Search widget is removed.",
            "category": "disable-features",
            "tags": [
                "search",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( is_search() && ! is_admin() ) {\n        $wp_query->set_404();\n        status_header( 404 );\n        nocache_headers();\n    }\n} );\n\nadd_filter( 'get_search_form', '__return_empty_string', 999 );\n\nadd_action( 'widgets_init', function () {\n    unregister_widget( 'WP_Widget_Search' );\n}, 11 );",
            "requires": [],
            "notes": "Search blocks placed in block themes still render; remove them from your templates."
        },
        {
            "id": "disable-admin-email-verification",
            "version": 1,
            "title": "Disable the Admin Email Check Screen",
            "description": "Stops the periodic \"Administration email verification\" screen that administrators see after logging in.",
            "category": "disable-features",
            "tags": [
                "admin-email",
                "disable",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'admin_email_check_interval', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-lazy-loading",
            "version": 1,
            "title": "Disable Native Lazy Loading",
            "description": "Removes the loading=\"lazy\" attribute WordPress adds to images and iframes. Use it when another plugin handles lazy loading or it conflicts with a slider.",
            "category": "disable-features",
            "tags": [
                "lazy-load",
                "images",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_lazy_loading_enabled', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-admin-bar-for-everyone",
            "version": 1,
            "title": "Disable the Admin Bar for Everyone",
            "description": "Hides the front-end admin bar for all users, including administrators. The admin area is not affected.",
            "category": "disable-features",
            "tags": [
                "admin-bar",
                "toolbar",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'show_admin_bar', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-speculative-loading",
            "version": 1,
            "title": "Disable Speculative Loading",
            "description": "Turns off the speculative prefetching of links that WordPress 6.8 adds to the front end, for sites with heavy pages or strict analytics.",
            "category": "disable-features",
            "tags": [
                "speculative-loading",
                "prefetch",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_speculation_rules_configuration', '__return_null' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-post-tags",
            "version": 1,
            "title": "Disable Tags for Posts",
            "description": "Removes the Tags taxonomy from posts: the Tags menu, the tag box in the editor and the tag column disappear.",
            "category": "disable-features",
            "tags": [
                "tags",
                "taxonomy",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    unregister_taxonomy_for_object_type( 'post_tag', 'post' );\n}, 20 );",
            "requires": [],
            "notes": "Existing tags stay in the database."
        },
        {
            "id": "disable-capital-p-dangit",
            "version": 1,
            "title": "Stop Changing \"Wordpress\" to \"WordPress\"",
            "description": "Disables the filter that silently rewrites the spelling of WordPress in titles, content and comments, which can break URLs or quoted text.",
            "category": "disable-features",
            "tags": [
                "formatting",
                "content",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "foreach ( array( 'the_content', 'the_title', 'wp_title', 'document_title', 'comment_text', 'widget_text_content' ) as $wpide_filter ) {\n    $wpide_priority = has_filter( $wpide_filter, 'capital_P_dangit' );\n\n    if ( false !== $wpide_priority ) {\n        remove_filter( $wpide_filter, 'capital_P_dangit', $wpide_priority );\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-self-pingbacks",
            "version": 1,
            "title": "Disable Self-Pingbacks",
            "description": "Stops WordPress from sending pingbacks to your own site when you link to your own posts, so they do not clutter the comment queue.",
            "category": "comments",
            "tags": [
                "comments",
                "pingbacks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_ping', function ( &$links ) {\n    $home = home_url();\n\n    foreach ( $links as $index => $link ) {\n        if ( 0 === strpos( $link, $home ) ) {\n            unset( $links[ $index ] );\n        }\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-comment-website-field",
            "version": 1,
            "title": "Remove the Website Field From Comments",
            "description": "Removes the Website (URL) field from the comment form, which discourages comments posted only to get a link.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "form"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_default_fields', function ( $fields ) {\n    unset( $fields['url'] );\n\n    return $fields;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "comment-length-limits",
            "version": 1,
            "title": "Minimum and Maximum Comment Length",
            "description": "Rejects comments shorter than 15 characters or longer than 5,000 characters. Moderators are not limited.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'preprocess_comment', function ( $commentdata ) {\n    $min = 15;\n    $max = 5000;\n\n    $type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : '';\n\n    if ( ( '' !== $type && 'comment' !== $type ) || current_user_can( 'moderate_comments' ) ) {\n        return $commentdata;\n    }\n\n    $text   = trim( wp_strip_all_tags( (string) $commentdata['comment_content'] ) );\n    $length = function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text );\n\n    if ( $length < $min ) {\n        wp_die( esc_html( sprintf( 'Your comment is too short. Please write at least %d characters.', $min ) ), 'Comment too short', array( 'response' => 400, 'back_link' => true ) );\n    }\n\n    if ( $length > $max ) {\n        wp_die( esc_html( sprintf( 'Your comment is too long. Please keep it under %d characters.', $max ) ), 'Comment too long', array( 'response' => 400, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-pingbacks-trackbacks",
            "version": 1,
            "title": "Disable Pingbacks and Trackbacks",
            "description": "Closes pings on all content, removes the X-Pingback header and the XML-RPC pingback methods. Regular comments are not affected.",
            "category": "comments",
            "tags": [
                "comments",
                "pingbacks",
                "trackbacks",
                "xml-rpc"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pings_open', '__return_false', 20 );\n\nadd_filter( 'wp_headers', function ( $headers ) {\n    unset( $headers['X-Pingback'] );\n\n    return $headers;\n} );\n\nadd_filter( 'xmlrpc_methods', function ( $methods ) {\n    unset( $methods['pingback.ping'], $methods['pingback.extensions.getPingbacks'] );\n\n    return $methods;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "comment-field-last",
            "version": 1,
            "title": "Move the Comment Box Below the Name and Email Fields",
            "description": "Reorders the comment form so visitors fill in their name and email before writing the comment, the order many themes used before WordPress 4.4.",
            "category": "comments",
            "tags": [
                "comments",
                "form",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_fields', function ( $fields ) {\n    if ( ! isset( $fields['comment'] ) ) {\n        return $fields;\n    }\n\n    $comment = $fields['comment'];\n    unset( $fields['comment'] );\n\n    $cookies = isset( $fields['cookies'] ) ? $fields['cookies'] : null;\n    unset( $fields['cookies'] );\n\n    $fields['comment'] = $comment;\n\n    if ( null !== $cookies ) {\n        $fields['cookies'] = $cookies;\n    }\n\n    return $fields;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "comment-privacy-notice",
            "version": 1,
            "title": "Privacy Notice Above the Comment Submit Button",
            "description": "Adds a short note above the submit button explaining that the name, email and comment are stored, with a link to your privacy policy page if one is set.",
            "category": "comments",
            "tags": [
                "comments",
                "privacy",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_submit_field', function ( $submit_field ) {\n    $notice = 'By submitting this form you agree that your name, email address and comment are stored on this website.';\n    $policy = get_privacy_policy_url();\n\n    $html = esc_html( $notice );\n\n    if ( $policy ) {\n        $html .= ' <a href=\"' . esc_url( $policy ) . '\">Read our privacy policy</a>.';\n    }\n\n    return '<p class=\"comment-notes wpide-comment-privacy\">' . $html . '</p>' . $submit_field;\n} );",
            "requires": [],
            "notes": "Set your privacy policy page under Settings > Privacy."
        },
        {
            "id": "close-comments-on-media",
            "version": 1,
            "title": "Close Comments on Media Attachments",
            "description": "Prevents comments on attachment pages, a frequent target for spam bots.",
            "category": "comments",
            "tags": [
                "comments",
                "attachments",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comments_open', function ( $open, $post_id ) {\n    $post = get_post( $post_id );\n\n    return ( $post && 'attachment' === $post->post_type ) ? false : $open;\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "comment-honeypot",
            "version": 1,
            "title": "Honeypot Field for Comment Spam",
            "description": "Adds a hidden field to the comment form that people never see but many bots fill in. Comments that fill it are rejected before reaching the database.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "honeypot"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'comment_form', function () {\n    if ( is_user_logged_in() ) {\n        return;\n    }\n\n    echo '<p class=\"wpide-hp\" style=\"position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden;\" aria-hidden=\"true\">';\n    echo '<label for=\"wpide_hp_field\">Leave this field empty</label>';\n    echo '<input type=\"text\" name=\"wpide_hp_field\" id=\"wpide_hp_field\" value=\"\" tabindex=\"-1\" autocomplete=\"off\" />';\n    echo '</p>';\n} );\n\nadd_filter( 'preprocess_comment', function ( $commentdata ) {\n    $type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : '';\n\n    if ( is_user_logged_in() || ( '' !== $type && 'comment' !== $type ) ) {\n        return $commentdata;\n    }\n\n    if ( ! empty( $_POST['wpide_hp_field'] ) ) {\n        wp_die( 'Your comment could not be submitted.', 'Comment rejected', array( 'response' => 403, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "comment-thank-you-redirect",
            "version": 1,
            "title": "Redirect to a Thank-You Page After Commenting",
            "description": "Sends first-time commenters to a page of your choice after they submit a comment, instead of back to the post.",
            "category": "comments",
            "tags": [
                "comments",
                "redirect",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_post_redirect', function ( $location, $comment ) {\n    $page = get_page_by_path( 'thank-you' );\n\n    if ( ! $page || ! $comment instanceof WP_Comment ) {\n        return $location;\n    }\n\n    $previous = get_comments( array(\n        'author_email' => $comment->comment_author_email,\n        'count'        => true,\n        'status'       => 'all',\n    ) );\n\n    return (int) $previous <= 1 ? get_permalink( $page ) : $location;\n}, 10, 2 );",
            "requires": [],
            "notes": "Create a page with the slug thank-you, or change the slug in the code."
        },
        {
            "id": "highlight-post-author-comments",
            "version": 1,
            "title": "Highlight Comments by the Post Author",
            "description": "Gives replies written by the author of the post a colored left border so readers can spot them in long threads.",
            "category": "comments",
            "tags": [
                "comments",
                "css",
                "author"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".comment.bypostauthor > .comment-body,\n.comment.bypostauthor > article {\n    border-left: 3px solid #2271b1;\n    padding-left: 1em;\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "strip-html-from-comments",
            "version": 1,
            "title": "Strip HTML From Comments",
            "description": "Removes all HTML tags from comments submitted by users who are not allowed to post unfiltered HTML, so links and formatting cannot be injected.",
            "category": "comments",
            "tags": [
                "comments",
                "html",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_comment_content', function ( $content ) {\n    if ( current_user_can( 'unfiltered_html' ) ) {\n        return $content;\n    }\n\n    return wp_strip_all_tags( $content );\n}, 9 );",
            "requires": [],
            "notes": "URLs typed as plain text are still turned into links by WordPress when displayed."
        },
        {
            "id": "reading-time-shortcode",
            "version": 1,
            "title": "Reading Time Shortcode",
            "description": "Registers a [reading_time] shortcode that shows an estimated reading time such as \"4 min read\" for the current post.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "reading-time"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'reading_time', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'wpm'   => 220,\n        'label' => 'min read',\n    ), $atts, 'reading_time' );\n\n    $post = get_post();\n\n    if ( ! $post ) {\n        return '';\n    }\n\n    $words   = str_word_count( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) );\n    $minutes = max( 1, (int) ceil( $words / max( 1, (int) $atts['wpm'] ) ) );\n\n    return '<span class=\"reading-time\">' . esc_html( $minutes . ' ' . $atts['label'] ) . '</span>';\n} );",
            "requires": [],
            "notes": "Usage: [reading_time] or [reading_time wpm=\"250\" label=\"minutes\"]."
        },
        {
            "id": "external-links-new-tab",
            "version": 1,
            "title": "Open External Links in a New Tab",
            "description": "Adds target=\"_blank\" and rel=\"noopener noreferrer\" to links in post content that point to other domains. Optionally adds nofollow too. Requires WordPress 6.2 or newer.",
            "category": "content",
            "tags": [
                "content",
                "links",
                "external",
                "nofollow"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    $add_nofollow = false; // Set to true to also add rel=\"nofollow\".\n\n    if ( ! class_exists( 'WP_HTML_Tag_Processor' ) || false === stripos( $content, '<a ' ) ) {\n        return $content;\n    }\n\n    $site_host = wp_parse_url( home_url(), PHP_URL_HOST );\n    $tags      = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'a' ) ) {\n        $href = $tags->get_attribute( 'href' );\n\n        if ( ! is_string( $href ) || ! preg_match( '#^https?://#i', $href ) ) {\n            continue;\n        }\n\n        $host = wp_parse_url( $href, PHP_URL_HOST );\n\n        if ( ! $host || strtolower( $host ) === strtolower( (string) $site_host ) ) {\n            continue;\n        }\n\n        $rel   = $tags->get_attribute( 'rel' );\n        $parts = is_string( $rel ) ? preg_split( '/\\s+/', trim( $rel ) ) : array();\n        $parts = array_merge( array_filter( $parts ), array( 'noopener', 'noreferrer' ) );\n\n        if ( $add_nofollow ) {\n            $parts[] = 'nofollow';\n        }\n\n        $tags->set_attribute( 'target', '_blank' );\n        $tags->set_attribute( 'rel', implode( ' ', array_unique( $parts ) ) );\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-automatic-paragraphs",
            "version": 1,
            "title": "Disable Automatic Paragraphs",
            "description": "Stops WordPress from wrapping text in paragraph tags and adding line breaks in classic content and excerpts. Block content is not affected.",
            "category": "content",
            "tags": [
                "content",
                "wpautop",
                "formatting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_filter( 'the_content', 'wpautop' );\nremove_filter( 'the_excerpt', 'wpautop' );",
            "requires": [],
            "notes": "Existing classic posts written without HTML paragraphs will appear as one block of text."
        },
        {
            "id": "disable-smart-quotes",
            "version": 1,
            "title": "Disable Smart Quotes and Dashes",
            "description": "Turns off wptexturize, which converts straight quotes, double hyphens and ellipses into typographic characters. Useful for sites that publish code.",
            "category": "content",
            "tags": [
                "content",
                "formatting",
                "quotes",
                "code"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'run_wptexturize', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "custom-excerpt-length",
            "version": 1,
            "title": "Change the Excerpt Length",
            "description": "Sets automatically generated excerpts to 30 words instead of the default 55.",
            "category": "content",
            "tags": [
                "content",
                "excerpt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'excerpt_length', function () {\n    return 30;\n}, 999 );",
            "requires": [],
            "notes": "Manual excerpts are never shortened."
        },
        {
            "id": "excerpt-read-more-link",
            "version": 1,
            "title": "Add a \"Continue Reading\" Link to Excerpts",
            "description": "Replaces the [...] at the end of automatic excerpts with an accessible \"Continue reading\" link to the full post.",
            "category": "content",
            "tags": [
                "content",
                "excerpt",
                "read-more"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'excerpt_more', function () {\n    if ( is_admin() ) {\n        return ' &hellip;';\n    }\n\n    return sprintf(\n        ' &hellip; <a class=\"read-more\" href=\"%1$s\">Continue reading<span class=\"screen-reader-text\"> %2$s</span></a>',\n        esc_url( get_permalink() ),\n        esc_html( get_the_title() )\n    );\n}, 999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "related-posts-after-content",
            "version": 1,
            "title": "Related Posts Below Single Posts",
            "description": "Lists three posts from the same categories at the end of each single post. No plugin, no extra database tables.",
            "category": "content",
            "tags": [
                "content",
                "related-posts",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $post_id    = get_the_ID();\n    $categories = wp_get_post_categories( $post_id );\n\n    if ( ! $categories ) {\n        return $content;\n    }\n\n    $related = get_posts( array(\n        'category__in'        => $categories,\n        'post__not_in'        => array( $post_id ),\n        'numberposts'         => 3,\n        'ignore_sticky_posts' => true,\n        'no_found_rows'       => true,\n    ) );\n\n    if ( ! $related ) {\n        return $content;\n    }\n\n    $html = '<aside class=\"wpide-related-posts\"><h3>Related posts</h3><ul>';\n\n    foreach ( $related as $item ) {\n        $html .= '<li><a href=\"' . esc_url( get_permalink( $item ) ) . '\">' . esc_html( get_the_title( $item ) ) . '</a></li>';\n    }\n\n    return $content . $html . '</ul></aside>';\n}, 20 );",
            "requires": [],
            "notes": "Change numberposts to show more or fewer posts."
        },
        {
            "id": "last-updated-date",
            "version": 1,
            "title": "Show the \"Last Updated\" Date on Posts",
            "description": "Adds \"Last updated on\" with the modified date at the top of posts that were edited at least a day after publishing.",
            "category": "content",
            "tags": [
                "content",
                "date",
                "modified",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    if ( (int) get_the_modified_time( 'U' ) - (int) get_the_time( 'U' ) < DAY_IN_SECONDS ) {\n        return $content;\n    }\n\n    return '<p class=\"last-updated\"><em>Last updated on ' . esc_html( get_the_modified_date() ) . '</em></p>' . $content;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "copyright-years-shortcode",
            "version": 1,
            "title": "Dynamic Copyright Years Shortcode",
            "description": "Registers a [copyright_years] shortcode that outputs a range like \"© 2016–2026 Site Name\", starting from your first post or a year you choose.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "copyright",
                "footer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'copyright_years', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'start' => '' ), $atts, 'copyright_years' );\n    $current = (int) wp_date( 'Y' );\n    $start   = (int) $atts['start'];\n\n    if ( ! $start ) {\n        $first = get_posts( array(\n            'numberposts' => 1,\n            'post_status' => 'publish',\n            'orderby'     => 'date',\n            'order'       => 'ASC',\n            'fields'      => 'ids',\n        ) );\n        $start = $first ? (int) get_the_date( 'Y', $first[0] ) : $current;\n    }\n\n    $years = $start < $current ? $start . '&ndash;' . $current : (string) $current;\n\n    return '&copy; ' . $years . ' ' . esc_html( get_bloginfo( 'name' ) );\n} );",
            "requires": [],
            "notes": "Usage: [copyright_years] or [copyright_years start=\"2015\"]."
        },
        {
            "id": "current-year-shortcode",
            "version": 1,
            "title": "Current Year",
            "description": "Outputs the current year. Insert it with the snippet shortcode anywhere you need a year that updates itself, such as a footer notice.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "year",
                "footer"
            ],
            "code_type": "php",
            "insert_method": "shortcode",
            "location": "on_demand",
            "insert_number": 1,
            "code": "echo esc_html( wp_date( 'Y' ) );",
            "requires": [],
            "notes": "Copy the snippet shortcode from the editor after saving."
        },
        {
            "id": "auto-set-featured-image",
            "version": 1,
            "title": "Set the Featured Image Automatically",
            "description": "When a post is saved without a featured image, uses the first image attached to it or the first image block in its content.",
            "category": "content",
            "tags": [
                "content",
                "featured-image",
                "thumbnail"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'save_post', function ( $post_id, $post ) {\n    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || 'auto-draft' === $post->post_status ) {\n        return;\n    }\n\n    if ( ! post_type_supports( $post->post_type, 'thumbnail' ) || has_post_thumbnail( $post_id ) ) {\n        return;\n    }\n\n    if ( preg_match( '/wp-image-(\\d+)/', $post->post_content, $match ) && wp_attachment_is_image( (int) $match[1] ) ) {\n        set_post_thumbnail( $post_id, (int) $match[1] );\n        return;\n    }\n\n    $images = get_attached_media( 'image', $post_id );\n\n    if ( $images ) {\n        $first = reset( $images );\n        set_post_thumbnail( $post_id, $first->ID );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "login-logout-link-shortcode",
            "version": 1,
            "title": "Login / Logout Link Shortcode",
            "description": "Registers a [login_logout_link] shortcode that shows \"Log in\" to visitors and \"Log out\" to logged-in users, returning them to the same page.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "login",
                "menu"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'login_logout_link', function () {\n    $current = get_permalink();\n    $current = $current ? $current : home_url( '/' );\n\n    if ( is_user_logged_in() ) {\n        return '<a class=\"logout-link\" href=\"' . esc_url( wp_logout_url( $current ) ) . '\">Log out</a>';\n    }\n\n    return '<a class=\"login-link\" href=\"' . esc_url( wp_login_url( $current ) ) . '\">Log in</a>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "author-box-after-posts",
            "version": 1,
            "title": "Author Box After Posts",
            "description": "Adds a box with the author avatar, name, biography and a link to their other posts at the end of single posts. Authors without a biography are skipped.",
            "category": "content",
            "tags": [
                "content",
                "author",
                "bio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $author_id = (int) get_post_field( 'post_author', get_the_ID() );\n    $bio       = (string) get_the_author_meta( 'description', $author_id );\n\n    if ( '' === trim( $bio ) ) {\n        return $content;\n    }\n\n    $box  = '<div class=\"wpide-author-box\" style=\"display:flex;gap:16px;align-items:flex-start;margin-top:2em;padding:16px;border:1px solid rgba(0,0,0,.1);border-radius:6px;\">';\n    $box .= get_avatar( $author_id, 72 );\n    $box .= '<div><p style=\"margin:0 0 .4em;\"><strong>' . esc_html( get_the_author_meta( 'display_name', $author_id ) ) . '</strong></p>';\n    $box .= '<p style=\"margin:0;\">' . wp_kses_post( $bio ) . '</p>';\n    $box .= '<p style=\"margin:.4em 0 0;\"><a href=\"' . esc_url( get_author_posts_url( $author_id ) ) . '\">More posts by this author</a></p></div></div>';\n\n    return $content . $box;\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "ad-after-paragraph",
            "version": 1,
            "title": "Ad Slot After a Paragraph",
            "description": "Inserts a block of HTML (an ad, a newsletter prompt or any call to action) after the third paragraph of single posts.",
            "category": "content",
            "tags": [
                "content",
                "ads",
                "monetization",
                "html"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "after_paragraph",
            "insert_number": 3,
            "code": "<div class=\"wpide-inline-slot\" style=\"margin:2em 0;padding:16px;text-align:center;background:#f6f7f7;border:1px dashed #c3c4c7;\">\n    <!-- Replace this block with your ad code or call to action. -->\n    <p style=\"margin:0;\">Your ad or call to action goes here.</p>\n</div>",
            "requires": [],
            "notes": "Change the paragraph number in the snippet settings."
        },
        {
            "id": "newsletter-box-after-content",
            "version": 1,
            "title": "Newsletter Call to Action After Content",
            "description": "Adds a simple sign-up box at the end of post content that links to your newsletter page or form.",
            "category": "content",
            "tags": [
                "content",
                "newsletter",
                "cta",
                "html"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "after_content",
            "insert_number": 1,
            "code": "<div class=\"wpide-cta-box\" style=\"margin:2.5em 0 1em;padding:24px;border-radius:8px;background:#f0f6fc;text-align:center;\">\n    <h3 style=\"margin-top:0;\">Enjoyed this article?</h3>\n    <p>Get new posts delivered to your inbox. No spam, unsubscribe at any time.</p>\n    <p style=\"margin-bottom:0;\"><a class=\"wp-element-button button\" href=\"/newsletter/\">Subscribe</a></p>\n</div>",
            "requires": [],
            "notes": "Change /newsletter/ to your sign-up page or paste your email provider form."
        },
        {
            "id": "excerpts-for-pages",
            "version": 1,
            "title": "Enable Excerpts for Pages",
            "description": "Adds the Excerpt box to pages, which many themes and SEO plugins can use as a summary.",
            "category": "content",
            "tags": [
                "content",
                "excerpt",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_post_type_support( 'page', 'excerpt' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "shortcodes-in-excerpts",
            "version": 1,
            "title": "Run Shortcodes in Manual Excerpts",
            "description": "Processes shortcodes typed into the Excerpt field instead of printing them as plain text.",
            "category": "content",
            "tags": [
                "content",
                "excerpt",
                "shortcodes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'the_excerpt', 'do_shortcode', 11 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-archive-title-prefix",
            "version": 1,
            "title": "Remove \"Category:\" and \"Tag:\" From Archive Titles",
            "description": "Removes the prefix such as \"Category:\", \"Tag:\" or \"Author:\" from archive page titles, leaving only the term or name.",
            "category": "archive",
            "tags": [
                "archive",
                "title",
                "category"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_the_archive_title_prefix', '__return_empty_string' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-author-archives",
            "version": 1,
            "title": "Disable Author Archives",
            "description": "Redirects author archive pages to the homepage. Good for single-author sites where the author archive duplicates the blog page.",
            "category": "archive",
            "tags": [
                "archive",
                "author",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_author() ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n} );\n\nadd_filter( 'author_link', function () {\n    return home_url( '/' );\n} );\n\nadd_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {\n    return 'users' === $name ? false : $provider;\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-date-archives",
            "version": 1,
            "title": "Disable Date Archives",
            "description": "Makes yearly, monthly and daily archive pages return a 404, which avoids thin duplicate pages on most sites.",
            "category": "archive",
            "tags": [
                "archive",
                "date",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( is_date() ) {\n        $wp_query->set_404();\n        status_header( 404 );\n        nocache_headers();\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "exclude-category-from-blog",
            "version": 1,
            "title": "Exclude a Category From the Blog Page",
            "description": "Hides posts in chosen categories from the main blog page. The posts remain visible in their own category archive.",
            "category": "archive",
            "tags": [
                "archive",
                "category",
                "blog",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() ) {\n        return;\n    }\n\n    // IDs of the categories to hide.\n    $excluded = array( 1 );\n\n    $query->set( 'category__not_in', array_merge( (array) $query->get( 'category__not_in' ), $excluded ) );\n} );",
            "requires": [],
            "notes": "Replace 1 with your category IDs (1 is Uncategorized on most sites)."
        },
        {
            "id": "custom-post-types-on-blog",
            "version": 1,
            "title": "Include Custom Post Types on the Blog Page",
            "description": "Mixes entries from a custom post type into the main blog page and feed alongside regular posts.",
            "category": "archive",
            "tags": [
                "archive",
                "custom-post-types",
                "blog",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    if ( $query->is_home() || ( $query->is_feed() && ! $query->is_comment_feed() && ! $query->get( 'post_type' ) ) ) {\n        $query->set( 'post_type', array( 'post', 'portfolio' ) );\n    }\n} );",
            "requires": [],
            "notes": "Replace portfolio with your post type name(s)."
        },
        {
            "id": "taxonomy-archive-posts-per-page",
            "version": 1,
            "title": "Posts per Page on Category and Tag Archives",
            "description": "Shows 20 posts on category, tag and custom taxonomy archives without changing the number on the blog page.",
            "category": "archive",
            "tags": [
                "archive",
                "pagination",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    if ( $query->is_category() || $query->is_tag() || $query->is_tax() ) {\n        $query->set( 'posts_per_page', 20 );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "alphabetical-category-archive",
            "version": 1,
            "title": "Sort a Category Archive Alphabetically",
            "description": "Orders posts in chosen categories by title from A to Z, which suits glossaries, directories and recipe indexes.",
            "category": "archive",
            "tags": [
                "archive",
                "sorting",
                "category"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    // Category slugs to sort alphabetically.\n    $categories = array( 'glossary' );\n\n    if ( $query->is_category( $categories ) ) {\n        $query->set( 'orderby', 'title' );\n        $query->set( 'order', 'ASC' );\n    }\n} );",
            "requires": [],
            "notes": "Replace glossary with your category slugs."
        },
        {
            "id": "shortcodes-in-term-descriptions",
            "version": 1,
            "title": "Run Shortcodes in Category Descriptions",
            "description": "Processes shortcodes in category, tag and taxonomy descriptions shown on archive pages.",
            "category": "archive",
            "tags": [
                "archive",
                "shortcodes",
                "taxonomy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'term_description', 'do_shortcode' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "allow-svg-uploads-for-admins",
            "version": 1,
            "title": "Allow SVG Uploads for Administrators",
            "description": "Lets administrators upload SVG files to the Media Library. Files containing scripts, event handlers or javascript: links are rejected.",
            "category": "media",
            "tags": [
                "media",
                "svg",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'upload_mimes', function ( $mimes ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        $mimes['svg'] = 'image/svg+xml';\n    }\n\n    return $mimes;\n} );\n\nadd_filter( 'wp_check_filetype_and_ext', function ( $data, $file, $filename, $mimes ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return $data;\n    }\n\n    $check = wp_check_filetype( $filename, $mimes );\n\n    if ( 'svg' !== $check['ext'] ) {\n        return $data;\n    }\n\n    $contents = is_readable( $file ) ? (string) file_get_contents( $file, false, null, 0, 2 * MB_IN_BYTES ) : '';\n\n    if ( '' === $contents || preg_match( '/<script|\\son[a-z]+\\s*=|javascript:|<foreignObject/i', $contents ) ) {\n        return array( 'ext' => false, 'type' => false, 'proper_filename' => false );\n    }\n\n    $data['ext']  = 'svg';\n    $data['type'] = 'image/svg+xml';\n\n    return $data;\n}, 10, 4 );",
            "requires": [],
            "notes": "The check is a basic safety net, not a full SVG sanitizer. Only upload SVG files from sources you trust."
        },
        {
            "id": "disable-big-image-scaling",
            "version": 1,
            "title": "Disable Big Image Scaling",
            "description": "Stops WordPress from creating a scaled-down \"-scaled\" copy of images larger than 2560 pixels, so the original file is used as uploaded.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "scaling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'big_image_size_threshold', '__return_false' );",
            "requires": [],
            "notes": "Very large originals use more disk space and can be slow to serve; resize before uploading."
        },
        {
            "id": "jpeg-compression-quality",
            "version": 1,
            "title": "Set JPEG Compression Quality",
            "description": "Changes the quality used when WordPress generates resized JPEG images. Lower values give smaller files; the default is 82.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "jpeg",
                "compression"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_editor_set_quality', function ( $quality, $mime_type ) {\n    return 'image/jpeg' === $mime_type ? 75 : $quality;\n}, 10, 2 );",
            "requires": [],
            "notes": "Applies to images uploaded after activation. Use a thumbnail regeneration tool for existing images."
        },
        {
            "id": "disable-extra-image-sizes",
            "version": 1,
            "title": "Skip Unneeded Image Sizes",
            "description": "Stops WordPress from generating the medium_large, 1536px and 2048px copies of every upload, saving disk space.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "thumbnails",
                "disk-space"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'intermediate_image_sizes_advanced', function ( $sizes ) {\n    unset( $sizes['medium_large'], $sizes['1536x1536'], $sizes['2048x2048'] );\n\n    return $sizes;\n} );",
            "requires": [],
            "notes": "Responsive images will have fewer sizes to choose from on large screens."
        },
        {
            "id": "register-custom-image-size",
            "version": 1,
            "title": "Register a Custom Image Size",
            "description": "Adds a 600 by 400 cropped image size and makes it selectable in the editor image size dropdown.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "thumbnails"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    add_image_size( 'wpide-card', 600, 400, true );\n} );\n\nadd_filter( 'image_size_names_choose', function ( $sizes ) {\n    $sizes['wpide-card'] = 'Card (600 x 400)';\n\n    return $sizes;\n} );",
            "requires": [],
            "notes": "Rename the size and change the dimensions. Existing images need their thumbnails regenerated."
        },
        {
            "id": "alt-text-from-filename",
            "version": 1,
            "title": "Fill Missing Alt Text From the File Name",
            "description": "When an image is uploaded without alt text, creates one from its title (based on the file name), replacing dashes and underscores with spaces.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "alt-text",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_attachment', function ( $attachment_id ) {\n    if ( ! wp_attachment_is_image( $attachment_id ) ) {\n        return;\n    }\n\n    if ( '' !== (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) {\n        return;\n    }\n\n    $alt = trim( preg_replace( '/[\\s_-]+/', ' ', (string) get_the_title( $attachment_id ) ) );\n\n    if ( '' !== $alt ) {\n        update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( ucfirst( $alt ) ) );\n    }\n} );",
            "requires": [],
            "notes": "Generated alt text is only as good as the file name. Review it for important images."
        },
        {
            "id": "redirect-attachment-pages",
            "version": 1,
            "title": "Redirect Attachment Pages",
            "description": "Sends visitors of media attachment pages to the post the file is attached to, or to the homepage for unattached files.",
            "category": "media",
            "tags": [
                "media",
                "attachments",
                "redirect",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_attachment() ) {\n        return;\n    }\n\n    $attachment = get_queried_object();\n    $parent_id  = $attachment instanceof WP_Post ? (int) $attachment->post_parent : 0;\n    $target     = ( $parent_id && 'publish' === get_post_status( $parent_id ) ) ? get_permalink( $parent_id ) : home_url( '/' );\n\n    wp_safe_redirect( $target, 301 );\n    exit;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "clean-upload-file-names",
            "version": 1,
            "title": "Clean Up File Names on Upload",
            "description": "Converts uploaded file names to lowercase, removes accents and replaces spaces and symbols with dashes, for tidy and portable URLs.",
            "category": "media",
            "tags": [
                "media",
                "uploads",
                "file-names"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'sanitize_file_name', function ( $filename ) {\n    $info      = pathinfo( $filename );\n    $extension = isset( $info['extension'] ) && '' !== $info['extension'] ? '.' . strtolower( $info['extension'] ) : '';\n    $name      = isset( $info['filename'] ) ? $info['filename'] : $filename;\n\n    $name = remove_accents( $name );\n    $name = strtolower( preg_replace( '/[^A-Za-z0-9]+/', '-', $name ) );\n    $name = trim( $name, '-' );\n\n    return ( '' === $name ? 'file' : $name ) . $extension;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "media-file-size-column",
            "version": 1,
            "title": "File Size and Dimensions Columns in the Media Library",
            "description": "Adds File Size and Dimensions columns to the Media Library list view, making heavy images easy to spot.",
            "category": "media",
            "tags": [
                "media",
                "columns",
                "file-size"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_media_columns', function ( $columns ) {\n    $columns['wpide_filesize']   = 'File Size';\n    $columns['wpide_dimensions'] = 'Dimensions';\n\n    return $columns;\n} );\n\nadd_action( 'manage_media_custom_column', function ( $column, $attachment_id ) {\n    $meta = wp_get_attachment_metadata( $attachment_id );\n\n    if ( 'wpide_filesize' === $column ) {\n        $bytes = ! empty( $meta['filesize'] ) ? (int) $meta['filesize'] : 0;\n\n        if ( ! $bytes ) {\n            $file  = get_attached_file( $attachment_id );\n            $bytes = ( $file && file_exists( $file ) ) ? (int) filesize( $file ) : 0;\n        }\n\n        echo $bytes ? esc_html( size_format( $bytes, 1 ) ) : '&mdash;';\n    } elseif ( 'wpide_dimensions' === $column ) {\n        echo ( ! empty( $meta['width'] ) && ! empty( $meta['height'] ) ) ? esc_html( $meta['width'] . ' x ' . $meta['height'] ) : '&mdash;';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "limit-upload-size",
            "version": 1,
            "title": "Limit the Maximum Upload Size",
            "description": "Caps the size of files that can be uploaded through WordPress at 8 MB, even if the server allows more.",
            "category": "media",
            "tags": [
                "media",
                "uploads",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'upload_size_limit', function ( $bytes ) {\n    return min( (int) $bytes, 8 * MB_IN_BYTES );\n} );",
            "requires": [],
            "notes": "This can only lower the server limit, not raise it."
        },
        {
            "id": "restrict-media-to-own-uploads",
            "version": 1,
            "title": "Show Users Only Their Own Media",
            "description": "Authors and contributors only see files they uploaded in the Media Library and media picker. Editors and administrators still see everything.",
            "category": "media",
            "tags": [
                "media",
                "privacy",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'ajax_query_attachments_args', function ( $query ) {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        $query['author'] = get_current_user_id();\n    }\n\n    return $query;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    global $pagenow;\n\n    if ( is_admin() && 'upload.php' === $pagenow && $query->is_main_query() && ! current_user_can( 'edit_others_posts' ) ) {\n        $query->set( 'author', get_current_user_id() );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "default-image-link-none",
            "version": 1,
            "title": "Do Not Link Inserted Images by Default",
            "description": "Sets the default \"Link to\" option for images inserted in the classic editor and galleries to None instead of the media file.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_image_default_link_type', function () {\n    return 'none';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "categories-for-media",
            "version": 1,
            "title": "Categories for Media Files",
            "description": "Lets you assign post categories to images and other attachments to organize the Media Library.",
            "category": "media",
            "tags": [
                "media",
                "categories",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy_for_object_type( 'category', 'attachment' );\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-wordpress-sitemaps",
            "version": 1,
            "title": "Disable the Built-in XML Sitemaps",
            "description": "Turns off the wp-sitemap.xml sitemaps added in WordPress 5.5. Use it when an SEO plugin already generates sitemaps.",
            "category": "seo",
            "tags": [
                "seo",
                "sitemap",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_sitemaps_enabled', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "exclude-from-sitemaps",
            "version": 1,
            "title": "Exclude Content Types From the XML Sitemap",
            "description": "Removes chosen post types and taxonomies (tags by default) and the users list from the built-in wp-sitemap.xml.",
            "category": "seo",
            "tags": [
                "seo",
                "sitemap"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_sitemaps_post_types', function ( $post_types ) {\n    // Post type names to leave out, for example 'page'.\n    foreach ( array() as $name ) {\n        unset( $post_types[ $name ] );\n    }\n\n    return $post_types;\n} );\n\nadd_filter( 'wp_sitemaps_taxonomies', function ( $taxonomies ) {\n    unset( $taxonomies['post_tag'], $taxonomies['post_format'] );\n\n    return $taxonomies;\n} );\n\nadd_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) {\n    return 'users' === $name ? false : $provider;\n}, 10, 2 );",
            "requires": [],
            "notes": "Add post type names to the empty array to exclude them."
        },
        {
            "id": "noindex-thin-pages",
            "version": 1,
            "title": "Noindex Thin Archive Pages",
            "description": "Adds noindex, follow to date archives, attachment pages and page 2+ of archives, so search engines focus on your real content.",
            "category": "seo",
            "tags": [
                "seo",
                "robots",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( is_date() || is_attachment() || ( is_paged() && ( is_archive() || is_home() ) ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Skip this snippet if an SEO plugin already controls robots meta tags."
        },
        {
            "id": "noindex-non-production",
            "version": 1,
            "title": "Noindex Staging and Development Sites",
            "description": "Adds noindex, nofollow to every page when WP_ENVIRONMENT_TYPE is not production, so staging copies never end up in search results.",
            "category": "seo",
            "tags": [
                "seo",
                "robots",
                "staging",
                "environment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( function_exists( 'wp_get_environment_type' ) && 'production' !== wp_get_environment_type() ) {\n        $robots['noindex']  = true;\n        $robots['nofollow'] = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Set define( 'WP_ENVIRONMENT_TYPE', 'staging' ); in wp-config.php on staging copies."
        },
        {
            "id": "meta-description-fallback",
            "version": 1,
            "title": "Automatic Meta Description",
            "description": "Prints a meta description from the excerpt or opening words of posts, from term descriptions on archives and from the tagline on the homepage. Skipped when an SEO plugin is active.",
            "category": "seo",
            "tags": [
                "seo",
                "meta",
                "description"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'THE_SEO_FRAMEWORK_VERSION' ) || defined( 'SEOPRESS_VERSION' ) ) {\n        return;\n    }\n\n    $description = '';\n\n    if ( is_singular() ) {\n        $post        = get_queried_object();\n        $description = has_excerpt( $post ) ? $post->post_excerpt : wp_trim_words( excerpt_remove_blocks( strip_shortcodes( $post->post_content ) ), 35, '' );\n    } elseif ( is_category() || is_tag() || is_tax() ) {\n        $description = term_description();\n    } elseif ( is_front_page() || is_home() ) {\n        $description = get_bloginfo( 'description' );\n    }\n\n    $description = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( (string) $description ) ) );\n\n    if ( '' !== $description ) {\n        printf( \"<meta name=\\\"description\\\" content=\\\"%s\\\" />\\n\", esc_attr( wp_html_excerpt( $description, 160, '' ) ) );\n    }\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "open-graph-tags",
            "version": 1,
            "title": "Basic Open Graph and Twitter Card Tags",
            "description": "Adds Open Graph and Twitter Card meta tags (title, description, image, URL) so links shared on social networks show a proper preview. Skipped when an SEO plugin is active.",
            "category": "seo",
            "tags": [
                "seo",
                "open-graph",
                "social",
                "twitter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'THE_SEO_FRAMEWORK_VERSION' ) || defined( 'SEOPRESS_VERSION' ) ) {\n        return;\n    }\n\n    $tags = array(\n        'og:site_name' => get_bloginfo( 'name' ),\n        'og:locale'    => get_locale(),\n    );\n\n    if ( is_singular() ) {\n        $post                    = get_queried_object();\n        $tags['og:type']         = 'article';\n        $tags['og:title']        = get_the_title( $post );\n        $tags['og:url']          = get_permalink( $post );\n        $tags['og:description']  = has_excerpt( $post ) ? $post->post_excerpt : wp_trim_words( excerpt_remove_blocks( strip_shortcodes( $post->post_content ) ), 30, '' );\n\n        if ( has_post_thumbnail( $post ) ) {\n            $tags['og:image'] = get_the_post_thumbnail_url( $post, 'large' );\n        }\n    } else {\n        $tags['og:type']        = 'website';\n        $tags['og:title']       = wp_get_document_title();\n        $tags['og:description'] = get_bloginfo( 'description' );\n\n        if ( is_front_page() ) {\n            $tags['og:url'] = home_url( '/' );\n        }\n    }\n\n    if ( empty( $tags['og:image'] ) && has_site_icon() ) {\n        $tags['og:image'] = get_site_icon_url( 512 );\n    }\n\n    foreach ( $tags as $property => $value ) {\n        $value = trim( wp_strip_all_tags( (string) $value ) );\n\n        if ( '' === $value ) {\n            continue;\n        }\n\n        $is_url = in_array( $property, array( 'og:url', 'og:image' ), true );\n        printf( \"<meta property=\\\"%s\\\" content=\\\"%s\\\" />\\n\", esc_attr( $property ), $is_url ? esc_url( $value ) : esc_attr( $value ) );\n    }\n\n    printf( \"<meta name=\\\"twitter:card\\\" content=\\\"%s\\\" />\\n\", empty( $tags['og:image'] ) ? 'summary' : 'summary_large_image' );\n}, 5 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "organization-schema",
            "version": 1,
            "title": "Organization Structured Data",
            "description": "Outputs Organization schema (JSON-LD) on the homepage with your site name, URL, logo and social profiles, helping search engines understand who runs the site.",
            "category": "seo",
            "tags": [
                "seo",
                "schema",
                "json-ld",
                "structured-data"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_front_page() ) {\n        return;\n    }\n\n    $data = array(\n        '@context' => 'https://schema.org',\n        '@type'    => 'Organization',\n        'name'     => get_bloginfo( 'name' ),\n        'url'      => home_url( '/' ),\n    );\n\n    $logo_id = (int) get_theme_mod( 'custom_logo' );\n    $logo    = $logo_id ? wp_get_attachment_image_url( $logo_id, 'full' ) : '';\n\n    if ( $logo ) {\n        $data['logo'] = $logo;\n    }\n\n    // Social profile URLs, for example 'https://www.linkedin.com/company/your-company'.\n    $profiles = array();\n\n    if ( $profiles ) {\n        $data['sameAs'] = array_values( $profiles );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Add your social profile URLs to $profiles. Change @type to LocalBusiness or another type if it fits better."
        },
        {
            "id": "article-schema",
            "version": 1,
            "title": "Article Structured Data for Posts",
            "description": "Outputs BlogPosting schema (JSON-LD) on single posts with the headline, dates, author, image and publisher.",
            "category": "seo",
            "tags": [
                "seo",
                "schema",
                "json-ld",
                "articles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'post' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    $data = array(\n        '@context'         => 'https://schema.org',\n        '@type'            => 'BlogPosting',\n        'headline'         => wp_strip_all_tags( get_the_title( $post ) ),\n        'datePublished'    => get_the_date( DATE_W3C, $post ),\n        'dateModified'     => get_the_modified_date( DATE_W3C, $post ),\n        'mainEntityOfPage' => get_permalink( $post ),\n        'author'           => array(\n            '@type' => 'Person',\n            'name'  => get_the_author_meta( 'display_name', $post->post_author ),\n            'url'   => get_author_posts_url( $post->post_author ),\n        ),\n        'publisher'        => array(\n            '@type' => 'Organization',\n            'name'  => get_bloginfo( 'name' ),\n        ),\n    );\n\n    if ( has_post_thumbnail( $post ) ) {\n        $data['image'] = get_the_post_thumbnail_url( $post, 'full' );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Skip this snippet if an SEO plugin already outputs article schema."
        },
        {
            "id": "robots-txt-rules",
            "version": 1,
            "title": "Add Rules to the Virtual robots.txt",
            "description": "Adds Disallow rules for internal search result URLs to the robots.txt file that WordPress generates.",
            "category": "seo",
            "tags": [
                "seo",
                "robots-txt",
                "crawling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'robots_txt', function ( $output, $public ) {\n    if ( ! $public ) {\n        return $output;\n    }\n\n    $rules = \"Disallow: /?s=\\nDisallow: /search/\\n\";\n\n    if ( false !== strpos( $output, \"User-agent: *\\n\" ) ) {\n        return preg_replace( '/User-agent: \\*\\n/', \"User-agent: *\\n\" . $rules, $output, 1 );\n    }\n\n    return \"User-agent: *\\n\" . $rules . \"\\n\" . $output;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only works when there is no physical robots.txt file in the site root."
        },
        {
            "id": "featured-image-in-rss",
            "version": 1,
            "title": "Add Featured Images to RSS Feeds",
            "description": "Places the featured image at the top of each item in your RSS feeds, so feed readers and email newsletters show it.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "featured-image"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_feed_image = function ( $content ) {\n    $post = get_post();\n\n    if ( $post && has_post_thumbnail( $post ) ) {\n        $content = '<p>' . get_the_post_thumbnail( $post, 'large', array( 'style' => 'max-width:100%;height:auto;' ) ) . '</p>' . $content;\n    }\n\n    return $content;\n};\n\nadd_filter( 'the_excerpt_rss', $wpide_feed_image );\nadd_filter( 'the_content_feed', $wpide_feed_image );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "delay-posts-in-rss",
            "version": 1,
            "title": "Delay New Posts in RSS Feeds",
            "description": "Waits 15 minutes before new posts appear in your feeds, giving you time to fix typos before feed readers and newsletters pick them up.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_where', function ( $where, $query ) {\n    global $wpdb;\n\n    if ( ! $query->is_main_query() || ! $query->is_feed() ) {\n        return $where;\n    }\n\n    $minutes = 15;\n    $cutoff  = gmdate( 'Y-m-d H:i:s', time() - $minutes * MINUTE_IN_SECONDS );\n\n    return $where . $wpdb->prepare( \" AND {$wpdb->posts}.post_date_gmt <= %s\", $cutoff );\n}, 10, 2 );",
            "requires": [],
            "notes": "Change $minutes to adjust the delay."
        },
        {
            "id": "exclude-category-from-rss",
            "version": 1,
            "title": "Exclude a Category From RSS Feeds",
            "description": "Keeps posts in chosen categories out of the main RSS feed.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "category"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! $query->is_main_query() || ! $query->is_feed() || $query->is_category() ) {\n        return;\n    }\n\n    // IDs of the categories to leave out.\n    $excluded = array( 1 );\n\n    $query->set( 'category__not_in', array_merge( (array) $query->get( 'category__not_in' ), $excluded ) );\n} );",
            "requires": [],
            "notes": "Replace 1 with your category IDs. The category own feed still works."
        },
        {
            "id": "custom-post-types-in-rss",
            "version": 1,
            "title": "Add Custom Post Types to the RSS Feed",
            "description": "Includes entries from custom post types in the main RSS feed along with regular posts.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'request', function ( $query_vars ) {\n    if ( isset( $query_vars['feed'] ) && ! isset( $query_vars['post_type'] ) && ! isset( $query_vars['withcomments'] ) ) {\n        $query_vars['post_type'] = array( 'post', 'portfolio' );\n    }\n\n    return $query_vars;\n} );",
            "requires": [],
            "notes": "Replace portfolio with your post type names."
        },
        {
            "id": "rss-footer-attribution",
            "version": 1,
            "title": "Add an Attribution Line to Feed Items",
            "description": "Appends \"The post ... first appeared on ...\" with links to every feed item, which credits you when content is scraped from your feed.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "attribution",
                "scraping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_feed_footer = function ( $content ) {\n    return $content . sprintf(\n        '<p>The post <a href=\"%1$s\">%2$s</a> first appeared on <a href=\"%3$s\">%4$s</a>.</p>',\n        esc_url( get_permalink() ),\n        esc_html( get_the_title() ),\n        esc_url( home_url( '/' ) ),\n        esc_html( get_bloginfo( 'name' ) )\n    );\n};\n\nadd_filter( 'the_content_feed', $wpide_feed_footer );\nadd_filter( 'the_excerpt_rss', $wpide_feed_footer );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-comment-feeds",
            "version": 1,
            "title": "Remove Comment Feeds",
            "description": "Removes comment feed links from the page head and redirects comment feed URLs to the homepage, while keeping the post feeds.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'feed_links_show_comments_feed', '__return_false' );\nadd_filter( 'feed_links_extra_show_post_comments_feed', '__return_false' );\n\nadd_action( 'template_redirect', function () {\n    if ( is_comment_feed() ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "rss-items-count",
            "version": 1,
            "title": "Set the Number of Items in Feeds",
            "description": "Shows 20 items in RSS feeds regardless of the Reading settings.",
            "category": "rss",
            "tags": [
                "rss",
                "feeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_posts_per_rss', function () {\n    return 20;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "search-only-posts",
            "version": 1,
            "title": "Limit Search Results to Posts",
            "description": "Restricts front-end search results to blog posts, leaving out pages and other post types.",
            "category": "search",
            "tags": [
                "search",
                "query",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    // Post types to search, for example array( 'post', 'product' ).\n    $query->set( 'post_type', array( 'post' ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "exclude-items-from-search",
            "version": 1,
            "title": "Exclude Specific Pages From Search",
            "description": "Hides chosen posts or pages (thank-you pages, landing pages, legal pages) from front-end search results.",
            "category": "search",
            "tags": [
                "search",
                "query",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    // IDs of the posts or pages to hide.\n    $excluded = array( 0 );\n\n    $query->set( 'post__not_in', array_merge( (array) $query->get( 'post__not_in' ), $excluded ) );\n} );",
            "requires": [],
            "notes": "Replace 0 with the IDs to hide."
        },
        {
            "id": "redirect-single-search-result",
            "version": 1,
            "title": "Go Straight to a Single Search Result",
            "description": "When a search returns exactly one result, opens that result directly instead of showing a results page.",
            "category": "search",
            "tags": [
                "search",
                "redirect",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( is_search() && ! is_paged() && 1 === (int) $wp_query->found_posts && ! empty( $wp_query->posts ) ) {\n        wp_safe_redirect( get_permalink( $wp_query->posts[0] ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "highlight-search-terms",
            "version": 1,
            "title": "Highlight Search Terms in Results",
            "description": "Wraps the searched words in mark tags in result titles and excerpts, so visitors see why each result matched.",
            "category": "search",
            "tags": [
                "search",
                "highlight",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_highlight_terms = function ( $text ) {\n    if ( ! is_search() || ! in_the_loop() || ! is_string( $text ) ) {\n        return $text;\n    }\n\n    $terms = array_filter( preg_split( '/\\s+/', trim( get_search_query( false ) ) ), function ( $term ) {\n        return strlen( $term ) > 1;\n    } );\n\n    if ( ! $terms ) {\n        return $text;\n    }\n\n    $quoted  = array_map( function ( $term ) {\n        return preg_quote( $term, '/' );\n    }, $terms );\n    $pattern = '/(' . implode( '|', $quoted ) . ')(?![^<]*>)/iu';\n    $result  = preg_replace( $pattern, '<mark class=\"search-highlight\">$1</mark>', $text );\n\n    return null === $result ? $text : $result;\n};\n\nadd_filter( 'the_title', $wpide_highlight_terms );\nadd_filter( 'the_excerpt', $wpide_highlight_terms );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "search-results-per-page",
            "version": 1,
            "title": "Number of Search Results per Page",
            "description": "Shows 20 results per page on search results without changing the blog page.",
            "category": "search",
            "tags": [
                "search",
                "pagination",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {\n        $query->set( 'posts_per_page', 20 );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "redirect-empty-search",
            "version": 1,
            "title": "Redirect Empty Searches to the Homepage",
            "description": "Sends visitors who submit the search form without typing anything back to the homepage instead of showing every post.",
            "category": "search",
            "tags": [
                "search",
                "redirect",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( isset( $_GET['s'] ) && is_string( $_GET['s'] ) && '' === trim( wp_unslash( $_GET['s'] ) ) ) {\n        wp_safe_redirect( home_url( '/' ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-protected-posts-from-search",
            "version": 1,
            "title": "Hide Password-Protected Posts From Search",
            "description": "Leaves password-protected posts and pages out of search results so their titles are not exposed to visitors.",
            "category": "search",
            "tags": [
                "search",
                "privacy",
                "password"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {\n        $query->set( 'has_password', false );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pretty-search-urls",
            "version": 1,
            "title": "Pretty Search URLs",
            "description": "Redirects ?s=term searches to /search/term/ when pretty permalinks are enabled, which gives cleaner, shareable URLs.",
            "category": "search",
            "tags": [
                "search",
                "permalinks",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_search() || ! get_option( 'permalink_structure' ) || ! isset( $_GET['s'] ) || 1 !== count( $_GET ) ) {\n        return;\n    }\n\n    $term = get_search_query( false );\n\n    if ( '' !== $term ) {\n        wp_safe_redirect( home_url( '/search/' . rawurlencode( $term ) . '/' ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "classic-widgets-screen",
            "version": 1,
            "title": "Use the Classic Widgets Screen",
            "description": "Restores the classic Widgets screen and Customizer widget panel instead of the block-based widget editor.",
            "category": "widgets",
            "tags": [
                "widgets",
                "classic",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'use_widgets_block_editor', '__return_false' );\nadd_filter( 'gutenberg_use_widgets_block_editor', '__return_false' );",
            "requires": [],
            "notes": "Only applies to classic themes that have widget areas."
        },
        {
            "id": "remove-default-widgets",
            "version": 1,
            "title": "Remove Unused Default Widgets",
            "description": "Unregisters rarely used classic widgets (Meta, Calendar, RSS, Tag Cloud, Pages and Recent Comments) to declutter the widget list.",
            "category": "widgets",
            "tags": [
                "widgets",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'widgets_init', function () {\n    // Delete a line to keep that widget.\n    $widgets = array(\n        'WP_Widget_Meta',\n        'WP_Widget_Calendar',\n        'WP_Widget_RSS',\n        'WP_Widget_Tag_Cloud',\n        'WP_Widget_Pages',\n        'WP_Widget_Recent_Comments',\n    );\n\n    foreach ( $widgets as $widget ) {\n        unregister_widget( $widget );\n    }\n}, 11 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "after-post-widget-area",
            "version": 1,
            "title": "Widget Area Below Single Posts",
            "description": "Registers an \"After Post Content\" widget area and displays it at the end of single posts, without editing theme files.",
            "category": "widgets",
            "tags": [
                "widgets",
                "sidebar",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'widgets_init', function () {\n    register_sidebar( array(\n        'name'          => 'After Post Content',\n        'id'            => 'wpide-after-content',\n        'description'   => 'Shown below the content of single posts.',\n        'before_widget' => '<div id=\"%1$s\" class=\"widget %2$s\">',\n        'after_widget'  => '</div>',\n        'before_title'  => '<h3 class=\"widget-title\">',\n        'after_title'   => '</h3>',\n    ) );\n} );\n\nadd_filter( 'the_content', function ( $content ) {\n    static $running = false;\n\n    if ( $running || ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! is_active_sidebar( 'wpide-after-content' ) ) {\n        return $content;\n    }\n\n    $running = true;\n    ob_start();\n    dynamic_sidebar( 'wpide-after-content' );\n    $widgets = ob_get_clean();\n    $running = false;\n\n    return $content . '<div class=\"wpide-after-content-widgets\">' . $widgets . '</div>';\n}, 30 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "shortcodes-in-widget-titles",
            "version": 1,
            "title": "Run Shortcodes in Widget Titles",
            "description": "Processes shortcodes typed into classic widget titles, for example to show the current year or a dynamic label.",
            "category": "widgets",
            "tags": [
                "widgets",
                "shortcodes",
                "titles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_title', 'do_shortcode' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "recent-posts-widget-exclude-current",
            "version": 1,
            "title": "Hide the Current Post From the Recent Posts Widget",
            "description": "Stops the classic Recent Posts widget from listing the post that is currently being viewed.",
            "category": "widgets",
            "tags": [
                "widgets",
                "recent-posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_posts_args', function ( $args ) {\n    if ( is_singular() ) {\n        $args['post__not_in'] = array_merge( isset( $args['post__not_in'] ) ? (array) $args['post__not_in'] : array(), array( get_queried_object_id() ) );\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "categories-widget-exclude",
            "version": 1,
            "title": "Exclude Categories From the Categories Widget",
            "description": "Hides chosen categories from the classic Categories widget list or dropdown.",
            "category": "widgets",
            "tags": [
                "widgets",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_excluded_categories = array( 1 ); // Category IDs to hide.\n\nadd_filter( 'widget_categories_args', function ( $args ) use ( $wpide_excluded_categories ) {\n    $args['exclude'] = $wpide_excluded_categories;\n\n    return $args;\n} );\n\nadd_filter( 'widget_categories_dropdown_args', function ( $args ) use ( $wpide_excluded_categories ) {\n    $args['exclude'] = $wpide_excluded_categories;\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Replace 1 with your category IDs."
        },
        {
            "id": "tag-cloud-font-sizes",
            "version": 1,
            "title": "Even Font Sizes in the Tag Cloud",
            "description": "Limits the tag cloud to 30 tags with font sizes between 12 and 18 pixels, so it looks tidy in narrow sidebars.",
            "category": "widgets",
            "tags": [
                "widgets",
                "tag-cloud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_tag_cloud_args', function ( $args ) {\n    $args['smallest'] = 12;\n    $args['largest']  = 18;\n    $args['unit']     = 'px';\n    $args['number']   = 30;\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-block-editor",
            "version": 1,
            "title": "Disable the Block Editor",
            "description": "Uses the classic editor for all posts and pages instead of the block editor. The block-based widgets screen is not affected.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "gutenberg",
                "classic-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'use_block_editor_for_post', '__return_false', 100 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-block-editor-for-post-types",
            "version": 1,
            "title": "Disable the Block Editor for Specific Post Types",
            "description": "Keeps the block editor for posts but uses the classic editor for the post types you list (pages by default).",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "gutenberg",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'use_block_editor_for_post_type', function ( $use, $post_type ) {\n    // Post types that should use the classic editor.\n    $classic = array( 'page' );\n\n    return in_array( $post_type, $classic, true ) ? false : $use;\n}, 100, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-editor-fullscreen-mode",
            "version": 1,
            "title": "Turn Off Fullscreen Mode in the Block Editor",
            "description": "Opens the block editor with the admin menu and toolbar visible instead of in fullscreen mode, for every user.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "fullscreen",
                "preferences"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $script = \"wp.domReady(function(){var d=wp.data;if(d.dispatch('core/preferences')){d.dispatch('core/preferences').set('core/edit-post','fullscreenMode',false);}else if(d.select('core/edit-post')&&d.select('core/edit-post').isFeatureActive('fullscreenMode')){d.dispatch('core/edit-post').toggleFeature('fullscreenMode');}});\";\n\n    wp_add_inline_script( 'wp-edit-post', $script );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-editor-welcome-guide",
            "version": 1,
            "title": "Disable the Block Editor Welcome Guide",
            "description": "Stops the \"Welcome to the block editor\" popup from appearing for new users.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "welcome-guide",
                "preferences"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $script = \"wp.domReady(function(){var d=wp.data;if(d.dispatch('core/preferences')){d.dispatch('core/preferences').set('core/edit-post','welcomeGuide',false);}else if(d.select('core/edit-post')&&d.select('core/edit-post').isFeatureActive('welcomeGuide')){d.dispatch('core/edit-post').toggleFeature('welcomeGuide');}});\";\n\n    wp_add_inline_script( 'wp-edit-post', $script );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-openverse",
            "version": 1,
            "title": "Disable Openverse in the Media Inserter",
            "description": "Removes the Openverse tab from the block inserter, so editors cannot pull external stock images into posts.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "openverse",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings ) {\n    $settings['enableOpenverseMediaCategory'] = false;\n\n    return $settings;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-block-directory",
            "version": 1,
            "title": "Disable Block Directory Suggestions",
            "description": "Stops the block inserter from suggesting and installing blocks from the WordPress.org block directory.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "block-directory",
                "plugins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_action( 'enqueue_block_editor_assets', 'wp_enqueue_editor_block_directory_assets' );\nremove_action( 'enqueue_block_editor_assets', 'gutenberg_enqueue_block_editor_assets_block_directory' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-remote-block-patterns",
            "version": 1,
            "title": "Disable Remote Block Patterns",
            "description": "Stops WordPress from loading block patterns from the WordPress.org pattern directory, leaving only theme and plugin patterns.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "patterns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'should_load_remote_block_patterns', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-core-block-patterns",
            "version": 1,
            "title": "Disable Core Block Patterns",
            "description": "Removes the block patterns that ship with WordPress itself, so the pattern inserter only shows your theme and custom patterns.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "patterns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    remove_theme_support( 'core-block-patterns' );\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "unregister-blocks",
            "version": 1,
            "title": "Hide Specific Blocks From the Inserter",
            "description": "Removes the core blocks you list from the post editor, so writers only see the blocks you want them to use.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "blocks",
                "inserter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    // Block names to remove.\n    $blocks = array( 'core/verse', 'core/pullquote' );\n\n    wp_add_inline_script(\n        'wp-edit-post',\n        'wp.domReady(function(){' . wp_json_encode( $blocks ) . '.forEach(function(name){if(wp.blocks.getBlockType(name)){wp.blocks.unregisterBlockType(name);}});});'\n    );\n} );",
            "requires": [],
            "notes": "Posts that already contain a removed block will show it as unsupported in the editor."
        },
        {
            "id": "reusable-blocks-admin-menu",
            "version": 1,
            "title": "Add a Patterns Link to the Admin Menu",
            "description": "Adds a \"Patterns\" item under Appearance that lists your synced patterns (reusable blocks), if your WordPress version does not show one already.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "patterns",
                "reusable-blocks",
                "menu"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    global $submenu;\n\n    foreach ( (array) $submenu as $items ) {\n        foreach ( (array) $items as $item ) {\n            if ( isset( $item[2] ) && false !== strpos( $item[2], 'post_type=wp_block' ) ) {\n                return;\n            }\n        }\n    }\n\n    add_submenu_page( 'themes.php', 'Patterns', 'Patterns', 'edit_posts', 'edit.php?post_type=wp_block' );\n}, 99 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "code-editor-admins-only",
            "version": 1,
            "title": "Restrict the Code Editor to Administrators",
            "description": "Hides the \"Code editor\" mode of the block editor from everyone except administrators, so other users only edit visually.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "code-editor",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        $settings['codeEditingEnabled'] = false;\n    }\n\n    return $settings;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-locking-admins-only",
            "version": 1,
            "title": "Restrict Block Locking to Administrators",
            "description": "Only administrators can lock and unlock blocks, so locked layouts stay intact when other users edit content.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "locking",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings ) {\n    $settings['canLockBlocks'] = current_user_can( 'manage_options' );\n\n    return $settings;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-drop-cap",
            "version": 1,
            "title": "Disable the Drop Cap Option",
            "description": "Removes the drop cap toggle from the Paragraph block settings.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "typography",
                "theme-json"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => 2,\n        'settings' => array(\n            'typography' => array( 'dropCap' => false ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Requires WordPress 6.1 or newer."
        },
        {
            "id": "woo-add-to-cart-button-text",
            "version": 1,
            "title": "Change the Add to Cart Button Text",
            "description": "Replaces \"Add to cart\" with \"Buy now\" on single product pages and on shop listings for simple, in-stock products.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "add-to-cart",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_single_add_to_cart_text', function () {\n    return 'Buy now';\n} );\n\nadd_filter( 'woocommerce_product_add_to_cart_text', function ( $text, $product = null ) {\n    if ( $product && $product->is_type( 'simple' ) && $product->is_purchasable() && $product->is_in_stock() ) {\n        return 'Buy now';\n    }\n\n    return $text;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit both \"Buy now\" strings."
        },
        {
            "id": "woo-hide-prices-for-guests",
            "version": 1,
            "title": "Hide Prices and Purchasing From Guests",
            "description": "Shows a \"Log in to see prices\" link instead of prices and disables purchasing until the visitor logs in. Common for wholesale and B2B shops.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "wholesale",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_price_html', function ( $price ) {\n    if ( is_user_logged_in() || is_admin() ) {\n        return $price;\n    }\n\n    return '<a class=\"wpide-login-for-price\" href=\"' . esc_url( wc_get_page_permalink( 'myaccount' ) ) . '\">Log in to see prices</a>';\n}, 99 );\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable ) {\n    return is_user_logged_in() ? $purchasable : false;\n}, 99 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-remove-related-products",
            "version": 1,
            "title": "Remove Related Products",
            "description": "Hides the Related products section below single product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "related-products",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_related_products', '__return_empty_array', 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-related-products-count",
            "version": 1,
            "title": "Change the Number of Related Products",
            "description": "Shows four related products in four columns on single product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "related-products",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_output_related_products_args', function ( $args ) {\n    $args['posts_per_page'] = 4;\n    $args['columns']        = 4;\n\n    return $args;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-products-per-page",
            "version": 1,
            "title": "Products per Page on the Shop",
            "description": "Sets how many products are shown per page on the shop and product category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'loop_shop_per_page', function () {\n    return 24;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Applies to classic shop templates. Block themes set this in the Product Collection block."
        },
        {
            "id": "woo-shop-columns",
            "version": 1,
            "title": "Number of Columns on the Shop Page",
            "description": "Changes the number of product columns on the shop and product category pages for classic themes.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'loop_shop_columns', function () {\n    return 3;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Some themes control columns themselves and ignore this filter."
        },
        {
            "id": "woo-hide-shipping-when-free",
            "version": 1,
            "title": "Hide Other Shipping Rates When Free Shipping Is Available",
            "description": "When the cart qualifies for free shipping, shows only the free shipping and local pickup options instead of all rates.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_package_rates', function ( $rates ) {\n    $kept = array();\n\n    foreach ( $rates as $rate_id => $rate ) {\n        if ( 'free_shipping' === $rate->get_method_id() ) {\n            $kept[ $rate_id ] = $rate;\n        }\n    }\n\n    if ( ! $kept ) {\n        return $rates;\n    }\n\n    foreach ( $rates as $rate_id => $rate ) {\n        if ( in_array( $rate->get_method_id(), array( 'local_pickup', 'pickup_location' ), true ) ) {\n            $kept[ $rate_id ] = $rate;\n        }\n    }\n\n    return $kept;\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Clear the shipping cache (WooCommerce > Status > Tools) or update the cart to see the change."
        },
        {
            "id": "woo-minimum-order-amount",
            "version": 1,
            "title": "Minimum Order Amount",
            "description": "Blocks checkout and shows a notice until the cart subtotal reaches a minimum amount (50 in your store currency by default).",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "checkout",
                "minimum"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_check_cart_items', function () {\n    $minimum = 50;\n\n    if ( ! function_exists( 'WC' ) || ! WC()->cart ) {\n        return;\n    }\n\n    $subtotal = (float) WC()->cart->get_subtotal();\n\n    if ( $subtotal > 0 && $subtotal < $minimum ) {\n        wc_add_notice(\n            sprintf( 'Your current subtotal is %1$s. The minimum order amount is %2$s.', wc_price( $subtotal ), wc_price( $minimum ) ),\n            'error'\n        );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with the classic cart and checkout shortcodes. The subtotal is before tax and discounts."
        },
        {
            "id": "woo-redirect-to-checkout",
            "version": 1,
            "title": "Go Straight to Checkout After Adding to Cart",
            "description": "Skips the cart page and sends customers directly to checkout after they add a product, for single-product or quick-purchase stores.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "redirect",
                "add-to-cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_add_to_cart_redirect', function () {\n    return wc_get_checkout_url();\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Turn off \"Enable AJAX add to cart buttons on archives\" in WooCommerce > Settings > Products so shop buttons redirect too."
        },
        {
            "id": "woo-remove-checkout-fields",
            "version": 1,
            "title": "Remove Optional Checkout Fields",
            "description": "Removes the company and second address line fields from the classic checkout form to make it shorter.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_checkout_fields', function ( $fields ) {\n    // Field keys to remove.\n    $remove = array( 'billing_company', 'billing_address_2', 'shipping_company', 'shipping_address_2' );\n\n    foreach ( $remove as $key ) {\n        $group = 0 === strpos( $key, 'shipping_' ) ? 'shipping' : 'billing';\n        unset( $fields[ $group ][ $key ] );\n    }\n\n    return $fields;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Applies to the classic checkout shortcode. Block checkout fields are managed in the Site Editor."
        },
        {
            "id": "woo-optional-phone",
            "version": 1,
            "title": "Make the Billing Phone Field Optional",
            "description": "Keeps the phone field on the classic checkout but no longer requires it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    if ( isset( $fields['billing_phone'] ) ) {\n        $fields['billing_phone']['required'] = false;\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-autocomplete-virtual-orders",
            "version": 1,
            "title": "Auto-Complete Paid Orders for Virtual Products",
            "description": "Marks paid orders as Completed instead of Processing when every item is virtual, such as services, memberships or event tickets.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "virtual",
                "automation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_payment_complete_order_status', function ( $status, $order_id, $order = null ) {\n    if ( 'processing' !== $status ) {\n        return $status;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );\n\n    if ( ! $order || ! $order->get_items() ) {\n        return $status;\n    }\n\n    foreach ( $order->get_items() as $item ) {\n        $product = is_callable( array( $item, 'get_product' ) ) ? $item->get_product() : null;\n\n        if ( ! $product || ! $product->is_virtual() ) {\n            return $status;\n        }\n    }\n\n    return 'completed';\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-remove-product-tabs",
            "version": 1,
            "title": "Remove Product Page Tabs",
            "description": "Removes the \"Additional information\" tab from single product pages. The reviews and description tabs can be removed the same way.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "tabs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_tabs', function ( $tabs ) {\n    // Tab keys: description, additional_information, reviews.\n    unset( $tabs['additional_information'] );\n\n    return $tabs;\n}, 98 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-sale-badge-percentage",
            "version": 1,
            "title": "Show the Discount Percentage on Sale Badges",
            "description": "Replaces the \"Sale!\" badge with the actual discount, for example \"-25%\". Variable products show the largest discount among their variations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "badges",
                "pricing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_sale_flash', function ( $html, $post, $product ) {\n    if ( ! $product instanceof WC_Product ) {\n        return $html;\n    }\n\n    $percent = 0;\n\n    if ( $product->is_type( 'variable' ) ) {\n        foreach ( $product->get_children() as $child_id ) {\n            $child = wc_get_product( $child_id );\n\n            if ( ! $child || ! $child->is_on_sale() ) {\n                continue;\n            }\n\n            $regular = (float) $child->get_regular_price();\n            $sale    = (float) $child->get_sale_price();\n\n            if ( $regular > 0 ) {\n                $percent = max( $percent, ( $regular - $sale ) / $regular * 100 );\n            }\n        }\n    } else {\n        $regular = (float) $product->get_regular_price();\n        $sale    = (float) $product->get_sale_price();\n\n        if ( $regular > 0 && '' !== $product->get_sale_price() ) {\n            $percent = ( $regular - $sale ) / $regular * 100;\n        }\n    }\n\n    if ( $percent < 1 ) {\n        return $html;\n    }\n\n    return '<span class=\"onsale\">-' . esc_html( (string) (int) round( $percent ) ) . '%</span>';\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-empty-cart-button",
            "version": 1,
            "title": "Empty Cart Button",
            "description": "Adds an \"Empty cart\" button next to \"Update cart\" on the classic cart page, protected by a nonce.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_cart_actions', function () {\n    $url = wp_nonce_url( add_query_arg( 'wpide_empty_cart', '1', wc_get_cart_url() ), 'wpide_empty_cart' );\n\n    printf( '<a class=\"button wpide-empty-cart\" href=\"%s\">Empty cart</a>', esc_url( $url ) );\n} );\n\nadd_action( 'wp_loaded', function () {\n    if ( empty( $_GET['wpide_empty_cart'] ) || ! function_exists( 'WC' ) || ! WC()->cart ) {\n        return;\n    }\n\n    $nonce = isset( $_GET['_wpnonce'] ) ? sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ) : '';\n\n    if ( ! wp_verify_nonce( $nonce, 'wpide_empty_cart' ) ) {\n        return;\n    }\n\n    WC()->cart->empty_cart();\n    wp_safe_redirect( wc_get_cart_url() );\n    exit;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-continue-shopping-to-shop",
            "version": 1,
            "title": "Send \"Continue Shopping\" to the Shop Page",
            "description": "Points the \"Continue shopping\" and \"Return to shop\" links to the main shop page instead of the last viewed product or the homepage.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "shop",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_shop_url = function () {\n    return wc_get_page_permalink( 'shop' );\n};\n\nadd_filter( 'woocommerce_continue_shopping_redirect', $wpide_shop_url );\nadd_filter( 'woocommerce_return_to_shop_redirect', $wpide_shop_url );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-sold-individually",
            "version": 1,
            "title": "Limit Every Product to One per Order",
            "description": "Allows only one of each product in the cart, as if \"Sold individually\" were ticked on every product. Useful for licenses, courses and bookings.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "quantity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_is_sold_individually', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-max-quantity",
            "version": 1,
            "title": "Cap the Quantity Selector",
            "description": "Limits the quantity field on product and cart pages to a maximum of 10 per product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_quantity_input_args', function ( $args, $product ) {\n    $max = 10;\n\n    if ( ! isset( $args['max_value'] ) || $args['max_value'] <= 0 || $args['max_value'] > $max ) {\n        $args['max_value'] = $max;\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "This limits the input field. Quantities added another way (for example by URL) are not validated."
        },
        {
            "id": "woo-password-strength",
            "version": 1,
            "title": "Relax the Account Password Strength Requirement",
            "description": "Lowers the password strength WooCommerce requires on registration and checkout from \"strong\" to \"medium\", which reduces abandoned sign-ups.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "password",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_min_password_strength', function () {\n    // 0 = very weak, 1 = weak, 2 = medium, 3 = strong (default), 4 = very strong.\n    return 2;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-hide-sku",
            "version": 1,
            "title": "Hide SKUs on the Front End",
            "description": "Stops SKUs from being shown on product pages while keeping them available in the admin.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sku",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wc_product_sku_enabled', function ( $enabled ) {\n    return is_admin() ? $enabled : false;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-free-shipping-progress",
            "version": 1,
            "title": "Free Shipping Progress Notice",
            "description": "Shows \"Add 12.00 more to get free shipping\" on the cart and checkout pages, or a success message once the threshold is reached.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_shipping_notice = function () {\n    $threshold = 75;\n\n    if ( ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) {\n        return;\n    }\n\n    $subtotal = (float) WC()->cart->get_displayed_subtotal();\n\n    if ( $subtotal < $threshold ) {\n        wc_print_notice( sprintf( 'Add %s more to your cart to get free shipping.', wc_price( $threshold - $subtotal ) ), 'notice' );\n    } else {\n        wc_print_notice( 'Your order qualifies for free shipping.', 'success' );\n    }\n};\n\nadd_action( 'woocommerce_before_cart', $wpide_free_shipping_notice );\nadd_action( 'woocommerce_before_checkout_form', $wpide_free_shipping_notice, 5 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $threshold to the minimum amount configured in your Free Shipping method."
        },
        {
            "id": "woo-assets-only-on-shop-pages",
            "version": 1,
            "title": "Load WooCommerce Assets Only on Shop Pages",
            "description": "Stops WooCommerce stylesheets and scripts from loading on pages that are not part of the store, such as blog posts and the about page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "performance",
                "scripts",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! function_exists( 'is_woocommerce' ) ) {\n        return;\n    }\n\n    if ( is_woocommerce() || is_cart() || is_checkout() || is_account_page() ) {\n        return;\n    }\n\n    foreach ( array( 'woocommerce-general', 'woocommerce-layout', 'woocommerce-smallscreen' ) as $handle ) {\n        wp_dequeue_style( $handle );\n    }\n\n    foreach ( array( 'wc-cart-fragments', 'woocommerce', 'wc-add-to-cart' ) as $handle ) {\n        wp_dequeue_script( $handle );\n    }\n}, 99 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Do not use this if non-shop pages show products, a mini cart or add-to-cart buttons."
        },
        {
            "id": "woo-remove-order-notes",
            "version": 1,
            "title": "Remove the Order Notes Field",
            "description": "Removes the \"Order notes\" textarea from the classic checkout page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_enable_order_notes_field', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-hide-coupon-on-checkout",
            "version": 1,
            "title": "Hide the Coupon Form on Checkout",
            "description": "Removes the \"Have a coupon?\" prompt from the classic checkout page. Customers can still apply coupons on the cart page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "coupons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp', function () {\n    remove_action( 'woocommerce_before_checkout_form', 'woocommerce_checkout_coupon_form', 10 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-custom-thank-you-text",
            "version": 1,
            "title": "Personal Thank-You Message After Purchase",
            "description": "Replaces the standard \"Thank you. Your order has been received.\" text with a friendlier message that includes the customer first name.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "thank-you",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_thankyou_order_received_text', function ( $text, $order = null ) {\n    $name = $order instanceof WC_Order ? $order->get_billing_first_name() : '';\n\n    if ( '' === $name ) {\n        return 'Thank you! Your order has been received and a confirmation email is on its way.';\n    }\n\n    return sprintf( 'Thank you, %s! Your order has been received and a confirmation email is on its way.', esc_html( $name ) );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-remove-breadcrumbs",
            "version": 1,
            "title": "Remove WooCommerce Breadcrumbs",
            "description": "Hides the WooCommerce breadcrumb trail on shop, category and product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "breadcrumbs",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_breadcrumb', '__return_empty_array' );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-disable-marketplace-suggestions",
            "version": 1,
            "title": "Disable Marketplace Suggestions",
            "description": "Removes the extension suggestions WooCommerce shows in the admin, such as on the product and order screens.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "clean-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_allow_marketplace_suggestions', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "woo-place-order-button-text",
            "version": 1,
            "title": "Change the Place Order Button Text",
            "description": "Renames the \"Place order\" button on the classic checkout to something clearer, such as \"Complete purchase\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_order_button_text', function () {\n    return 'Complete purchase';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "redirect-404-to-homepage",
            "version": 1,
            "title": "Redirect 404 Pages to the Homepage",
            "description": "Sends visitors who hit a missing page to the homepage instead of showing the 404 template.",
            "category": "redirects",
            "tags": [
                "redirects",
                "404",
                "not-found"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_404() ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n}, 99 );",
            "requires": [],
            "notes": "Search engines treat mass redirects to the homepage as soft 404s. A helpful 404 page or specific redirects are usually better."
        },
        {
            "id": "redirect-non-editors-from-admin",
            "version": 1,
            "title": "Keep Subscribers Out of wp-admin",
            "description": "Redirects users who cannot edit posts (subscribers, customers) to the homepage when they try to open the admin area. AJAX and admin-post requests keep working.",
            "category": "redirects",
            "tags": [
                "redirects",
                "admin",
                "subscribers",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    global $pagenow;\n\n    if ( wp_doing_ajax() || 'admin-post.php' === $pagenow || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_safe_redirect( home_url( '/' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Users can still change their password through the front-end account page if your site has one."
        },
        {
            "id": "simple-redirect-list",
            "version": 1,
            "title": "Simple Redirect List",
            "description": "Redirects old URLs to new ones with a 301 status from a list you maintain in the snippet. Good for a handful of moved pages.",
            "category": "redirects",
            "tags": [
                "redirects",
                "301",
                "urls",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    // Old path => new path or full URL on this site.\n    $redirects = array(\n        '/old-page/'    => '/new-page/',\n        '/2019/promo/'  => '/offers/',\n    );\n\n    $request = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';\n    $path    = '/' . trim( (string) wp_parse_url( $request, PHP_URL_PATH ), '/' ) . '/';\n\n    foreach ( $redirects as $from => $to ) {\n        if ( '/' . trim( $from, '/' ) . '/' !== $path ) {\n            continue;\n        }\n\n        wp_safe_redirect( preg_match( '#^https?://#', $to ) ? $to : home_url( $to ), 301 );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "Paths are matched without the query string. On a subdirectory install include the subdirectory in the old path. For other domains, add them with the allowed_redirect_hosts filter."
        },
        {
            "id": "members-only-site",
            "version": 1,
            "title": "Require Login to View the Site",
            "description": "Makes the whole front end private: visitors who are not logged in are sent to the login page and returned to the page they asked for afterwards.",
            "category": "redirects",
            "tags": [
                "redirects",
                "private",
                "login",
                "intranet"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_user_logged_in() ) {\n        return;\n    }\n\n    // Page slugs that stay public, for example array( 'contact', 'privacy-policy' ).\n    $public_pages = array();\n\n    if ( $public_pages && is_page( $public_pages ) ) {\n        return;\n    }\n\n    auth_redirect();\n} );",
            "requires": [],
            "notes": "Feeds and the REST API are not covered. Combine with \"Restrict the REST API to Logged-In Users\" for full privacy."
        },
        {
            "id": "maintenance-mode",
            "version": 1,
            "title": "Maintenance Mode for Visitors",
            "description": "Shows a \"back soon\" message with a 503 status to visitors while logged-in staff browse the site normally. The login page stays reachable.",
            "category": "redirects",
            "tags": [
                "maintenance",
                "coming-soon",
                "503"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    if ( ! headers_sent() ) {\n        header( 'Retry-After: 3600' );\n    }\n\n    wp_die(\n        '<h1>We&rsquo;ll be right back</h1><p>The site is undergoing scheduled maintenance. Please check back soon.</p>',\n        esc_html( get_bloginfo( 'name' ) ) . ' - Maintenance',\n        array( 'response' => 503 )\n    );\n} );",
            "requires": [],
            "notes": "Remember to deactivate the snippet when maintenance is over."
        },
        {
            "id": "redirect-default-registration",
            "version": 1,
            "title": "Redirect the Default Registration Page",
            "description": "Sends visitors who open wp-login.php?action=register to your own registration page.",
            "category": "redirects",
            "tags": [
                "redirects",
                "registration",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_form_register', function () {\n    wp_safe_redirect( home_url( '/register/' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Change /register/ to the path of your registration page."
        },
        {
            "id": "email-sender-name-and-address",
            "version": 1,
            "title": "Change the Sender Name and Address of WordPress Emails",
            "description": "Replaces the default \"WordPress <wordpress@yourdomain>\" sender with your site name and, optionally, an address of your choice.",
            "category": "email",
            "tags": [
                "email",
                "sender",
                "from"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail_from', function ( $email ) {\n    $custom = ''; // For example 'hello@yourdomain.com'. Leave empty to keep the current address.\n\n    return is_email( $custom ) ? $custom : $email;\n} );\n\nadd_filter( 'wp_mail_from_name', function ( $name ) {\n    return 'WordPress' === $name ? wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) : $name;\n} );",
            "requires": [],
            "notes": "Use an address on your own domain so messages pass SPF and DKIM checks."
        },
        {
            "id": "disable-new-user-admin-email",
            "version": 1,
            "title": "Stop New User Notifications to the Admin",
            "description": "Prevents the \"New user registration\" email that is sent to the site admin every time someone registers. The welcome email to the user still goes out.",
            "category": "email",
            "tags": [
                "email",
                "notifications",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_send_new_user_notification_to_admin', '__return_false' );",
            "requires": [],
            "notes": "Requires WordPress 6.1 or newer."
        },
        {
            "id": "disable-password-change-admin-email",
            "version": 1,
            "title": "Stop Password Reset Notifications to the Admin",
            "description": "Stops the email the site admin gets each time a user resets their password.",
            "category": "email",
            "tags": [
                "email",
                "notifications",
                "password"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "remove_action( 'after_password_reset', 'wp_password_change_notification' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-auto-update-emails",
            "version": 1,
            "title": "Stop Auto-Update Success Emails",
            "description": "Silences the emails sent after successful automatic updates of WordPress, plugins and themes. Emails about failed core updates are kept.",
            "category": "email",
            "tags": [
                "email",
                "updates",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'auto_core_update_send_email', function ( $send, $type ) {\n    return 'success' === $type ? false : $send;\n}, 10, 2 );\n\nadd_filter( 'auto_plugin_update_send_email', '__return_false' );\nadd_filter( 'auto_theme_update_send_email', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-user-change-emails",
            "version": 1,
            "title": "Stop Password and Email Change Confirmations",
            "description": "Stops WordPress from emailing users when their password or email address is changed, for example during bulk imports.",
            "category": "email",
            "tags": [
                "email",
                "notifications",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'send_password_change_email', '__return_false' );\nadd_filter( 'send_email_change_email', '__return_false' );",
            "requires": [],
            "notes": "These emails alert users to account takeovers. Consider enabling the snippet only while importing."
        },
        {
            "id": "smtp-from-wp-config",
            "version": 1,
            "title": "Send Email Through SMTP",
            "description": "Routes all WordPress email through an SMTP server, using credentials stored as constants in wp-config.php rather than in the database.",
            "category": "email",
            "tags": [
                "email",
                "smtp",
                "deliverability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    if ( ! defined( 'SMTP_HOST' ) || '' === SMTP_HOST ) {\n        return;\n    }\n\n    $phpmailer->isSMTP();\n    $phpmailer->Host       = SMTP_HOST;\n    $phpmailer->Port       = defined( 'SMTP_PORT' ) ? (int) SMTP_PORT : 587;\n    $phpmailer->SMTPSecure = defined( 'SMTP_SECURE' ) ? SMTP_SECURE : 'tls';\n\n    if ( defined( 'SMTP_USER' ) && defined( 'SMTP_PASS' ) ) {\n        $phpmailer->SMTPAuth = true;\n        $phpmailer->Username = SMTP_USER;\n        $phpmailer->Password = SMTP_PASS;\n    }\n\n    if ( defined( 'SMTP_FROM' ) && is_email( SMTP_FROM ) ) {\n        $phpmailer->setFrom( SMTP_FROM, wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), false );\n    }\n} );",
            "requires": [],
            "notes": "Add to wp-config.php: define( 'SMTP_HOST', 'smtp.example.com' ); define( 'SMTP_PORT', 587 ); define( 'SMTP_SECURE', 'tls' ); define( 'SMTP_USER', 'user' ); define( 'SMTP_PASS', 'password' ); and optionally define( 'SMTP_FROM', 'hello@example.com' ); The snippet does nothing until SMTP_HOST is defined."
        },
        {
            "id": "log-failed-emails",
            "version": 1,
            "title": "Log Failed Emails",
            "description": "Writes a line to the PHP error log whenever wp_mail() fails, with the subject, recipients and error message, to help debug delivery problems.",
            "category": "email",
            "tags": [
                "email",
                "debugging",
                "logs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_mail_failed', function ( $error ) {\n    $data    = (array) $error->get_error_data();\n    $to      = isset( $data['to'] ) ? implode( ', ', (array) $data['to'] ) : 'unknown';\n    $subject = isset( $data['subject'] ) ? (string) $data['subject'] : '';\n\n    error_log( sprintf( '[wp_mail] Could not send \"%s\" to %s: %s', $subject, $to, $error->get_error_message() ) );\n} );",
            "requires": [],
            "notes": "The log contains recipient addresses. Enable WP_DEBUG_LOG or check your server error log."
        },
        {
            "id": "email-reply-to-admin",
            "version": 1,
            "title": "Add a Reply-To Header to Outgoing Emails",
            "description": "Adds the site admin address as Reply-To on emails that do not set one, so replies to no-reply senders reach a real inbox.",
            "category": "email",
            "tags": [
                "email",
                "reply-to",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $args ) {\n    $reply_to = (string) get_option( 'admin_email' );\n    $headers  = isset( $args['headers'] ) ? $args['headers'] : array();\n\n    if ( ! is_array( $headers ) ) {\n        $headers = '' === trim( (string) $headers ) ? array() : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $headers ) );\n    }\n\n    foreach ( $headers as $header ) {\n        if ( 0 === stripos( trim( $header ), 'reply-to:' ) ) {\n            return $args;\n        }\n    }\n\n    if ( is_email( $reply_to ) ) {\n        $headers[]       = 'Reply-To: ' . $reply_to;\n        $args['headers'] = $headers;\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-comment-notification-emails",
            "version": 1,
            "title": "Stop Comment Notification Emails",
            "description": "Stops WordPress from emailing post authors about new comments and moderators about comments awaiting approval.",
            "category": "email",
            "tags": [
                "email",
                "comments",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'notify_post_author', '__return_false' );\nadd_filter( 'notify_moderator', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "show-template-in-admin-bar",
            "version": 1,
            "title": "Show the Current Template in the Admin Bar",
            "description": "Displays the theme template file (and block template, on block themes) used for the current page in the admin bar, for administrators only.",
            "category": "developer",
            "tags": [
                "developer",
                "templates",
                "admin-bar",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'template_include', function ( $template ) {\n    $GLOBALS['wpide_current_template'] = $template;\n\n    return $template;\n}, 9999 );\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) || empty( $GLOBALS['wpide_current_template'] ) ) {\n        return;\n    }\n\n    $template = wp_normalize_path( $GLOBALS['wpide_current_template'] );\n    $label    = str_replace( wp_normalize_path( get_theme_root() ) . '/', '', $template );\n\n    if ( ! empty( $GLOBALS['_wp_current_template_id'] ) ) {\n        $label = $GLOBALS['_wp_current_template_id'];\n    }\n\n    $admin_bar->add_node( array(\n        'id'     => 'wpide-current-template',\n        'parent' => 'top-secondary',\n        'title'  => 'Template: ' . esc_html( $label ),\n    ) );\n}, 999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "performance-stats-comment",
            "version": 1,
            "title": "Query Count and Load Time in the Page Source",
            "description": "Adds an HTML comment at the bottom of front-end and admin pages with the number of database queries, generation time and peak memory. Visible to administrators only.",
            "category": "developer",
            "tags": [
                "developer",
                "performance",
                "debugging",
                "queries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_stats_comment = function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    printf(\n        \"\\n<!-- %d queries in %s seconds, peak memory %s -->\\n\",\n        (int) get_num_queries(),\n        esc_html( timer_stop( 0, 3 ) ),\n        esc_html( size_format( memory_get_peak_usage( true ), 1 ) )\n    );\n};\n\nadd_action( 'wp_footer', $wpide_stats_comment, 9999 );\nadd_action( 'admin_footer', $wpide_stats_comment, 9999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "extra-body-classes",
            "version": 1,
            "title": "Add Useful Body Classes",
            "description": "Adds the post type and slug of the current page plus a logged-in or logged-out class to the body tag, for precise CSS targeting.",
            "category": "developer",
            "tags": [
                "developer",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'body_class', function ( $classes ) {\n    if ( is_singular() ) {\n        $post = get_queried_object();\n\n        if ( $post instanceof WP_Post ) {\n            $classes[] = sanitize_html_class( $post->post_type . '-' . $post->post_name );\n        }\n    }\n\n    $classes[] = is_user_logged_in() ? 'is-logged-in' : 'is-logged-out';\n\n    return $classes;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "register-custom-post-type",
            "version": 1,
            "title": "Register a Custom Post Type",
            "description": "A ready-to-edit example that registers a \"Portfolio\" post type with archive, featured images and block editor support.",
            "category": "developer",
            "tags": [
                "developer",
                "custom-post-types",
                "portfolio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_type( 'portfolio', array(\n        'labels'       => array(\n            'name'          => 'Portfolio',\n            'singular_name' => 'Portfolio Item',\n            'add_new_item'  => 'Add New Portfolio Item',\n            'edit_item'     => 'Edit Portfolio Item',\n            'all_items'     => 'All Portfolio Items',\n            'search_items'  => 'Search Portfolio',\n            'not_found'     => 'No portfolio items found.',\n        ),\n        'public'       => true,\n        'has_archive'  => true,\n        'show_in_rest' => true,\n        'menu_icon'    => 'dashicons-portfolio',\n        'menu_position' => 20,\n        'supports'     => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n        'rewrite'      => array( 'slug' => 'portfolio' ),\n    ) );\n} );",
            "requires": [],
            "notes": "After activating, visit Settings > Permalinks once so WordPress refreshes its URL rules."
        },
        {
            "id": "register-custom-taxonomy",
            "version": 1,
            "title": "Register a Custom Taxonomy",
            "description": "A ready-to-edit example that registers a hierarchical \"Project Type\" taxonomy for the Portfolio post type.",
            "category": "developer",
            "tags": [
                "developer",
                "taxonomy",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy( 'project_type', array( 'portfolio' ), array(\n        'labels'            => array(\n            'name'          => 'Project Types',\n            'singular_name' => 'Project Type',\n            'add_new_item'  => 'Add New Project Type',\n            'edit_item'     => 'Edit Project Type',\n            'all_items'     => 'All Project Types',\n        ),\n        'hierarchical'      => true,\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n        'rewrite'           => array( 'slug' => 'project-type' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Change portfolio to the post type(s) the taxonomy belongs to, then visit Settings > Permalinks once."
        },
        {
            "id": "featured-image-url-in-rest",
            "version": 1,
            "title": "Featured Image URL in the REST API",
            "description": "Adds a featured_image_url field to posts and pages in REST API responses, so headless front ends do not need a second request.",
            "category": "developer",
            "tags": [
                "developer",
                "rest-api",
                "headless",
                "featured-image"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_field( array( 'post', 'page' ), 'featured_image_url', array(\n        'get_callback' => function ( $object ) {\n            $id = isset( $object['featured_media'] ) ? (int) $object['featured_media'] : 0;\n\n            return $id ? wp_get_attachment_image_url( $id, 'large' ) : null;\n        },\n        'schema'       => array(\n            'description' => 'URL of the featured image in the large size.',\n            'type'        => array( 'string', 'null' ),\n            'context'     => array( 'view', 'edit', 'embed' ),\n            'readonly'    => true,\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "custom-cron-interval",
            "version": 1,
            "title": "Add a Five-Minute Cron Schedule",
            "description": "Registers an \"every_five_minutes\" interval that your own scheduled events can use with wp_schedule_event().",
            "category": "developer",
            "tags": [
                "developer",
                "cron",
                "scheduling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'cron_schedules', function ( $schedules ) {\n    if ( ! isset( $schedules['every_five_minutes'] ) ) {\n        $schedules['every_five_minutes'] = array(\n            'interval' => 5 * MINUTE_IN_SECONDS,\n            'display'  => 'Every five minutes',\n        );\n    }\n\n    return $schedules;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "enable-featured-images",
            "version": 1,
            "title": "Enable Featured Images for Any Theme",
            "description": "Turns on featured image (post thumbnail) support for themes that do not declare it.",
            "category": "developer",
            "tags": [
                "developer",
                "featured-image",
                "theme-support"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    add_theme_support( 'post-thumbnails' );\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-updates-for-plugin",
            "version": 1,
            "title": "Hide Update Notices for a Specific Plugin",
            "description": "Stops WordPress from offering updates for plugins you list, such as a customized plugin that must not be overwritten.",
            "category": "developer",
            "tags": [
                "developer",
                "updates",
                "plugins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'site_transient_update_plugins', function ( $value ) {\n    // Plugin basenames: folder/main-file.php.\n    $plugins = array( 'example-plugin/example-plugin.php' );\n\n    if ( is_object( $value ) && isset( $value->response ) && is_array( $value->response ) ) {\n        foreach ( $plugins as $plugin ) {\n            unset( $value->response[ $plugin ] );\n        }\n    }\n\n    return $value;\n} );",
            "requires": [],
            "notes": "You will not hear about security fixes for the listed plugins. Check them manually."
        },
        {
            "id": "load-custom-stylesheet",
            "version": 1,
            "title": "Load a custom.css File From the Theme",
            "description": "Enqueues custom.css from the active (child) theme folder if it exists, with a version based on its modification time to bust caches.",
            "category": "developer",
            "tags": [
                "developer",
                "css",
                "theme",
                "enqueue"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    $path = get_stylesheet_directory() . '/custom.css';\n\n    if ( file_exists( $path ) ) {\n        wp_enqueue_style( 'wpide-custom-css', get_stylesheet_directory_uri() . '/custom.css', array(), (string) filemtime( $path ) );\n    }\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "google-analytics-4",
            "version": 1,
            "title": "Google Analytics 4 Tag",
            "description": "Adds the Google Analytics 4 (gtag.js) tracking code to the head of every public page.",
            "category": "analytics",
            "tags": [
                "analytics",
                "google-analytics",
                "ga4",
                "tracking"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Google Analytics 4. Replace G-XXXXXXXXXX with your Measurement ID (two places). -->\n<script async src=\"https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX\"></script>\n<script>\n  window.dataLayer = window.dataLayer || [];\n  function gtag(){dataLayer.push(arguments);}\n  gtag('js', new Date());\n  gtag('config', 'G-XXXXXXXXXX');\n</script>",
            "requires": [],
            "notes": "Find your Measurement ID in Google Analytics under Admin > Data streams. G-XXXXXXXXXX is a placeholder. Check your privacy and consent obligations before enabling."
        },
        {
            "id": "google-analytics-4-exclude-staff",
            "version": 1,
            "title": "Google Analytics 4 Without Tracking Logged-In Staff",
            "description": "Outputs the GA4 tag only for visitors and customers, so your own visits as an author, editor or administrator do not skew the statistics.",
            "category": "analytics",
            "tags": [
                "analytics",
                "google-analytics",
                "ga4",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $measurement_id = 'G-XXXXXXXXXX'; // Your GA4 Measurement ID.\n\n    if ( current_user_can( 'edit_posts' ) || 'G-XXXXXXXXXX' === $measurement_id ) {\n        return;\n    }\n\n    printf(\n        \"<script async src=\\\"%s\\\"></script>\\n<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',%s);</script>\\n\",\n        esc_url( 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $measurement_id ) ),\n        wp_json_encode( $measurement_id )\n    );\n}, 5 );",
            "requires": [],
            "notes": "Replace G-XXXXXXXXXX with your Measurement ID. Nothing is printed while the placeholder is still in place."
        },
        {
            "id": "google-tag-manager-head",
            "version": 1,
            "title": "Google Tag Manager (Head)",
            "description": "Adds the Google Tag Manager container script to the head of every page. Use it together with the GTM body snippet.",
            "category": "analytics",
            "tags": [
                "analytics",
                "google-tag-manager",
                "gtm",
                "tracking"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Google Tag Manager. Replace GTM-XXXXXXX with your container ID. -->\n<script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':\nnew Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],\nj=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=\n'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);\n})(window,document,'script','dataLayer','GTM-XXXXXXX');</script>",
            "requires": [],
            "notes": "GTM-XXXXXXX is a placeholder for your container ID from tagmanager.google.com."
        },
        {
            "id": "google-tag-manager-body",
            "version": 1,
            "title": "Google Tag Manager (Body)",
            "description": "Adds the Google Tag Manager noscript fallback right after the opening body tag. Use it together with the GTM head snippet.",
            "category": "analytics",
            "tags": [
                "analytics",
                "google-tag-manager",
                "gtm",
                "tracking"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_body",
            "insert_number": 1,
            "code": "<!-- Google Tag Manager (noscript). Replace GTM-XXXXXXX with your container ID. -->\n<noscript><iframe src=\"https://www.googletagmanager.com/ns.html?id=GTM-XXXXXXX\"\nheight=\"0\" width=\"0\" style=\"display:none;visibility:hidden\"></iframe></noscript>",
            "requires": [],
            "notes": "Your theme must call wp_body_open() for this location to work."
        },
        {
            "id": "meta-pixel",
            "version": 1,
            "title": "Meta (Facebook) Pixel",
            "description": "Adds the Meta Pixel base code to track page views for Facebook and Instagram ads.",
            "category": "analytics",
            "tags": [
                "analytics",
                "facebook",
                "meta",
                "pixel",
                "ads"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Meta Pixel. Replace YOUR_PIXEL_ID (two places) with your pixel ID. -->\n<script>\n!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?\nn.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;\nn.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;\nt.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,\ndocument,'script','https://connect.facebook.net/en_US/fbevents.js');\nfbq('init', 'YOUR_PIXEL_ID');\nfbq('track', 'PageView');\n</script>\n<noscript><img height=\"1\" width=\"1\" style=\"display:none\" alt=\"\"\nsrc=\"https://www.facebook.com/tr?id=YOUR_PIXEL_ID&ev=PageView&noscript=1\"/></noscript>",
            "requires": [],
            "notes": "YOUR_PIXEL_ID is a placeholder; find the ID in Meta Events Manager."
        },
        {
            "id": "microsoft-clarity",
            "version": 1,
            "title": "Microsoft Clarity",
            "description": "Adds the Microsoft Clarity script for heatmaps and session recordings.",
            "category": "analytics",
            "tags": [
                "analytics",
                "clarity",
                "heatmaps",
                "microsoft"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Microsoft Clarity. Replace YOUR_PROJECT_ID with your Clarity project ID. -->\n<script type=\"text/javascript\">\n(function(c,l,a,r,i,t,y){\n    c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};\n    t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;\n    y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y);\n})(window, document, \"clarity\", \"script\", \"YOUR_PROJECT_ID\");\n</script>",
            "requires": [],
            "notes": "YOUR_PROJECT_ID is a placeholder from clarity.microsoft.com > Settings > Setup."
        },
        {
            "id": "plausible-analytics",
            "version": 1,
            "title": "Plausible Analytics",
            "description": "Adds the lightweight, cookie-free Plausible Analytics script.",
            "category": "analytics",
            "tags": [
                "analytics",
                "plausible",
                "privacy"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Plausible Analytics. Replace example.com with the domain you added in Plausible. -->\n<script defer data-domain=\"example.com\" src=\"https://plausible.io/js/script.js\"></script>",
            "requires": [],
            "notes": "If you self-host Plausible, replace plausible.io with your own host."
        },
        {
            "id": "fathom-analytics",
            "version": 1,
            "title": "Fathom Analytics",
            "description": "Adds the privacy-focused Fathom Analytics tracking script.",
            "category": "analytics",
            "tags": [
                "analytics",
                "fathom",
                "privacy"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<!-- Fathom Analytics. Replace ABCDEFGH with your site ID. -->\n<script src=\"https://cdn.usefathom.com/script.js\" data-site=\"ABCDEFGH\" defer></script>",
            "requires": [],
            "notes": "ABCDEFGH is a placeholder for the site ID shown in your Fathom dashboard."
        },
        {
            "id": "linkedin-insight-tag",
            "version": 1,
            "title": "LinkedIn Insight Tag",
            "description": "Adds the LinkedIn Insight Tag for conversion tracking and retargeting with LinkedIn ads.",
            "category": "analytics",
            "tags": [
                "analytics",
                "linkedin",
                "ads",
                "tracking"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<!-- LinkedIn Insight Tag. Replace YOUR_PARTNER_ID (two places) with your partner ID. -->\n<script type=\"text/javascript\">\n_linkedin_partner_id = \"YOUR_PARTNER_ID\";\nwindow._linkedin_data_partner_ids = window._linkedin_data_partner_ids || [];\nwindow._linkedin_data_partner_ids.push(_linkedin_partner_id);\n</script>\n<script type=\"text/javascript\">\n(function(l) {\nif (!l){window.lintrk = function(a,b){window.lintrk.q.push([a,b])};\nwindow.lintrk.q=[]}\nvar s = document.getElementsByTagName(\"script\")[0];\nvar b = document.createElement(\"script\");\nb.type = \"text/javascript\";b.async = true;\nb.src = \"https://snap.licdn.com/li.lms-analytics/insight.min.js\";\ns.parentNode.insertBefore(b, s);})(window.lintrk);\n</script>\n<noscript><img height=\"1\" width=\"1\" style=\"display:none;\" alt=\"\" src=\"https://px.ads.linkedin.com/collect/?pid=YOUR_PARTNER_ID&fmt=gif\" /></noscript>",
            "requires": [],
            "notes": "YOUR_PARTNER_ID is a placeholder from LinkedIn Campaign Manager > Insight Tag."
        },
        {
            "id": "google-site-verification",
            "version": 1,
            "title": "Google Search Console Verification Tag",
            "description": "Adds the HTML meta tag Google Search Console uses to verify that you own the site.",
            "category": "analytics",
            "tags": [
                "analytics",
                "search-console",
                "verification",
                "seo"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<meta name=\"google-site-verification\" content=\"YOUR_VERIFICATION_CODE\" />",
            "requires": [],
            "notes": "Replace YOUR_VERIFICATION_CODE with the content value Search Console gives you under the HTML tag method."
        },
        {
            "id": "smooth-scrolling",
            "version": 1,
            "title": "Smooth Scrolling for Anchor Links",
            "description": "Makes in-page anchor links scroll smoothly instead of jumping, while respecting visitors who prefer reduced motion.",
            "category": "frontend",
            "tags": [
                "css",
                "scrolling",
                "ux"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "html {\n    scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n    html {\n        scroll-behavior: auto;\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "back-to-top-button",
            "version": 1,
            "title": "Back to Top Button",
            "description": "Adds a small round button in the bottom corner that appears after scrolling down and takes visitors back to the top of the page.",
            "category": "frontend",
            "tags": [
                "html",
                "javascript",
                "ux",
                "navigation"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<button type=\"button\" id=\"wpide-back-to-top\" aria-label=\"Back to top\" hidden>&#8593;</button>\n<style>\n#wpide-back-to-top{position:fixed;right:20px;bottom:20px;z-index:9999;width:44px;height:44px;border:0;border-radius:50%;background:#1e1e1e;color:#fff;font-size:20px;line-height:44px;cursor:pointer;opacity:.8;box-shadow:0 2px 8px rgba(0,0,0,.2);}\n#wpide-back-to-top:hover,#wpide-back-to-top:focus{opacity:1;}\n</style>\n<script>\n(function () {\n    var button = document.getElementById('wpide-back-to-top');\n    if (!button) { return; }\n    var toggle = function () { button.hidden = window.pageYOffset < 400; };\n    window.addEventListener('scroll', toggle, { passive: true });\n    button.addEventListener('click', function () {\n        var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n        window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' });\n    });\n    toggle();\n})();\n</script>",
            "requires": [],
            "notes": "Change the colors in the style block to match your theme."
        },
        {
            "id": "hide-recaptcha-badge",
            "version": 1,
            "title": "Hide the reCAPTCHA Badge",
            "description": "Hides the floating Google reCAPTCHA v3 badge in the bottom corner of every page.",
            "category": "frontend",
            "tags": [
                "css",
                "recaptcha",
                "forms"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".grecaptcha-badge {\n    visibility: hidden !important;\n}",
            "requires": [],
            "notes": "Google requires you to show the reCAPTCHA notice text near your forms when the badge is hidden."
        },
        {
            "id": "reading-progress-bar",
            "version": 1,
            "title": "Reading Progress Bar",
            "description": "Shows a thin bar at the top of the window that fills up as the visitor scrolls through the page.",
            "category": "frontend",
            "tags": [
                "html",
                "javascript",
                "ux",
                "reading"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div id=\"wpide-progress\" aria-hidden=\"true\"></div>\n<style>\n#wpide-progress{position:fixed;top:0;left:0;height:3px;width:0;background:#2271b1;z-index:100000;transition:width .1s linear;}\n.admin-bar #wpide-progress{top:32px;}\n@media screen and (max-width:782px){.admin-bar #wpide-progress{top:46px;}}\n</style>\n<script>\n(function () {\n    var bar = document.getElementById('wpide-progress');\n    if (!bar) { return; }\n    var update = function () {\n        var doc = document.documentElement;\n        var max = doc.scrollHeight - doc.clientHeight;\n        bar.style.width = (max > 0 ? (doc.scrollTop / max) * 100 : 0) + '%';\n    };\n    window.addEventListener('scroll', update, { passive: true });\n    window.addEventListener('resize', update);\n    update();\n})();\n</script>",
            "requires": [],
            "notes": "Change #2271b1 to your brand color. Add conditional logic to show it only on posts."
        },
        {
            "id": "copyright-footer-line",
            "version": 1,
            "title": "Copyright Line in the Footer",
            "description": "Prints a centered \"© year Site Name. All rights reserved.\" line at the bottom of every page, with the year updated automatically.",
            "category": "frontend",
            "tags": [
                "footer",
                "copyright",
                "universal"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div class=\"wpide-copyright\" style=\"text-align:center;padding:1em 0;font-size:14px;\">\n    &copy; <?php echo esc_html( wp_date( 'Y' ) ); ?> <?php echo esc_html( get_bloginfo( 'name' ) ); ?>. All rights reserved.\n</div>",
            "requires": [],
            "notes": ""
        },
        {
            "id": "dismissible-announcement-bar",
            "version": 1,
            "title": "Dismissible Announcement Bar",
            "description": "Shows a slim message bar at the top of the page (for sales, shipping notes or news) that visitors can close. It stays closed on later visits.",
            "category": "frontend",
            "tags": [
                "html",
                "javascript",
                "announcement",
                "notice"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_body",
            "insert_number": 1,
            "code": "<div id=\"wpide-announcement\" style=\"display:none;position:relative;padding:10px 48px 10px 16px;background:#1e1e1e;color:#fff;text-align:center;font-size:14px;\">\n    Free shipping on all orders this week. <a href=\"/shop/\" style=\"color:#fff;text-decoration:underline;\">Shop now</a>\n    <button type=\"button\" aria-label=\"Dismiss announcement\" style=\"position:absolute;right:8px;top:50%;transform:translateY(-50%);background:none;border:0;color:#fff;font-size:20px;line-height:1;cursor:pointer;\">&times;</button>\n</div>\n<script>\n(function () {\n    var key = 'wpide-announcement-1';\n    var bar = document.getElementById('wpide-announcement');\n    if (!bar) { return; }\n    try { if (localStorage.getItem(key)) { return; } } catch (e) {}\n    bar.style.display = 'block';\n    bar.querySelector('button').addEventListener('click', function () {\n        bar.style.display = 'none';\n        try { localStorage.setItem(key, '1'); } catch (e) {}\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Edit the message and link. Change the key (wpide-announcement-1) whenever you publish a new announcement so it shows again to everyone."
        },
        {
            "id": "print-friendly-styles",
            "version": 1,
            "title": "Print-Friendly Styles",
            "description": "Hides navigation, sidebars, comments and the admin bar when a page is printed, and prints link addresses after external links.",
            "category": "frontend",
            "tags": [
                "css",
                "print",
                "accessibility"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media print {\n    #wpadminbar,\n    .site-header nav,\n    .main-navigation,\n    .widget-area,\n    .sidebar,\n    .comments-area,\n    .site-footer nav,\n    .wp-block-navigation {\n        display: none !important;\n    }\n\n    body {\n        color: #000;\n        background: #fff;\n    }\n\n    .entry-content a[href^=\"http\"]::after {\n        content: \" (\" attr(href) \")\";\n        font-size: 90%;\n    }\n}",
            "requires": [],
            "notes": "Adjust the selectors to match the class names used by your theme."
        },
        {
            "id": "slug-column-admin-lists",
            "version": 1,
            "title": "Slug Column in Admin Lists",
            "description": "Adds a Slug column to the posts and pages lists so you can check permalinks without opening each item.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the Slug column.\n$wpide_slug_post_types = array( 'post', 'page' );\n\nforeach ( $wpide_slug_post_types as $wpide_slug_type ) {\n    add_filter( \"manage_{$wpide_slug_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_slug'] = 'Slug';\n\n        return $columns;\n    } );\n\n    add_action( \"manage_{$wpide_slug_type}_posts_custom_column\", function ( $column, $post_id ) {\n        if ( 'wpide_slug' === $column ) {\n            echo '<code>' . esc_html( urldecode( (string) get_post_field( 'post_name', $post_id ) ) ) . '</code>';\n        }\n    }, 10, 2 );\n}",
            "requires": [],
            "notes": "Add custom post type names to $wpide_slug_post_types to show the column there too."
        },
        {
            "id": "page-template-column",
            "version": 1,
            "title": "Page Template Column for Pages",
            "description": "Shows which page template each page uses, right in the Pages list.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_page_posts_columns', function ( $columns ) {\n    $columns['wpide_template'] = 'Template';\n\n    return $columns;\n} );\n\nadd_action( 'manage_page_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_template' !== $column ) {\n        return;\n    }\n\n    $slug = get_page_template_slug( $post_id );\n\n    if ( ! $slug ) {\n        echo '<span aria-hidden=\"true\">&#8212;</span> Default';\n        return;\n    }\n\n    $templates = wp_get_theme()->get_page_templates( null, 'page' );\n\n    echo esc_html( isset( $templates[ $slug ] ) ? $templates[ $slug ] : $slug );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "filter-posts-by-author",
            "version": 1,
            "title": "Filter Posts by Author in the Admin List",
            "description": "Adds an author dropdown next to the date and category filters, so editors can list one writer's posts.",
            "category": "admin",
            "tags": [
                "admin",
                "filters",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'restrict_manage_posts', function ( $post_type ) {\n    if ( ! post_type_supports( $post_type, 'author' ) || ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_dropdown_users( array(\n        'name'                    => 'author',\n        'show_option_all'         => 'All authors',\n        'selected'                => isset( $_GET['author'] ) ? absint( $_GET['author'] ) : 0,\n        'capability'              => array( 'edit_posts' ),\n        'hide_if_only_one_author' => true,\n    ) );\n} );",
            "requires": [],
            "notes": "WordPress already understands the author filter; this snippet only adds the dropdown."
        },
        {
            "id": "filter-admin-lists-by-taxonomy",
            "version": 1,
            "title": "Filter Admin Lists by Any Taxonomy",
            "description": "Adds a dropdown filter for every taxonomy attached to a post type, such as tags or custom taxonomies on a portfolio.",
            "category": "admin",
            "tags": [
                "admin",
                "filters",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Taxonomies that should not get a dropdown (WordPress already shows one for post categories).\n$wpide_tax_filter_skip = array( 'category', 'post_format' );\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_tax_filter_skip ) {\n    foreach ( get_object_taxonomies( $post_type, 'objects' ) as $taxonomy ) {\n        if ( ! $taxonomy->show_ui || ! $taxonomy->query_var || in_array( $taxonomy->name, $wpide_tax_filter_skip, true ) ) {\n            continue;\n        }\n\n        $selected = isset( $_GET[ $taxonomy->query_var ] ) ? sanitize_title( wp_unslash( $_GET[ $taxonomy->query_var ] ) ) : '';\n\n        wp_dropdown_categories( array(\n            'taxonomy'        => $taxonomy->name,\n            'name'            => $taxonomy->query_var,\n            'value_field'     => 'slug',\n            'selected'        => $selected,\n            'show_option_all' => $taxonomy->labels->all_items,\n            'hierarchical'    => $taxonomy->hierarchical,\n            'hide_empty'      => false,\n            'hide_if_empty'   => true,\n            'show_count'      => true,\n        ) );\n    }\n} );\n\n// The \"All\" option submits 0; drop it so the list is not filtered by a term called \"0\".\nadd_filter( 'request', function ( $vars ) {\n    if ( ! is_admin() ) {\n        return $vars;\n    }\n\n    foreach ( get_taxonomies( array( 'show_ui' => true ), 'objects' ) as $taxonomy ) {\n        if ( $taxonomy->query_var && isset( $vars[ $taxonomy->query_var ] ) && '0' === (string) $vars[ $taxonomy->query_var ] ) {\n            unset( $vars[ $taxonomy->query_var ] );\n        }\n    }\n\n    return $vars;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "menu-order-column",
            "version": 1,
            "title": "Sortable Order Column for Pages",
            "description": "Shows the page order (menu_order) value in the Pages list and lets you sort by it.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_page_posts_columns', function ( $columns ) {\n    $columns['wpide_menu_order'] = 'Order';\n\n    return $columns;\n} );\n\nadd_action( 'manage_page_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_menu_order' === $column ) {\n        echo (int) get_post_field( 'menu_order', $post_id );\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_edit-page_sortable_columns', function ( $columns ) {\n    $columns['wpide_menu_order'] = 'menu_order';\n\n    return $columns;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "admin-search-by-post-id",
            "version": 1,
            "title": "Find Posts by ID in the Admin Search",
            "description": "Type #123 or id:123 in the posts or pages search box to jump straight to the item with that ID.",
            "category": "admin",
            "tags": [
                "admin",
                "search",
                "post-id"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    global $pagenow;\n\n    if ( ! is_admin() || ! $query->is_main_query() || 'edit.php' !== $pagenow ) {\n        return;\n    }\n\n    $search = trim( (string) $query->get( 's' ) );\n\n    if ( preg_match( '/^(?:#|id:\\s*)(\\d+)$/i', $search, $match ) ) {\n        $query->set( 's', '' );\n        $query->set( 'p', (int) $match[1] );\n    }\n} );",
            "requires": [],
            "notes": "Plain numbers are still searched as text, so titles like \"2025\" keep working."
        },
        {
            "id": "admin-list-items-per-page",
            "version": 1,
            "title": "Default Number of Items in Admin Lists",
            "description": "Shows more (or fewer) posts per page in admin lists for everyone who has not chosen their own number in Screen Options.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Items per page when a user has not set their own value in Screen Options.\n$wpide_admin_per_page = 50;\n\nadd_filter( 'edit_posts_per_page', function ( $per_page, $post_type ) use ( $wpide_admin_per_page ) {\n    $own = get_user_option( \"edit_{$post_type}_per_page\" );\n\n    return empty( $own ) ? $wpide_admin_per_page : $per_page;\n}, 10, 2 );",
            "requires": [],
            "notes": "Very high values make list screens slower on large sites."
        },
        {
            "id": "admin-sort-by-modified-default",
            "version": 1,
            "title": "Sort Admin Lists by Last Modified by Default",
            "description": "Lists recently edited posts first in the admin, unless you click a column to sort differently.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types whose admin list should default to \"last modified first\".\n$wpide_sort_modified_types = array( 'post' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_sort_modified_types ) {\n    global $pagenow;\n\n    if ( ! is_admin() || ! $query->is_main_query() || 'edit.php' !== $pagenow || isset( $_GET['orderby'] ) ) {\n        return;\n    }\n\n    $post_type = $query->get( 'post_type' ) ? $query->get( 'post_type' ) : 'post';\n\n    if ( in_array( $post_type, $wpide_sort_modified_types, true ) ) {\n        $query->set( 'orderby', 'modified' );\n        $query->set( 'order', 'DESC' );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "site-wide-admin-notice",
            "version": 1,
            "title": "Site-Wide Admin Notice",
            "description": "Shows your own message at the top of every admin screen, for example \"This is the staging site\" or a reminder for editors.",
            "category": "admin",
            "tags": [
                "admin",
                "notices",
                "message"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_admin_notice = array(\n    'message'    => '<strong>Heads up:</strong> content freeze until Friday. Please save changes as drafts.',\n    'type'       => 'warning',    // info, success, warning or error.\n    'capability' => 'edit_posts', // Who sees it.\n);\n\nadd_action( 'admin_notices', function () use ( $wpide_admin_notice ) {\n    if ( ! current_user_can( $wpide_admin_notice['capability'] ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-%1$s\"><p>%2$s</p></div>',\n        esc_attr( $wpide_admin_notice['type'] ),\n        wp_kses_post( $wpide_admin_notice['message'] )\n    );\n} );",
            "requires": [],
            "notes": "Deactivate the snippet to remove the notice; it cannot be dismissed by users."
        },
        {
            "id": "reorder-admin-menu",
            "version": 1,
            "title": "Reorder the Admin Menu",
            "description": "Puts the admin menu items you use most at the top, in the order you choose. Everything else keeps its place below.",
            "category": "admin",
            "tags": [
                "admin",
                "menu",
                "order"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Menu slugs in the order you want them at the top of the menu.\n$wpide_menu_order = array(\n    'index.php',\n    'separator1',\n    'edit.php?post_type=page',\n    'edit.php',\n    'upload.php',\n);\n\nadd_filter( 'custom_menu_order', '__return_true' );\n\nadd_filter( 'menu_order', function ( $order ) use ( $wpide_menu_order ) {\n    if ( ! is_array( $order ) ) {\n        return $order;\n    }\n\n    $first = array_values( array_intersect( $wpide_menu_order, $order ) );\n\n    return array_merge( $first, array_values( array_diff( $order, $first ) ) );\n} );",
            "requires": [],
            "notes": "Find a slug by hovering a menu item: it is the part of the link after /wp-admin/."
        },
        {
            "id": "rename-posts-to-articles",
            "version": 1,
            "title": "Rename \"Posts\" to \"Articles\"",
            "description": "Changes every label of the built-in Posts type in the admin, such as the menu, buttons and messages, to a name that fits your site.",
            "category": "admin",
            "tags": [
                "admin",
                "labels",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// The new names for posts.\n$wpide_post_singular = 'Article';\n$wpide_post_plural   = 'Articles';\n\nadd_filter( 'post_type_labels_post', function ( $labels ) use ( $wpide_post_singular, $wpide_post_plural ) {\n    $one  = $wpide_post_singular;\n    $many = $wpide_post_plural;\n    $low  = strtolower( $many );\n\n    $new = array(\n        'name'                  => $many,\n        'singular_name'         => $one,\n        'add_new_item'          => \"Add New $one\",\n        'edit_item'             => \"Edit $one\",\n        'new_item'              => \"New $one\",\n        'view_item'             => \"View $one\",\n        'view_items'            => \"View $many\",\n        'search_items'          => \"Search $many\",\n        'not_found'             => \"No $low found.\",\n        'not_found_in_trash'    => \"No $low found in Trash.\",\n        'all_items'             => \"All $many\",\n        'archives'              => \"$one Archives\",\n        'attributes'            => \"$one Attributes\",\n        'insert_into_item'      => \"Insert into $one\",\n        'uploaded_to_this_item' => \"Uploaded to this $one\",\n        'filter_items_list'     => \"Filter $low list\",\n        'items_list'            => \"$many list\",\n        'item_published'        => \"$one published.\",\n        'item_updated'          => \"$one updated.\",\n        'item_scheduled'        => \"$one scheduled.\",\n        'item_reverted_to_draft' => \"$one reverted to draft.\",\n        'menu_name'             => $many,\n        'name_admin_bar'        => $one,\n    );\n\n    foreach ( $new as $key => $value ) {\n        $labels->$key = $value;\n    }\n\n    return $labels;\n} );",
            "requires": [],
            "notes": "Only labels change; URLs, the post type name and existing content stay the same."
        },
        {
            "id": "visit-site-new-tab",
            "version": 1,
            "title": "Open \"Visit Site\" in a New Tab",
            "description": "Makes the site name and \"Visit Site\" links in the admin bar open the front end in a new tab, so you keep your place in the admin.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    if ( ! is_admin() ) {\n        return;\n    }\n\n    foreach ( array( 'site-name', 'view-site' ) as $node_id ) {\n        $node = $wp_admin_bar->get_node( $node_id );\n\n        if ( ! $node ) {\n            continue;\n        }\n\n        $meta           = (array) $node->meta;\n        $meta['target'] = '_blank';\n        $meta['rel']    = 'noopener';\n\n        $wp_admin_bar->add_node( array( 'id' => $node_id, 'meta' => $meta ) );\n    }\n}, 999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "admin-bar-quick-links",
            "version": 1,
            "title": "Quick Links Menu in the Admin Bar",
            "description": "Adds a \"Quick links\" dropdown to the admin bar with the outside tools your team opens every day, such as analytics or support.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label => URL. Links open in a new tab.\n$wpide_quick_links = array(\n    'Google Analytics' => 'https://analytics.google.com/',\n    'Search Console'   => 'https://search.google.com/search-console',\n    'Email support'    => 'mailto:support@example.com',\n);\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_quick_links ) {\n    if ( ! current_user_can( 'edit_posts' ) || empty( $wpide_quick_links ) ) {\n        return;\n    }\n\n    $wp_admin_bar->add_node( array(\n        'id'    => 'wpide-quick-links',\n        'title' => 'Quick links',\n    ) );\n\n    $i = 0;\n\n    foreach ( $wpide_quick_links as $label => $url ) {\n        $wp_admin_bar->add_node( array(\n            'parent' => 'wpide-quick-links',\n            'id'     => 'wpide-quick-link-' . ( ++$i ),\n            'title'  => esc_html( $label ),\n            'href'   => esc_url( $url ),\n            'meta'   => array( 'target' => '_blank', 'rel' => 'noopener noreferrer' ),\n        ) );\n    }\n}, 100 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-admin-bar-items",
            "version": 1,
            "title": "Remove Items From the Admin Bar",
            "description": "Removes admin bar entries you never use, such as Comments, \"+ New\", Customize or Search.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Node IDs to remove. Common ones: comments, new-content, customize, search, updates, edit, site-editor.\n$wpide_admin_bar_remove = array( 'comments', 'new-content', 'customize', 'search' );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_admin_bar_remove ) {\n    foreach ( $wpide_admin_bar_remove as $node_id ) {\n        $wp_admin_bar->remove_node( $node_id );\n    }\n}, 999 );",
            "requires": [],
            "notes": "To find a node ID, inspect the admin bar item: its list element id is \"wp-admin-bar-\" followed by the node ID."
        },
        {
            "id": "admin-bar-at-bottom",
            "version": 1,
            "title": "Move the Admin Bar to the Bottom on the Front End",
            "description": "Pins the admin bar to the bottom of the screen on the front end, so it no longer covers sticky headers and menus while you browse your site.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "frontend"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_admin_bar_showing() ) {\n        return;\n    }\n    ?>\n    <style id=\"wpide-admin-bar-bottom\">\n        html { margin-top: 0 !important; }\n        body { margin-bottom: 32px; }\n        #wpadminbar { top: auto !important; bottom: 0; position: fixed; }\n        #wpadminbar .menupop .ab-sub-wrapper { bottom: 32px; }\n        @media screen and (max-width: 782px) {\n            body { margin-bottom: 46px; }\n            #wpadminbar .menupop .ab-sub-wrapper { bottom: 46px; }\n        }\n    </style>\n    <?php\n}, 100 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "auto-hide-admin-bar",
            "version": 1,
            "title": "Auto-Hide the Admin Bar on the Front End",
            "description": "Slides the admin bar out of view on the front end and brings it back when you move the mouse to the top edge of the screen.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "frontend"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_admin_bar_showing() ) {\n        return;\n    }\n    ?>\n    <style id=\"wpide-admin-bar-autohide\">\n        @media (hover: hover) and (min-width: 783px) {\n            html { margin-top: 0 !important; }\n            #wpadminbar { transform: translateY(calc(-100% + 5px)); transition: transform .2s ease .3s; opacity: .95; }\n            #wpadminbar:hover, #wpadminbar:focus-within { transform: none; transition-delay: 0s; }\n        }\n    </style>\n    <?php\n}, 100 );",
            "requires": [],
            "notes": "Touch screens and narrow windows keep the normal admin bar, because there is no hover there."
        },
        {
            "id": "collapse-admin-menu-by-default",
            "version": 1,
            "title": "Collapse the Admin Menu by Default",
            "description": "Starts everyone with the narrow, icon-only admin menu for more working space. Users who expand it keep their choice.",
            "category": "admin",
            "tags": [
                "admin",
                "menu",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'admin_body_class', function ( $classes ) {\n    // \"mfold\" is the saved collapse setting; it is empty until the user toggles the menu.\n    if ( '' === (string) get_user_setting( 'mfold', '' ) && false === strpos( $classes, 'folded' ) ) {\n        $classes .= ' folded';\n    }\n\n    return $classes;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "role-classes-admin-body",
            "version": 1,
            "title": "Add User Role Classes to the Admin Body",
            "description": "Adds classes such as role-editor to the admin body tag, so you can write CSS that hides or highlights things for specific roles.",
            "category": "admin",
            "tags": [
                "admin",
                "css",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'admin_body_class', function ( $classes ) {\n    $user = wp_get_current_user();\n\n    foreach ( (array) $user->roles as $role ) {\n        $classes .= ' role-' . sanitize_html_class( $role );\n    }\n\n    return $classes;\n} );",
            "requires": [],
            "notes": "Example CSS: body.role-editor #menu-tools { display: none; }"
        },
        {
            "id": "row-action-view-new-tab",
            "version": 1,
            "title": "Open \"View\" and \"Preview\" Row Links in a New Tab",
            "description": "Makes the View and Preview links under each post and page in admin lists open in a new browser tab.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_row_links_new_tab = function ( $actions ) {\n    foreach ( array( 'view', 'preview' ) as $key ) {\n        if ( isset( $actions[ $key ] ) && false === strpos( $actions[ $key ], 'target=' ) ) {\n            $actions[ $key ] = preg_replace( '/<a /', '<a target=\"_blank\" rel=\"noopener\" ', $actions[ $key ], 1 );\n        }\n    }\n\n    return $actions;\n};\n\nadd_filter( 'post_row_actions', $wpide_row_links_new_tab );\nadd_filter( 'page_row_actions', $wpide_row_links_new_tab );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-quick-edit-non-admins",
            "version": 1,
            "title": "Remove Quick Edit for Non-Administrators",
            "description": "Hides the Quick Edit link and the Edit bulk action for everyone except administrators, so status, dates and slugs are only changed in the full editor.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_remove_quick_edit = function ( $actions ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        unset( $actions['inline hide-if-no-js'] );\n    }\n\n    return $actions;\n};\n\nadd_filter( 'post_row_actions', $wpide_remove_quick_edit, 20 );\nadd_filter( 'page_row_actions', $wpide_remove_quick_edit, 20 );\n\nadd_action( 'admin_init', function () {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        add_filter( \"bulk_actions-edit-{$post_type}\", function ( $actions ) {\n            unset( $actions['edit'] );\n\n            return $actions;\n        } );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "require-featured-image",
            "version": 1,
            "title": "Require a Featured Image Before Publishing",
            "description": "Stops posts from being published or scheduled until a featured image is set, in both the block editor and the classic editor.",
            "category": "admin",
            "tags": [
                "admin",
                "editor",
                "featured-image"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that must have a featured image before they go live.\n$wpide_require_image_types = array( 'post' );\n\nforeach ( $wpide_require_image_types as $wpide_image_type ) {\n    // Block editor (REST API) saves.\n    add_filter( \"rest_pre_insert_{$wpide_image_type}\", function ( $prepared, $request ) {\n        if ( false !== strpos( (string) $request->get_route(), '/autosaves' ) ) {\n            return $prepared;\n        }\n\n        $status = isset( $prepared->post_status ) ? $prepared->post_status : get_post_status( (int) $request['id'] );\n\n        if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n            return $prepared;\n        }\n\n        if ( isset( $request['featured_media'] ) ) {\n            $image = (int) $request['featured_media'];\n        } else {\n            $image = ! empty( $request['id'] ) ? (int) get_post_thumbnail_id( (int) $request['id'] ) : 0;\n        }\n\n        if ( $image > 0 ) {\n            return $prepared;\n        }\n\n        return new WP_Error( 'wpide_featured_image_required', 'Please set a featured image before publishing.', array( 'status' => 400 ) );\n    }, 10, 2 );\n}\n\n// Classic editor saves: keep the post as a draft and explain why.\nadd_filter( 'wp_insert_post_data', function ( $data, $postarr ) use ( $wpide_require_image_types ) {\n    if ( ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || empty( $postarr['ID'] ) || ! isset( $_POST['action'] ) || 'editpost' !== $_POST['action'] ) {\n        return $data;\n    }\n\n    if ( ! in_array( $data['post_type'], $wpide_require_image_types, true ) || ! in_array( $data['post_status'], array( 'publish', 'future' ), true ) ) {\n        return $data;\n    }\n\n    $image = isset( $_POST['_thumbnail_id'] ) ? (int) $_POST['_thumbnail_id'] : (int) get_post_thumbnail_id( (int) $postarr['ID'] );\n\n    if ( $image > 0 ) {\n        return $data;\n    }\n\n    $data['post_status'] = 'draft';\n\n    add_filter( 'redirect_post_location', function ( $location ) {\n        return add_query_arg( 'wpide_needs_image', 1, remove_query_arg( 'message', $location ) );\n    } );\n\n    return $data;\n}, 10, 2 );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_needs_image'] ) ) {\n        echo '<div class=\"notice notice-error\"><p>The post was saved as a draft. Please set a featured image before publishing.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Already published posts without an image also need one before they can be updated."
        },
        {
            "id": "hide-plugins-from-list",
            "version": 1,
            "title": "Hide Plugins From the Plugins Screen",
            "description": "Hides chosen plugins from the Plugins screen for everyone except the users you name, so clients do not deactivate them by accident.",
            "category": "admin",
            "tags": [
                "admin",
                "plugins",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Plugin files to hide (folder/main-file.php, as shown in the plugin's edit link).\n$wpide_hidden_plugins = array( 'hello.php' );\n\n// Usernames that still see every plugin.\n$wpide_plugins_visible_to = array( 'admin' );\n\nadd_filter( 'all_plugins', function ( $plugins ) use ( $wpide_hidden_plugins, $wpide_plugins_visible_to ) {\n    $user = wp_get_current_user();\n\n    if ( in_array( $user->user_login, $wpide_plugins_visible_to, true ) ) {\n        return $plugins;\n    }\n\n    foreach ( $wpide_hidden_plugins as $file ) {\n        unset( $plugins[ $file ] );\n    }\n\n    return $plugins;\n} );",
            "requires": [],
            "notes": "Hidden plugins keep running, so pair this with blocking plugin installs on client sites."
        },
        {
            "id": "separate-admin-favicon",
            "version": 1,
            "title": "Separate Favicon for the Admin Area",
            "description": "Uses a different browser tab icon in wp-admin than on the public site, so admin tabs are easy to spot among the others.",
            "category": "admin",
            "tags": [
                "admin",
                "favicon",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Full URL of the icon (PNG, SVG or ICO). Upload it to the Media Library and paste its URL.\n$wpide_admin_favicon = 'https://example.com/wp-content/uploads/admin-icon.png';\n\nadd_action( 'admin_init', function () {\n    remove_action( 'admin_head', 'wp_site_icon' );\n} );\n\nadd_action( 'admin_head', function () use ( $wpide_admin_favicon ) {\n    if ( $wpide_admin_favicon ) {\n        echo '<link rel=\"icon\" href=\"' . esc_url( $wpide_admin_favicon ) . '\">' . \"\\n\";\n    }\n} );",
            "requires": [],
            "notes": "A different icon per environment (for example red on staging) helps prevent editing the wrong site."
        },
        {
            "id": "remove-wordpress-from-admin-titles",
            "version": 1,
            "title": "Remove \"WordPress\" From Admin Page Titles",
            "description": "Shortens browser tab titles in the admin and on the login page by dropping the trailing \"— WordPress\".",
            "category": "admin",
            "tags": [
                "admin",
                "branding",
                "title"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_strip_wp_title = function ( $title ) {\n    return preg_replace( '/\\s*(&#8212;|—|&mdash;)\\s*WordPress\\s*$/u', '', (string) $title );\n};\n\nadd_filter( 'admin_title', $wpide_strip_wp_title, 99 );\nadd_filter( 'login_title', $wpide_strip_wp_title, 99 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "drafts-submenu-links",
            "version": 1,
            "title": "Drafts Link Under Every Post Type Menu",
            "description": "Adds a \"Drafts\" submenu under Posts, Pages and each custom post type for one-click access to unfinished content.",
            "category": "admin",
            "tags": [
                "admin",
                "menu",
                "drafts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    foreach ( get_post_types( array( 'show_ui' => true, 'show_in_menu' => true ), 'objects' ) as $type ) {\n        if ( 'attachment' === $type->name || true !== $type->show_in_menu ) {\n            continue;\n        }\n\n        $parent = 'post' === $type->name ? 'edit.php' : 'edit.php?post_type=' . $type->name;\n\n        add_submenu_page(\n            $parent,\n            'Drafts',\n            'Drafts',\n            $type->cap->edit_posts,\n            'edit.php?post_type=' . $type->name . '&post_status=draft'\n        );\n    }\n}, 20 );",
            "requires": [],
            "notes": "Post types that sit inside another plugin menu are skipped."
        },
        {
            "id": "pending-count-in-admin-menu",
            "version": 1,
            "title": "Pending Review Count in the Admin Menu",
            "description": "Shows a red count bubble on Posts, Pages and custom post types when items are waiting for review, like WordPress does for comments.",
            "category": "admin",
            "tags": [
                "admin",
                "menu",
                "workflow"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    global $menu;\n\n    if ( ! current_user_can( 'edit_others_posts' ) || ! is_array( $menu ) ) {\n        return;\n    }\n\n    foreach ( $menu as $index => $item ) {\n        if ( empty( $item[2] ) ) {\n            continue;\n        }\n\n        if ( 'edit.php' === $item[2] ) {\n            $post_type = 'post';\n        } elseif ( 0 === strpos( $item[2], 'edit.php?post_type=' ) ) {\n            $post_type = substr( $item[2], strlen( 'edit.php?post_type=' ) );\n        } else {\n            continue;\n        }\n\n        if ( ! post_type_exists( $post_type ) ) {\n            continue;\n        }\n\n        $pending = (int) wp_count_posts( $post_type )->pending;\n\n        if ( $pending > 0 ) {\n            $menu[ $index ][0] .= sprintf( ' <span class=\"awaiting-mod count-%1$d\"><span class=\"pending-count\">%1$d</span></span>', $pending );\n        }\n    }\n}, 999 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-editor-panels-non-admins",
            "version": 1,
            "title": "Hide Editor Panels From Non-Administrators",
            "description": "Removes rarely needed panels like Discussion, Custom Fields and the slug box from the editor for everyone but administrators, in both editors.",
            "category": "admin",
            "tags": [
                "admin",
                "editor",
                "meta-boxes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Block editor panel names => classic editor meta box IDs to hide.\n$wpide_hidden_panels = array(\n    'discussion-panel' => array( 'commentstatusdiv', 'commentsdiv', 'trackbacksdiv' ),\n    'post-link'        => array( 'slugdiv' ),\n    'page-attributes'  => array( 'pageparentdiv' ),\n);\n\n// Classic editor meta boxes without a block editor panel.\n$wpide_hidden_meta_boxes = array( 'postcustom' );\n\nadd_action( 'add_meta_boxes', function ( $post_type ) use ( $wpide_hidden_panels, $wpide_hidden_meta_boxes ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $boxes = $wpide_hidden_meta_boxes;\n\n    foreach ( $wpide_hidden_panels as $ids ) {\n        $boxes = array_merge( $boxes, $ids );\n    }\n\n    foreach ( $boxes as $box ) {\n        foreach ( array( 'normal', 'advanced', 'side' ) as $context ) {\n            remove_meta_box( $box, $post_type, $context );\n        }\n    }\n}, 99 );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_hidden_panels ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $script = sprintf(\n        'wp.domReady(function(){var s=wp.data.dispatch(\"core/editor\");if(!s||!s.removeEditorPanel){s=wp.data.dispatch(\"core/edit-post\");}%s.forEach(function(p){s.removeEditorPanel(p);});});',\n        wp_json_encode( array_keys( $wpide_hidden_panels ) )\n    );\n\n    wp_add_inline_script( 'wp-edit-post', $script );\n} );",
            "requires": [],
            "notes": "Edit the two lists to choose which panels stay hidden."
        },
        {
            "id": "enable-links-manager",
            "version": 1,
            "title": "Enable the Links Manager",
            "description": "Brings back the classic Links menu (blogroll) that WordPress hides on new sites, for managing a list of links.",
            "category": "admin",
            "tags": [
                "admin",
                "links",
                "blogroll"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_link_manager_enabled', '__return_true' );",
            "requires": [],
            "notes": "Use the Links widget or wp_list_bookmarks() in a classic theme to display them."
        },
        {
            "id": "all-settings-menu-link",
            "version": 1,
            "title": "All Settings Link Under Settings",
            "description": "Adds \"All Settings\" to the Settings menu, which opens the hidden screen listing every stored option for administrators.",
            "category": "admin",
            "tags": [
                "admin",
                "settings",
                "options"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_options_page( 'All Settings', 'All Settings', 'manage_options', 'options.php' );\n}, 99 );",
            "requires": [],
            "notes": "Saving that screen writes every option at once; change values there with care."
        },
        {
            "id": "sticky-admin-table-headers",
            "version": 1,
            "title": "Sticky Table Headers in Admin Lists",
            "description": "Keeps the column headings of posts, pages, users and other admin tables visible while you scroll long lists.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "@media screen and (min-width: 783px) {\n    .wp-list-table thead th,\n    .wp-list-table thead td {\n        position: sticky;\n        top: 32px;\n        z-index: 3;\n        background: #fff;\n        box-shadow: inset 0 -1px 0 #c3c4c7;\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "fuller-classic-editor-toolbar",
            "version": 1,
            "title": "Fuller Classic Editor Toolbar",
            "description": "Always shows the second toolbar row in the classic editor and adds font size, highlight, subscript and superscript buttons.",
            "category": "admin",
            "tags": [
                "admin",
                "classic-editor",
                "tinymce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Extra buttons for the second row.\n$wpide_extra_mce_buttons = array( 'fontsizeselect', 'backcolor', 'subscript', 'superscript' );\n\nadd_filter( 'tiny_mce_before_init', function ( $init ) {\n    $init['wordpress_adv_hidden'] = false;\n\n    return $init;\n} );\n\nadd_filter( 'mce_buttons_2', function ( $buttons ) use ( $wpide_extra_mce_buttons ) {\n    return array_values( array_unique( array_merge( $buttons, $wpide_extra_mce_buttons ) ) );\n} );",
            "requires": [],
            "notes": "Only affects the classic editor (and classic blocks)."
        },
        {
            "id": "authors-see-only-own-posts",
            "version": 1,
            "title": "Show Authors Only Their Own Posts in Admin Lists",
            "description": "Users who cannot edit other people's posts, like authors and contributors, see only their own items in the admin lists.",
            "category": "admin",
            "tags": [
                "admin",
                "authors",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    global $pagenow;\n\n    if ( ! is_admin() || ! $query->is_main_query() || 'edit.php' !== $pagenow ) {\n        return;\n    }\n\n    $post_type = $query->get( 'post_type' ) ? $query->get( 'post_type' ) : 'post';\n    $object    = get_post_type_object( is_array( $post_type ) ? reset( $post_type ) : $post_type );\n\n    if ( $object && ! current_user_can( $object->cap->edit_others_posts ) ) {\n        $query->set( 'author', get_current_user_id() );\n    }\n} );",
            "requires": [],
            "notes": "The counts above the list (All, Published) still include other people's posts."
        },
        {
            "id": "warn-search-engines-blocked",
            "version": 1,
            "title": "Warn When Search Engines Are Blocked",
            "description": "Shows a red notice on every admin screen while \"Discourage search engines\" is ticked, so a launched site is not left hidden by mistake.",
            "category": "admin",
            "tags": [
                "admin",
                "seo",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_notices', function () {\n    if ( '0' !== (string) get_option( 'blog_public' ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-error\"><p><strong>Search engines are blocked.</strong> This site asks search engines not to index it. <a href=\"%s\">Change this in Reading settings</a>.</p></div>',\n        esc_url( admin_url( 'options-reading.php' ) )\n    );\n} );",
            "requires": [],
            "notes": "Leave it off on staging sites, where blocking search engines is intended."
        },
        {
            "id": "warn-debug-mode-production",
            "version": 1,
            "title": "Warn When Debug Display Is On in Production",
            "description": "Alerts administrators when WP_DEBUG shows errors on screen on a production site, where visitors could see file paths and warnings.",
            "category": "admin",
            "tags": [
                "admin",
                "debug",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_notices', function () {\n    if ( ! current_user_can( 'manage_options' ) || 'production' !== wp_get_environment_type() ) {\n        return;\n    }\n\n    $displaying = defined( 'WP_DEBUG' ) && WP_DEBUG && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY );\n\n    if ( $displaying ) {\n        echo '<div class=\"notice notice-warning\"><p><strong>Debug output is visible.</strong> WP_DEBUG is on and errors are displayed on this production site. Set WP_DEBUG_DISPLAY to false in wp-config.php.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "The environment type comes from WP_ENVIRONMENT_TYPE and defaults to production."
        },
        {
            "id": "term-id-column",
            "version": 1,
            "title": "ID Column on Category and Tag Screens",
            "description": "Adds a sortable ID column to categories, tags and every other taxonomy screen, handy for shortcodes and filters that need term IDs.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    foreach ( get_taxonomies( array( 'show_ui' => true ) ) as $taxonomy ) {\n        add_filter( \"manage_edit-{$taxonomy}_columns\", function ( $columns ) {\n            $columns['wpide_term_id'] = 'ID';\n\n            return $columns;\n        } );\n\n        add_filter( \"manage_{$taxonomy}_custom_column\", function ( $content, $column, $term_id ) {\n            return 'wpide_term_id' === $column ? (string) (int) $term_id : $content;\n        }, 10, 3 );\n\n        add_filter( \"manage_edit-{$taxonomy}_sortable_columns\", function ( $columns ) {\n            $columns['wpide_term_id'] = 'term_id';\n\n            return $columns;\n        } );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "last-edited-by-column",
            "version": 1,
            "title": "Last Edited By Column",
            "description": "Shows who last edited each post or page, and when, in the admin lists. Useful when several people work on the same content.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the column.\n$wpide_last_editor_types = array( 'post', 'page' );\n\nforeach ( $wpide_last_editor_types as $wpide_editor_type ) {\n    add_filter( \"manage_{$wpide_editor_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_last_editor'] = 'Last edited by';\n\n        return $columns;\n    } );\n\n    add_action( \"manage_{$wpide_editor_type}_posts_custom_column\", function ( $column, $post_id ) {\n        if ( 'wpide_last_editor' !== $column ) {\n            return;\n        }\n\n        $editor_id = (int) get_post_meta( $post_id, '_edit_last', true );\n        $editor    = $editor_id ? get_userdata( $editor_id ) : false;\n\n        echo $editor ? esc_html( $editor->display_name ) : '&#8212;';\n        echo '<br><small>' . esc_html( get_post_modified_time( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), false, $post_id, true ) ) . '</small>';\n    }, 10, 2 );\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "bulk-move-to-draft",
            "version": 1,
            "title": "Bulk Action to Move Posts Back to Draft",
            "description": "Adds \"Move to draft\" to the Bulk actions menu for posts and pages, to unpublish several items at once.",
            "category": "admin",
            "tags": [
                "admin",
                "bulk-actions",
                "drafts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the bulk action.\n$wpide_to_draft_types = array( 'post', 'page' );\n\nforeach ( $wpide_to_draft_types as $wpide_draft_type ) {\n    add_filter( \"bulk_actions-edit-{$wpide_draft_type}\", function ( $actions ) {\n        $actions['wpide_to_draft'] = 'Move to draft';\n\n        return $actions;\n    } );\n\n    // WordPress checks the bulk action nonce before this filter runs.\n    add_filter( \"handle_bulk_actions-edit-{$wpide_draft_type}\", function ( $redirect, $action, $post_ids ) {\n        if ( 'wpide_to_draft' !== $action ) {\n            return $redirect;\n        }\n\n        $done = 0;\n\n        foreach ( (array) $post_ids as $post_id ) {\n            if ( current_user_can( 'edit_post', $post_id ) && 'draft' !== get_post_status( $post_id ) ) {\n                $result = wp_update_post( array( 'ID' => (int) $post_id, 'post_status' => 'draft' ), true );\n                $done  += is_wp_error( $result ) ? 0 : 1;\n            }\n        }\n\n        return add_query_arg( 'wpide_drafted', $done, $redirect );\n    }, 10, 3 );\n}\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_drafted'] ) ) {\n        $count = absint( $_GET['wpide_drafted'] );\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>%s</p></div>', esc_html( sprintf( _n( '%d item moved to draft.', '%d items moved to draft.', $count ), $count ) ) );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "protect-key-pages-from-deletion",
            "version": 1,
            "title": "Protect Important Pages From Deletion",
            "description": "Stops anyone, administrators included, from trashing or deleting the homepage, blog page, privacy page and any other pages you list.",
            "category": "admin",
            "tags": [
                "admin",
                "pages",
                "protection"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra page or post IDs to protect, besides the homepage, posts page and privacy policy page.\n$wpide_protected_ids = array();\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_protected_ids ) {\n    if ( ! in_array( $cap, array( 'delete_post', 'delete_page' ), true ) || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $protected = array_merge(\n        $wpide_protected_ids,\n        array(\n            (int) get_option( 'page_on_front' ),\n            (int) get_option( 'page_for_posts' ),\n            (int) get_option( 'wp_page_for_privacy_policy' ),\n        )\n    );\n\n    if ( in_array( (int) $args[0], array_filter( array_map( 'intval', $protected ) ), true ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "To delete a protected page later, change the Reading settings first or deactivate the snippet."
        },
        {
            "id": "site-time-in-admin-bar",
            "version": 1,
            "title": "Show the Site Time in the Admin Bar",
            "description": "Displays the current time in the site's timezone in the admin bar, which helps when scheduling posts from another timezone.",
            "category": "admin",
            "tags": [
                "admin",
                "admin-bar",
                "timezone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $timezone = wp_timezone_string();\n\n    $wp_admin_bar->add_node( array(\n        'id'     => 'wpide-site-time',\n        'parent' => 'top-secondary',\n        'title'  => esc_html( wp_date( get_option( 'time_format' ) ) ),\n        'href'   => current_user_can( 'manage_options' ) ? admin_url( 'options-general.php#timezone_string' ) : false,\n        'meta'   => array( 'title' => 'Site time (' . $timezone . ')' ),\n    ) );\n}, 1 );",
            "requires": [],
            "notes": "The time is set when the page loads; it does not tick."
        },
        {
            "id": "ctrl-s-saves-classic-editor",
            "version": 1,
            "title": "Save With Ctrl+S or Cmd+S in the Classic Editor",
            "description": "Lets you press Ctrl+S (Cmd+S on a Mac) to save a draft or update a published post in the classic editor, instead of the browser's save dialog.",
            "category": "admin",
            "tags": [
                "admin",
                "classic-editor",
                "keyboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_footer-post.php', 'wpide_ctrl_s_script' );\nadd_action( 'admin_footer-post-new.php', 'wpide_ctrl_s_script' );\n\nif ( ! function_exists( 'wpide_ctrl_s_script' ) ) {\n    function wpide_ctrl_s_script() {\n        if ( function_exists( 'use_block_editor_for_post' ) && use_block_editor_for_post( get_post() ) ) {\n            return; // The block editor already supports the shortcut.\n        }\n        ?>\n        <script>\n        ( function ( $ ) {\n            function save( event ) {\n                if ( ! ( event.ctrlKey || event.metaKey ) || 's' !== String( event.key ).toLowerCase() ) {\n                    return;\n                }\n                event.preventDefault();\n                var $draft = $( '#save-post:visible' );\n                if ( $draft.length ) {\n                    $draft.trigger( 'click' );\n                } else if ( 'publish' === $( '#original_post_status' ).val() ) {\n                    $( '#publish' ).trigger( 'click' );\n                }\n            }\n            $( document ).on( 'keydown', save );\n            $( document ).on( 'tinymce-editor-init', function ( e, editor ) {\n                editor.on( 'keydown', save );\n            } );\n        } )( jQuery );\n        </script>\n        <?php\n    }\n}",
            "requires": [],
            "notes": "Unpublished posts are only saved as drafts, never published, by the shortcut."
        },
        {
            "id": "title-placeholder-per-post-type",
            "version": 1,
            "title": "Custom \"Add Title\" Placeholder per Post Type",
            "description": "Replaces the \"Add title\" hint in the editor with your own wording for each post type, such as \"Product name\" or \"Team member name\".",
            "category": "admin",
            "tags": [
                "admin",
                "editor",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type => placeholder text.\n$wpide_title_placeholders = array(\n    'post' => 'Write a clear, specific headline',\n    'page' => 'Page name',\n);\n\nadd_filter( 'enter_title_here', function ( $text, $post ) use ( $wpide_title_placeholders ) {\n    $type = $post ? get_post_type( $post ) : '';\n\n    return isset( $wpide_title_placeholders[ $type ] ) ? $wpide_title_placeholders[ $type ] : $text;\n}, 10, 2 );",
            "requires": [],
            "notes": "Works in the block editor and the classic editor."
        },
        {
            "id": "default-content-new-posts",
            "version": 1,
            "title": "Default Content for New Posts",
            "description": "Pre-fills new posts or pages with starter content, such as a block layout or a checklist, so writers start from your template.",
            "category": "admin",
            "tags": [
                "admin",
                "editor",
                "template"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type => starter content (block markup works in the block editor).\n$wpide_default_content = array(\n    'post' => \"<!-- wp:paragraph -->\\n<p>Intro: what will the reader learn?</p>\\n<!-- /wp:paragraph -->\\n\\n<!-- wp:heading -->\\n<h2 class=\\\"wp-block-heading\\\">Main point</h2>\\n<!-- /wp:heading -->\\n\\n<!-- wp:paragraph -->\\n<p></p>\\n<!-- /wp:paragraph -->\",\n);\n\nadd_filter( 'default_content', function ( $content, $post ) use ( $wpide_default_content ) {\n    $type = $post ? get_post_type( $post ) : '';\n\n    return ( '' === $content && isset( $wpide_default_content[ $type ] ) ) ? $wpide_default_content[ $type ] : $content;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only new items are pre-filled; existing content is never changed."
        },
        {
            "id": "remove-posts-list-columns",
            "version": 1,
            "title": "Remove Columns From the Posts and Pages Lists",
            "description": "Drops columns you never look at, like Comments or Tags, from the admin lists for a cleaner overview.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Column keys to remove: author, categories, tags, comments, date.\n$wpide_remove_post_columns = array( 'comments', 'tags' );\n$wpide_remove_page_columns = array( 'comments' );\n\nadd_filter( 'manage_posts_columns', function ( $columns ) use ( $wpide_remove_post_columns ) {\n    return array_diff_key( $columns, array_flip( $wpide_remove_post_columns ) );\n} );\n\nadd_filter( 'manage_pages_columns', function ( $columns ) use ( $wpide_remove_page_columns ) {\n    return array_diff_key( $columns, array_flip( $wpide_remove_page_columns ) );\n} );",
            "requires": [],
            "notes": "manage_posts_columns also covers non-hierarchical custom post types."
        },
        {
            "id": "keep-checked-categories-in-place",
            "version": 1,
            "title": "Keep Checked Categories in Their Place",
            "description": "Stops the classic editor from moving ticked categories to the top of the list, so the category tree keeps its order.",
            "category": "admin",
            "tags": [
                "admin",
                "classic-editor",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_terms_checklist_args', function ( $args ) {\n    $args['checked_ontop'] = false;\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "publish-missed-schedule-posts",
            "version": 1,
            "title": "Publish Posts That Missed Their Schedule",
            "description": "Checks every few minutes for scheduled posts stuck with a \"Missed schedule\" label and publishes them, a common problem on low-traffic sites.",
            "category": "admin",
            "tags": [
                "admin",
                "scheduling",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How often to check, in seconds.\n$wpide_missed_check_every = 5 * MINUTE_IN_SECONDS;\n\nadd_action( 'init', function () use ( $wpide_missed_check_every ) {\n    if ( get_transient( 'wpide_missed_schedule_lock' ) ) {\n        return;\n    }\n\n    set_transient( 'wpide_missed_schedule_lock', 1, $wpide_missed_check_every );\n\n    $missed = get_posts( array(\n        'post_type'        => 'any',\n        'post_status'      => 'future',\n        'date_query'       => array( array( 'column' => 'post_date_gmt', 'before' => gmdate( 'Y-m-d H:i:s' ) ) ),\n        'numberposts'      => 10,\n        'fields'           => 'ids',\n        'suppress_filters' => true,\n    ) );\n\n    foreach ( $missed as $post_id ) {\n        wp_publish_post( $post_id );\n    }\n}, 30 );",
            "requires": [],
            "notes": "A real server cron hitting wp-cron.php is the long-term fix; this is a safety net."
        },
        {
            "id": "hide-columns-by-default",
            "version": 1,
            "title": "Hide Admin List Columns by Default",
            "description": "Hides less useful columns for users who have not customized Screen Options yet. Anyone can still turn them back on.",
            "category": "admin",
            "tags": [
                "admin",
                "columns",
                "screen-options"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Screen ID => columns hidden until the user changes Screen Options.\n$wpide_default_hidden_columns = array(\n    'edit-post' => array( 'tags', 'comments' ),\n    'edit-page' => array( 'comments', 'author' ),\n    'users'     => array( 'posts' ),\n);\n\nadd_filter( 'default_hidden_columns', function ( $hidden, $screen ) use ( $wpide_default_hidden_columns ) {\n    if ( $screen && isset( $wpide_default_hidden_columns[ $screen->id ] ) ) {\n        $hidden = array_unique( array_merge( (array) $hidden, $wpide_default_hidden_columns[ $screen->id ] ) );\n    }\n\n    return $hidden;\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "drop-uncategorized-when-other-category",
            "version": 1,
            "title": "Drop \"Uncategorized\" When Another Category Is Chosen",
            "description": "Removes the default category from a post as soon as it has another category, so posts do not end up filed under both.",
            "category": "admin",
            "tags": [
                "admin",
                "categories",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'set_object_terms', function ( $object_id, $terms, $tt_ids, $taxonomy ) {\n    if ( 'category' !== $taxonomy || count( $tt_ids ) < 2 ) {\n        return;\n    }\n\n    $default = (int) get_option( 'default_category' );\n    $term    = get_term( $default, 'category' );\n\n    if ( $term && ! is_wp_error( $term ) && in_array( (int) $term->term_taxonomy_id, array_map( 'intval', $tt_ids ), true ) ) {\n        wp_remove_object_terms( $object_id, $default, 'category' );\n    }\n}, 10, 4 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "compact-admin-list-rows",
            "version": 1,
            "title": "Compact Rows in Admin Lists",
            "description": "Tightens the spacing of admin table rows so more posts, pages and users fit on screen at once.",
            "category": "admin",
            "tags": [
                "admin",
                "list-table",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "// Vertical padding of each cell.\n$cell-padding: 4px;\n\n.wp-list-table {\n    td,\n    th.check-column,\n    tbody th {\n        padding-top: $cell-padding;\n        padding-bottom: $cell-padding;\n    }\n\n    .row-actions {\n        padding-top: 0;\n    }\n\n    tbody .check-column input[type=\"checkbox\"] {\n        margin-top: $cell-padding;\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "admin-menu-brand-colors",
            "version": 1,
            "title": "Admin Menu in Your Brand Colors",
            "description": "Recolors the admin sidebar menu and admin bar with your brand colors, set as a few SCSS variables at the top.",
            "category": "admin",
            "tags": [
                "admin",
                "branding",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "// Brand colors.\n$menu-bg: #1d2b3a;\n$menu-text: #e6edf3;\n$accent: #ff6b35;\n\n#adminmenuback,\n#adminmenuwrap,\n#adminmenu,\n#wpadminbar {\n    background: $menu-bg;\n}\n\n#adminmenu a,\n#adminmenu div.wp-menu-image::before,\n#wpadminbar .ab-item,\n#wpadminbar .ab-icon::before,\n#wpadminbar .ab-item::before {\n    color: $menu-text;\n}\n\n#adminmenu .wp-submenu,\n#adminmenu .wp-has-current-submenu .wp-submenu,\n#adminmenu a.wp-has-current-submenu:focus + .wp-submenu {\n    background: darken($menu-bg, 5%);\n}\n\n#adminmenu li.menu-top:hover,\n#adminmenu li > a.menu-top:focus,\n#adminmenu li.current a.menu-top,\n#adminmenu li.wp-has-current-submenu a.wp-has-current-submenu {\n    background: $accent;\n    color: #fff;\n}\n\n#adminmenu .awaiting-mod,\n#adminmenu .update-plugins {\n    background: $accent;\n}",
            "requires": [],
            "notes": "Works best with the Default admin color scheme selected in each user profile."
        },
        {
            "id": "admin-accent-color",
            "version": 1,
            "title": "Accent Color for Admin Buttons and Links",
            "description": "Changes the blue of primary buttons, links and focus rings across wp-admin to one accent color of your choice.",
            "category": "admin",
            "tags": [
                "admin",
                "branding",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "// Your accent color.\n$accent: #6a2fd6;\n\n.wp-core-ui .button-primary {\n    background: $accent;\n    border-color: $accent;\n\n    &:hover,\n    &:focus {\n        background: darken($accent, 8%);\n        border-color: darken($accent, 8%);\n    }\n}\n\n.wp-core-ui .button:not(.button-primary) {\n    color: $accent;\n    border-color: $accent;\n}\n\n#wpbody-content a:not(.button):not(.page-title-action) {\n    color: $accent;\n}\n\ninput:focus,\nselect:focus,\ntextarea:focus,\n.wp-core-ui .button:focus {\n    border-color: $accent;\n    box-shadow: 0 0 0 1px $accent;\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "readable-admin-text",
            "version": 1,
            "title": "Larger, More Readable Admin Text",
            "description": "Raises the font size and line height of admin tables, forms and menus for editors who find the default admin text too small.",
            "category": "admin",
            "tags": [
                "admin",
                "accessibility",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "// Base sizes.\n$base-size: 15px;\n$line-height: 1.6;\n\n#wpbody-content {\n    font-size: $base-size;\n    line-height: $line-height;\n\n    .wp-list-table td,\n    .wp-list-table th,\n    .form-table td,\n    .form-table th,\n    p,\n    label {\n        font-size: $base-size;\n        line-height: $line-height;\n    }\n\n    .row-actions {\n        font-size: $base-size - 2px;\n    }\n}\n\n#adminmenu a {\n    font-size: $base-size - 1px;\n}",
            "requires": [],
            "notes": "The block editor canvas uses your theme styles and is not affected."
        },
        {
            "id": "recent-drafts-all-authors-widget",
            "version": 1,
            "title": "Recent Drafts From All Authors Widget",
            "description": "Lists the latest drafts from every author on the dashboard, so editors can see what is in progress across the team.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "drafts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// How many drafts to list.\n$wpide_drafts_widget_count = 10;\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_drafts_widget_count ) {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_team_drafts', 'Team Drafts', function () use ( $wpide_drafts_widget_count ) {\n        $drafts = get_posts( array(\n            'post_type'   => 'any',\n            'post_status' => 'draft',\n            'numberposts' => $wpide_drafts_widget_count,\n            'orderby'     => 'modified',\n        ) );\n\n        if ( ! $drafts ) {\n            echo '<p>No drafts right now.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $drafts as $draft ) {\n            $author = get_the_author_meta( 'display_name', $draft->post_author );\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> <span style=\"color:#646970\">by %3$s, %4$s ago</span></li>',\n                esc_url( get_edit_post_link( $draft->ID ) ),\n                esc_html( get_the_title( $draft ) ? get_the_title( $draft ) : '(no title)' ),\n                esc_html( $author ),\n                esc_html( human_time_diff( get_post_modified_time( 'U', true, $draft ), time() ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pending-review-dashboard-widget",
            "version": 1,
            "title": "Pending Review Dashboard Widget",
            "description": "Shows everything waiting for review on the dashboard, with the author and a direct link to review it.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "workflow"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_pending_review', 'Waiting for Review', function () {\n        $pending = get_posts( array(\n            'post_type'   => 'any',\n            'post_status' => 'pending',\n            'numberposts' => 20,\n            'orderby'     => 'date',\n            'order'       => 'ASC',\n        ) );\n\n        if ( ! $pending ) {\n            echo '<p>Nothing is waiting for review.</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $pending as $item ) {\n            $type = get_post_type_object( $item->post_type );\n            printf(\n                '<tr><td><a href=\"%1$s\"><strong>%2$s</strong></a><br><small>%3$s by %4$s</small></td><td style=\"text-align:right\">%5$s</td></tr>',\n                esc_url( get_edit_post_link( $item->ID ) ),\n                esc_html( get_the_title( $item ) ? get_the_title( $item ) : '(no title)' ),\n                esc_html( $type ? $type->labels->singular_name : $item->post_type ),\n                esc_html( get_the_author_meta( 'display_name', $item->post_author ) ),\n                esc_html( get_the_date( '', $item ) )\n            );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Oldest submissions are listed first."
        },
        {
            "id": "server-info-dashboard-widget",
            "version": 1,
            "title": "Server and Site Info Widget",
            "description": "Adds a dashboard widget with the PHP, database and WordPress versions, memory limit, upload limit and active theme at a glance.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "server"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_server_info', 'Server Info', function () {\n        global $wpdb;\n\n        $theme = wp_get_theme();\n        $rows  = array(\n            'WordPress'          => get_bloginfo( 'version' ),\n            'PHP'                => PHP_VERSION,\n            'Database'           => $wpdb->db_server_info(),\n            'Web server'         => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : 'Unknown',\n            'PHP memory limit'   => ini_get( 'memory_limit' ),\n            'WP memory limit'    => WP_MEMORY_LIMIT,\n            'Max upload size'    => size_format( wp_max_upload_size() ),\n            'Max execution time' => ini_get( 'max_execution_time' ) . 's',\n            'Environment'        => wp_get_environment_type(),\n            'Active theme'       => $theme->get( 'Name' ) . ' ' . $theme->get( 'Version' ),\n            'Object cache'       => wp_using_ext_object_cache() ? 'Persistent' : 'None',\n        );\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $rows as $label => $value ) {\n            printf( '<tr><th scope=\"row\" style=\"width:45%%\">%s</th><td><code>%s</code></td></tr>', esc_html( $label ), esc_html( (string) $value ) );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Only administrators see the widget."
        },
        {
            "id": "shortcut-buttons-dashboard-widget",
            "version": 1,
            "title": "Shortcut Buttons Dashboard Widget",
            "description": "Puts big buttons for the most common tasks, like writing a post or editing menus, at the top of the dashboard. Ideal for clients.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Label => array( admin path, capability needed ).\n$wpide_dashboard_shortcuts = array(\n    'Write a post' => array( 'post-new.php', 'edit_posts' ),\n    'Add a page'   => array( 'post-new.php?post_type=page', 'edit_pages' ),\n    'Media'        => array( 'upload.php', 'upload_files' ),\n    'Menus'        => array( 'nav-menus.php', 'edit_theme_options' ),\n    'Comments'     => array( 'edit-comments.php', 'moderate_comments' ),\n);\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_dashboard_shortcuts ) {\n    wp_add_dashboard_widget( 'wpide_shortcuts', 'Shortcuts', function () use ( $wpide_dashboard_shortcuts ) {\n        echo '<div style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px\">';\n        foreach ( $wpide_dashboard_shortcuts as $label => $shortcut ) {\n            if ( ! current_user_can( $shortcut[1] ) ) {\n                continue;\n            }\n            printf(\n                '<a class=\"button button-hero\" style=\"text-align:center;white-space:normal\" href=\"%s\">%s</a>',\n                esc_url( admin_url( $shortcut[0] ) ),\n                esc_html( $label )\n            );\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Menus only exist in classic themes; use site-editor.php?path=/navigation for block themes."
        },
        {
            "id": "recently-updated-content-widget",
            "version": 1,
            "title": "Recently Updated Content Widget",
            "description": "Lists the most recently modified posts and pages of any type on the dashboard, with who changed them.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "activity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_recently_updated', 'Recently Updated', function () {\n        $items = get_posts( array(\n            'post_type'   => array_values( get_post_types( array( 'show_ui' => true ) ) ),\n            'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ),\n            'numberposts' => 10,\n            'orderby'     => 'modified',\n        ) );\n\n        echo '<ul>';\n        foreach ( $items as $item ) {\n            if ( ! current_user_can( 'edit_post', $item->ID ) ) {\n                continue;\n            }\n            $editor_id = (int) get_post_meta( $item->ID, '_edit_last', true );\n            $editor    = $editor_id ? get_userdata( $editor_id ) : false;\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> <span style=\"color:#646970\">%3$s ago%4$s</span></li>',\n                esc_url( get_edit_post_link( $item->ID ) ),\n                esc_html( get_the_title( $item ) ? get_the_title( $item ) : '(no title)' ),\n                esc_html( human_time_diff( get_post_modified_time( 'U', true, $item ), time() ) ),\n                $editor ? esc_html( ' by ' . $editor->display_name ) : ''\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "shared-notes-dashboard-widget",
            "version": 1,
            "title": "Shared Notes Dashboard Widget",
            "description": "A notepad on the dashboard that every editor can read and update, for handover notes, to-dos or reminders.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Who can edit the notes (everyone with dashboard access can read them).\n$wpide_notes_capability = 'edit_others_posts';\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_notes_capability ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_shared_notes', 'Team Notes', function () use ( $wpide_notes_capability ) {\n        $notes = (string) get_option( 'wpide_dashboard_notes', '' );\n\n        if ( ! current_user_can( $wpide_notes_capability ) ) {\n            echo $notes ? wp_kses_post( wpautop( $notes ) ) : '<p>No notes yet.</p>';\n            return;\n        }\n        ?>\n        <form method=\"post\" action=\"<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>\">\n            <input type=\"hidden\" name=\"action\" value=\"wpide_save_dashboard_notes\">\n            <?php wp_nonce_field( 'wpide_save_dashboard_notes' ); ?>\n            <textarea name=\"wpide_notes\" rows=\"8\" style=\"width:100%\"><?php echo esc_textarea( $notes ); ?></textarea>\n            <p><button type=\"submit\" class=\"button button-primary\">Save notes</button></p>\n        </form>\n        <?php\n    } );\n} );\n\nadd_action( 'admin_post_wpide_save_dashboard_notes', function () use ( $wpide_notes_capability ) {\n    if ( ! current_user_can( $wpide_notes_capability ) ) {\n        wp_die( 'You are not allowed to edit these notes.' );\n    }\n\n    check_admin_referer( 'wpide_save_dashboard_notes' );\n\n    $notes = isset( $_POST['wpide_notes'] ) ? wp_kses_post( wp_unslash( $_POST['wpide_notes'] ) ) : '';\n    update_option( 'wpide_dashboard_notes', $notes, false );\n\n    wp_safe_redirect( admin_url( 'index.php' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Notes are stored in the wpide_dashboard_notes option."
        },
        {
            "id": "rss-feed-dashboard-widget",
            "version": 1,
            "title": "Any RSS Feed as a Dashboard Widget",
            "description": "Shows the latest headlines from an RSS feed of your choice on the dashboard, such as your agency blog or an industry news site.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "rss"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_feed_widget = array(\n    'title' => 'Latest News',\n    'url'   => 'https://wordpress.org/news/feed/',\n    'items' => 5,\n);\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_feed_widget ) {\n    wp_add_dashboard_widget( 'wpide_rss_feed', esc_html( $wpide_feed_widget['title'] ), function () use ( $wpide_feed_widget ) {\n        echo '<div class=\"rss-widget\">';\n        // wp_widget_rss_output() fetches the feed and caches it for 12 hours.\n        wp_widget_rss_output( $wpide_feed_widget['url'], array(\n            'items'        => (int) $wpide_feed_widget['items'],\n            'show_summary' => 1,\n            'show_date'    => 1,\n        ) );\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "The feed is fetched from the remote site, then cached by WordPress."
        },
        {
            "id": "support-contact-dashboard-widget",
            "version": 1,
            "title": "Support Contact Dashboard Widget",
            "description": "Shows your agency or developer contact details on the dashboard, so clients always know how to reach you.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_support = array(\n    'name'  => 'Acme Web Studio',\n    'email' => 'help@example.com',\n    'phone' => '+1 555 0100',\n    'url'   => 'https://example.com/support',\n    'hours' => 'Monday to Friday, 9:00 to 17:00',\n);\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_support ) {\n    wp_add_dashboard_widget( 'wpide_support_contact', 'Need Help?', function () use ( $wpide_support ) {\n        printf( '<p>Questions about your website? Contact <strong>%s</strong>.</p><ul>', esc_html( $wpide_support['name'] ) );\n        if ( $wpide_support['email'] ) {\n            printf( '<li>Email: <a href=\"mailto:%1$s\">%1$s</a></li>', esc_html( antispambot( $wpide_support['email'] ) ) );\n        }\n        if ( $wpide_support['phone'] ) {\n            printf( '<li>Phone: <a href=\"tel:%1$s\">%2$s</a></li>', esc_attr( preg_replace( '/[^0-9+]/', '', $wpide_support['phone'] ) ), esc_html( $wpide_support['phone'] ) );\n        }\n        if ( $wpide_support['url'] ) {\n            printf( '<li><a href=\"%s\" target=\"_blank\" rel=\"noopener\">Open a support request</a></li>', esc_url( $wpide_support['url'] ) );\n        }\n        echo '</ul>';\n        if ( $wpide_support['hours'] ) {\n            printf( '<p style=\"color:#646970\">%s</p>', esc_html( $wpide_support['hours'] ) );\n        }\n    } );\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "users-by-role-dashboard-widget",
            "version": 1,
            "title": "Users by Role Dashboard Widget",
            "description": "Shows how many users the site has in total and per role, with links to each filtered users list.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_users_by_role', 'Users', function () {\n        $counts = count_users();\n        $names  = wp_roles()->get_names();\n\n        printf( '<p><strong>%s</strong> users in total.</p><ul>', esc_html( number_format_i18n( $counts['total_users'] ) ) );\n        foreach ( $counts['avail_roles'] as $role => $count ) {\n            if ( ! $count || 'none' === $role ) {\n                continue;\n            }\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a>: %3$s</li>',\n                esc_url( admin_url( 'users.php?role=' . $role ) ),\n                esc_html( isset( $names[ $role ] ) ? translate_user_role( $names[ $role ] ) : $role ),\n                esc_html( number_format_i18n( $count ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "count_users() can be slow on sites with hundreds of thousands of users."
        },
        {
            "id": "content-stats-dashboard-widget",
            "version": 1,
            "title": "Content Statistics Widget",
            "description": "Summarizes published, draft and scheduled items for every post type, plus media files and comments awaiting moderation.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_content_stats', 'Content Statistics', function () {\n        echo '<table class=\"widefat striped\"><thead><tr><th>Type</th><th>Published</th><th>Drafts</th><th>Scheduled</th></tr></thead><tbody>';\n        foreach ( get_post_types( array( 'show_ui' => true ), 'objects' ) as $type ) {\n            if ( in_array( $type->name, array( 'attachment', 'wp_block', 'wp_navigation', 'wp_template', 'wp_template_part' ), true ) ) {\n                continue;\n            }\n            $count = wp_count_posts( $type->name );\n            printf(\n                '<tr><td><a href=\"%1$s\">%2$s</a></td><td>%3$d</td><td>%4$d</td><td>%5$d</td></tr>',\n                esc_url( admin_url( 'edit.php?post_type=' . $type->name ) ),\n                esc_html( $type->labels->name ),\n                isset( $count->publish ) ? (int) $count->publish : 0,\n                isset( $count->draft ) ? (int) $count->draft : 0,\n                isset( $count->future ) ? (int) $count->future : 0\n            );\n        }\n        echo '</tbody></table>';\n\n        $media    = array_sum( (array) wp_count_attachments() );\n        $comments = wp_count_comments();\n        printf( '<p>Media files: <strong>%1$s</strong> &middot; Comments awaiting moderation: <strong>%2$s</strong></p>', esc_html( number_format_i18n( $media ) ), esc_html( number_format_i18n( $comments->moderated ) ) );\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "my-recent-posts-dashboard-widget",
            "version": 1,
            "title": "My Recent Posts Widget",
            "description": "Gives each writer a dashboard widget with their own latest posts in any status, so they can pick up where they left off.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_my_posts', 'My Recent Posts', function () {\n        $posts = get_posts( array(\n            'author'      => get_current_user_id(),\n            'post_type'   => 'any',\n            'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ),\n            'numberposts' => 8,\n            'orderby'     => 'modified',\n        ) );\n\n        if ( ! $posts ) {\n            printf( '<p>You have not written anything yet. <a href=\"%s\">Start a post</a>.</p>', esc_url( admin_url( 'post-new.php' ) ) );\n            return;\n        }\n\n        $statuses = get_post_statuses();\n        echo '<ul>';\n        foreach ( $posts as $item ) {\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> <span class=\"post-state\">&mdash; %3$s</span></li>',\n                esc_url( get_edit_post_link( $item->ID ) ),\n                esc_html( get_the_title( $item ) ? get_the_title( $item ) : '(no title)' ),\n                esc_html( isset( $statuses[ $item->post_status ] ) ? $statuses[ $item->post_status ] : $item->post_status )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "cron-events-dashboard-widget",
            "version": 1,
            "title": "Upcoming Scheduled Tasks Widget",
            "description": "Lists the next WP-Cron events and flags overdue ones, a quick way to spot a broken scheduler.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_cron_events', 'Scheduled Tasks', function () {\n        $crons = _get_cron_array();\n\n        if ( empty( $crons ) ) {\n            echo '<p>No scheduled tasks.</p>';\n            return;\n        }\n\n        $now   = time();\n        $shown = 0;\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $crons as $timestamp => $hooks ) {\n            foreach ( $hooks as $hook => $events ) {\n                foreach ( $events as $event ) {\n                    if ( ++$shown > 12 ) {\n                        break 3;\n                    }\n                    $late = $timestamp < $now - 10 * MINUTE_IN_SECONDS;\n                    printf(\n                        '<tr><td><code>%1$s</code><br><small>%2$s</small></td><td style=\"text-align:right;%3$s\">%4$s</td></tr>',\n                        esc_html( $hook ),\n                        esc_html( ! empty( $event['schedule'] ) ? $event['schedule'] : 'once' ),\n                        $late ? 'color:#d63638;font-weight:600' : '',\n                        esc_html( ( $timestamp < $now ? 'overdue by ' : 'in ' ) . human_time_diff( $timestamp, $now ) )\n                    );\n                }\n            }\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Many overdue tasks usually mean WP-Cron is disabled or the site gets too little traffic."
        },
        {
            "id": "woo-todays-orders-widget",
            "version": 1,
            "title": "Today's Orders Dashboard Widget",
            "description": "Shows how many WooCommerce orders came in today and their total, with the latest orders listed below.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "woocommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'wc_get_orders' ) || ! current_user_can( 'edit_shop_orders' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_woo_today', 'Today\\'s Orders', function () {\n        $start  = wp_date( 'Y-m-d' ) . ' 00:00:00';\n        $orders = wc_get_orders( array(\n            'limit'        => 50,\n            'status'       => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ),\n            'date_created' => '>=' . strtotime( get_gmt_from_date( $start ) ),\n            'orderby'      => 'date',\n            'order'        => 'DESC',\n        ) );\n\n        $total = 0;\n        foreach ( $orders as $order ) {\n            $total += (float) $order->get_total();\n        }\n\n        printf( '<p style=\"font-size:16px\"><strong>%1$d</strong> orders &middot; <strong>%2$s</strong></p>', count( $orders ), wp_kses_post( wc_price( $total ) ) );\n\n        if ( ! $orders ) {\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( array_slice( $orders, 0, 5 ) as $order ) {\n            printf(\n                '<li><a href=\"%1$s\">#%2$s</a> %3$s &middot; %4$s</li>',\n                esc_url( $order->get_edit_order_url() ),\n                esc_html( $order->get_order_number() ),\n                esc_html( $order->get_formatted_billing_full_name() ),\n                wp_kses_post( $order->get_formatted_order_total() )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The total sums the shown statuses in the store currency and counts up to 50 orders."
        },
        {
            "id": "woo-low-stock-widget",
            "version": 1,
            "title": "Low Stock Products Dashboard Widget",
            "description": "Lists WooCommerce products that are running low or out of stock, so you can reorder before customers notice.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "woocommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'wc_get_products' ) || ! current_user_can( 'edit_products' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_woo_low_stock', 'Low Stock', function () {\n        $threshold = (int) get_option( 'woocommerce_notify_low_stock_amount', 2 );\n        $products  = wc_get_products( array(\n            'limit'        => 15,\n            'manage_stock' => true,\n            'stock_status' => array( 'instock', 'outofstock', 'onbackorder' ),\n            'orderby'      => 'meta_value_num',\n            'meta_key'     => '_stock',\n            'order'        => 'ASC',\n            'type'         => array( 'simple', 'variation' ),\n        ) );\n\n        $rows = array();\n        foreach ( $products as $product ) {\n            if ( (int) $product->get_stock_quantity() <= $threshold ) {\n                $rows[] = $product;\n            }\n        }\n\n        if ( ! $rows ) {\n            echo '<p>All tracked products are above the low stock threshold.</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $rows as $product ) {\n            $edit_id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n            printf(\n                '<tr><td><a href=\"%1$s\">%2$s</a></td><td style=\"text-align:right;%3$s\">%4$d</td></tr>',\n                esc_url( get_edit_post_link( $edit_id ) ),\n                esc_html( $product->get_name() ),\n                $product->get_stock_quantity() <= 0 ? 'color:#d63638;font-weight:600' : '',\n                (int) $product->get_stock_quantity()\n            );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the low stock threshold from WooCommerce > Settings > Products > Inventory."
        },
        {
            "id": "edd-recent-sales-widget",
            "version": 1,
            "title": "Recent Sales Widget for Easy Digital Downloads",
            "description": "Lists the latest completed Easy Digital Downloads orders on the dashboard with customer email and amount.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "edd"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'edd_get_orders' ) || ! current_user_can( 'view_shop_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_edd_recent_sales', 'Recent Sales', function () {\n        $orders = edd_get_orders( array(\n            'number'  => 8,\n            'status'  => array( 'complete' ),\n            'type'    => 'sale',\n            'orderby' => 'date_created',\n            'order'   => 'DESC',\n        ) );\n\n        if ( ! $orders ) {\n            echo '<p>No sales yet.</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $orders as $order ) {\n            printf(\n                '<tr><td><a href=\"%1$s\">#%2$s</a><br><small>%3$s</small></td><td style=\"text-align:right\">%4$s<br><small>%5$s</small></td></tr>',\n                esc_url( admin_url( 'edit.php?post_type=download&page=edd-payment-history&view=view-order-details&id=' . $order->id ) ),\n                esc_html( $order->get_number() ),\n                esc_html( $order->email ),\n                esc_html( edd_currency_filter( edd_format_amount( $order->total ), $order->currency ) ),\n                esc_html( human_time_diff( strtotime( $order->date_created ), time() ) . ' ago' )\n            );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Needs Easy Digital Downloads 3.0 or newer."
        },
        {
            "id": "keep-only-chosen-dashboard-widgets",
            "version": 1,
            "title": "Keep Only Chosen Dashboard Widgets",
            "description": "Removes every dashboard widget, including those added by plugins, except the ones on your allow list.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widgets",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Widget IDs to keep. Core IDs: dashboard_right_now, dashboard_activity, dashboard_quick_press, dashboard_site_health, dashboard_primary.\n$wpide_keep_widgets = array( 'dashboard_right_now', 'dashboard_activity' );\n\n// Set to true to apply the cleanup to administrators as well.\n$wpide_clean_for_admins = false;\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_keep_widgets, $wpide_clean_for_admins ) {\n    global $wp_meta_boxes;\n\n    if ( ( ! $wpide_clean_for_admins && current_user_can( 'manage_options' ) ) || empty( $wp_meta_boxes['dashboard'] ) ) {\n        return;\n    }\n\n    foreach ( $wp_meta_boxes['dashboard'] as $context => $priorities ) {\n        foreach ( $priorities as $priority => $widgets ) {\n            foreach ( array_keys( (array) $widgets ) as $widget_id ) {\n                if ( ! in_array( $widget_id, $wpide_keep_widgets, true ) && 0 !== strpos( $widget_id, 'wpide_' ) ) {\n                    unset( $wp_meta_boxes['dashboard'][ $context ][ $priority ][ $widget_id ] );\n                }\n            }\n        }\n    }\n\n    remove_action( 'welcome_panel', 'wp_welcome_panel' );\n}, 999 );",
            "requires": [],
            "notes": "Widgets added by WPIDE snippets (IDs starting with wpide_) are always kept."
        },
        {
            "id": "skip-dashboard-for-roles",
            "version": 1,
            "title": "Skip the Dashboard for Chosen Roles",
            "description": "Sends users with certain roles straight to a more useful screen, like the Posts list, whenever they open the dashboard.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "redirect",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Role => admin page to open instead of the dashboard.\n$wpide_dashboard_redirects = array(\n    'author'      => 'edit.php',\n    'contributor' => 'edit.php',\n    'shop_manager' => 'admin.php?page=wc-orders',\n);\n\nadd_action( 'load-index.php', function () use ( $wpide_dashboard_redirects ) {\n    $user = wp_get_current_user();\n\n    foreach ( (array) $user->roles as $role ) {\n        if ( isset( $wpide_dashboard_redirects[ $role ] ) ) {\n            wp_safe_redirect( admin_url( $wpide_dashboard_redirects[ $role ] ) );\n            exit;\n        }\n    }\n} );",
            "requires": [],
            "notes": "Administrators are never redirected unless you add the administrator role to the list."
        },
        {
            "id": "new-users-dashboard-widget",
            "version": 1,
            "title": "Newest Users Dashboard Widget",
            "description": "Shows the most recent registrations on the dashboard with their role and sign-up date.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_new_users', 'Newest Users', function () {\n        $users = get_users( array(\n            'number'  => 8,\n            'orderby' => 'registered',\n            'order'   => 'DESC',\n        ) );\n        $names = wp_roles()->get_names();\n\n        echo '<ul>';\n        foreach ( $users as $user ) {\n            $role = $user->roles ? reset( $user->roles ) : '';\n            printf(\n                '<li>%1$s <a href=\"%2$s\">%3$s</a> <span style=\"color:#646970\">%4$s &middot; %5$s ago</span></li>',\n                get_avatar( $user->ID, 20, '', '', array( 'extra_attr' => 'style=\"vertical-align:middle;border-radius:50%\"' ) ),\n                esc_url( get_edit_user_link( $user->ID ) ),\n                esc_html( $user->display_name ),\n                esc_html( $role && isset( $names[ $role ] ) ? translate_user_role( $names[ $role ] ) : 'No role' ),\n                esc_html( human_time_diff( strtotime( $user->user_registered ), time() ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "most-commented-posts-widget",
            "version": 1,
            "title": "Most Commented Posts Widget",
            "description": "Lists the posts with the most approved comments on the dashboard, a quick read on what sparks conversation.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_most_commented', 'Most Commented', function () {\n        $posts = get_posts( array(\n            'post_type'   => 'post',\n            'numberposts' => 8,\n            'orderby'     => 'comment_count',\n            'order'       => 'DESC',\n        ) );\n\n        echo '<ol>';\n        foreach ( $posts as $item ) {\n            if ( ! $item->comment_count ) {\n                continue;\n            }\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> <span style=\"color:#646970\">(%3$s)</span></li>',\n                esc_url( get_permalink( $item ) ),\n                esc_html( get_the_title( $item ) ),\n                esc_html( number_format_i18n( $item->comment_count ) )\n            );\n        }\n        echo '</ol>';\n    } );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "glance-drafts-pending-scheduled",
            "version": 1,
            "title": "Drafts, Pending and Scheduled in \"At a Glance\"",
            "description": "Adds draft, pending and scheduled post counts to the At a Glance dashboard box, linked to the filtered lists.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "at-a-glance",
                "drafts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'dashboard_glance_items', function ( $items ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return $items;\n    }\n\n    $counts = wp_count_posts( 'post' );\n    $labels = array(\n        'draft'   => array( '%s Draft', '%s Drafts' ),\n        'pending' => array( '%s Pending', '%s Pending' ),\n        'future'  => array( '%s Scheduled', '%s Scheduled' ),\n    );\n\n    foreach ( $labels as $status => $forms ) {\n        $count = isset( $counts->$status ) ? (int) $counts->$status : 0;\n\n        if ( $count ) {\n            $items[] = sprintf(\n                '<a class=\"post-count\" href=\"%1$s\">%2$s</a>',\n                esc_url( admin_url( 'edit.php?post_status=' . $status ) ),\n                esc_html( sprintf( 1 === $count ? $forms[0] : $forms[1], number_format_i18n( $count ) ) )\n            );\n        }\n    }\n\n    return $items;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "tutorial-video-dashboard-widget",
            "version": 1,
            "title": "Tutorial Video Dashboard Widget",
            "description": "Embeds a how-to video on the dashboard, such as a walkthrough recorded for your client, with an optional short message.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "video"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_tutorial = array(\n    'title'   => 'How to Edit Your Website',\n    'video'   => 'https://www.youtube.com/watch?v=XXXXXXXXXXX', // YouTube, Vimeo or any oEmbed URL.\n    'message' => 'Watch this short walkthrough before your first edit.',\n);\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_tutorial ) {\n    wp_add_dashboard_widget( 'wpide_tutorial_video', esc_html( $wpide_tutorial['title'] ), function () use ( $wpide_tutorial ) {\n        if ( $wpide_tutorial['message'] ) {\n            echo '<p>' . esc_html( $wpide_tutorial['message'] ) . '</p>';\n        }\n\n        $embed = wp_oembed_get( $wpide_tutorial['video'], array( 'width' => 600 ) );\n\n        echo $embed ? '<div class=\"wpide-video\" style=\"aspect-ratio:16/9\">' . str_replace( '<iframe ', '<iframe style=\"width:100%;height:100%\" ', $embed ) . '</div>' : '<p><a href=\"' . esc_url( $wpide_tutorial['video'] ) . '\" target=\"_blank\" rel=\"noopener\">Open the video</a></p>';\n    } );\n}, 1 );",
            "requires": [],
            "notes": "The oEmbed result is fetched from the video site; WordPress does not cache it outside posts, so keep the widget for clients only if needed."
        },
        {
            "id": "disk-usage-dashboard-widget",
            "version": 1,
            "title": "Disk and Database Usage Widget",
            "description": "Shows how much space the uploads folder and the database take, recalculated once a day, so you notice a full hosting plan early.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "widget",
                "storage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_disk_usage', 'Disk Usage', function () {\n        global $wpdb;\n\n        $usage = get_transient( 'wpide_disk_usage' );\n\n        if ( false === $usage ) {\n            $uploads = wp_get_upload_dir();\n            $bytes   = 0;\n\n            if ( is_dir( $uploads['basedir'] ) ) {\n                $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $uploads['basedir'], FilesystemIterator::SKIP_DOTS ) );\n                foreach ( $files as $file ) {\n                    $bytes += $file->isFile() ? $file->getSize() : 0;\n                }\n            }\n\n            $database = (float) $wpdb->get_var( $wpdb->prepare(\n                'SELECT SUM(data_length + index_length) FROM information_schema.TABLES WHERE table_schema = %s',\n                DB_NAME\n            ) );\n\n            $usage = array( 'uploads' => $bytes, 'database' => $database, 'time' => time() );\n            set_transient( 'wpide_disk_usage', $usage, DAY_IN_SECONDS );\n        }\n\n        printf(\n            '<p>Uploads folder: <strong>%1$s</strong><br>Database: <strong>%2$s</strong></p><p style=\"color:#646970\">Calculated %3$s ago.</p>',\n            esc_html( size_format( $usage['uploads'], 1 ) ),\n            esc_html( size_format( $usage['database'], 1 ) ),\n            esc_html( human_time_diff( $usage['time'], time() ) )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "The first calculation can take a few seconds on sites with many uploads."
        },
        {
            "id": "dashboard-card-style",
            "version": 1,
            "title": "Softer Card Style for Dashboard Widgets",
            "description": "Gives dashboard widgets rounded corners, more breathing room and a colored top border, set with SCSS variables.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "design",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "// Style settings.\n$accent: #2271b1;\n$radius: 10px;\n\n#dashboard-widgets {\n    .postbox {\n        border-radius: $radius;\n        border-top: 3px solid $accent;\n        box-shadow: 0 1px 3px rgba(0, 0, 0, .06);\n        overflow: hidden;\n    }\n\n    .postbox-header {\n        border-bottom-color: rgba($accent, .15);\n\n        .hndle {\n            font-size: 15px;\n        }\n    }\n\n    .inside {\n        padding: 4px 16px 16px;\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "login-background-image",
            "version": 1,
            "title": "Login Page Background Image",
            "description": "Covers the login page with a full-screen background photo and a dark overlay that keeps the form readable.",
            "category": "login",
            "tags": [
                "login",
                "branding",
                "background"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Full URL of the background image, and the overlay color on top of it.\n$wpide_login_bg_image   = 'https://example.com/wp-content/uploads/login-background.jpg';\n$wpide_login_bg_overlay = 'rgba(0, 0, 0, 0.35)';\n\nadd_action( 'login_enqueue_scripts', function () use ( $wpide_login_bg_image, $wpide_login_bg_overlay ) {\n    $image   = esc_url_raw( $wpide_login_bg_image );\n    $overlay = preg_replace( '/[^a-zA-Z0-9#(),.\\s%]/', '', $wpide_login_bg_overlay );\n\n    if ( ! $image ) {\n        return;\n    }\n\n    wp_add_inline_style( 'login', sprintf(\n        'body.login{background:linear-gradient(%1$s,%1$s),url(\"%2$s\") center / cover no-repeat fixed;}' .\n        '.login #backtoblog a,.login #nav a,.login .privacy-policy-page-link a{color:#fff;text-shadow:0 1px 2px rgba(0,0,0,.6);}',\n        $overlay,\n        $image\n    ) );\n} );",
            "requires": [],
            "notes": "Use a compressed image of about 1920px wide so the login page stays fast."
        },
        {
            "id": "login-message-above-form",
            "version": 1,
            "title": "Message Above the Login Form",
            "description": "Shows your own notice above the login form, for example to point customers to the shop account page or announce maintenance.",
            "category": "login",
            "tags": [
                "login",
                "message",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// HTML is allowed (links, bold text).\n$wpide_login_notice = 'This login is for staff. Customers can <a href=\"/my-account/\">sign in to their account here</a>.';\n\nadd_filter( 'login_message', function ( $message ) use ( $wpide_login_notice ) {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : 'login';\n\n    if ( 'login' !== $action || '' === $wpide_login_notice ) {\n        return $message;\n    }\n\n    return $message . '<p class=\"message\">' . wp_kses_post( $wpide_login_notice ) . '</p>';\n} );",
            "requires": [],
            "notes": "The message only shows on the main login screen, not on password reset or registration."
        },
        {
            "id": "login-footer-links",
            "version": 1,
            "title": "Extra Links Below the Login Form",
            "description": "Adds a row of links under the login box, such as Contact, Help or Terms of Use.",
            "category": "login",
            "tags": [
                "login",
                "links",
                "footer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label => URL.\n$wpide_login_links = array(\n    'Help'    => home_url( '/help/' ),\n    'Contact' => home_url( '/contact/' ),\n    'Terms'   => home_url( '/terms/' ),\n);\n\nadd_action( 'login_footer', function () use ( $wpide_login_links ) {\n    if ( empty( $wpide_login_links ) ) {\n        return;\n    }\n\n    $links = array();\n    foreach ( $wpide_login_links as $label => $url ) {\n        $links[] = sprintf( '<a href=\"%s\">%s</a>', esc_url( $url ), esc_html( $label ) );\n    }\n\n    echo '<p class=\"wpide-login-links\" style=\"text-align:center;margin:0 auto 24px;font-size:13px\">' . implode( ' &middot; ', $links ) . '</p>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-login-page-links",
            "version": 1,
            "title": "Hide Links on the Login Page",
            "description": "Removes the \"Go to site\", \"Lost your password?\" and privacy policy links from the login screen, each one switchable on its own.",
            "category": "login",
            "tags": [
                "login",
                "cleanup",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Choose which links to hide.\n$wpide_hide_login_links = array(\n    'back_to_site'  => true,  // \"← Go to Site Name\"\n    'lost_password' => false, // \"Lost your password?\"\n    'privacy'       => true,  // Privacy policy link\n);\n\nadd_action( 'login_enqueue_scripts', function () use ( $wpide_hide_login_links ) {\n    $selectors = array();\n\n    if ( ! empty( $wpide_hide_login_links['back_to_site'] ) ) {\n        $selectors[] = '#backtoblog';\n    }\n    if ( ! empty( $wpide_hide_login_links['privacy'] ) ) {\n        $selectors[] = '.login .privacy-policy-page-link';\n    }\n\n    if ( $selectors ) {\n        wp_add_inline_style( 'login', implode( ',', $selectors ) . '{display:none !important;}' );\n    }\n} );\n\nadd_filter( 'lost_password_html_link', function ( $html ) use ( $wpide_hide_login_links ) {\n    return empty( $wpide_hide_login_links['lost_password'] ) ? $html : '';\n} );",
            "requires": [],
            "notes": "Hiding the lost password link does not disable password resets."
        },
        {
            "id": "disable-password-reset-everyone",
            "version": 1,
            "title": "Disable Password Resets for Everyone",
            "description": "Turns off the \"Lost your password?\" feature for all users, for sites where accounts are managed only by an administrator.",
            "category": "login",
            "tags": [
                "login",
                "password",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'allow_password_reset', '__return_false' );\n\nadd_filter( 'lost_password_html_link', '__return_empty_string' );\n\nadd_action( 'login_form_lostpassword', function () {\n    wp_safe_redirect( wp_login_url() );\n    exit;\n} );\n\nadd_action( 'login_form_retrievepassword', function () {\n    wp_safe_redirect( wp_login_url() );\n    exit;\n} );",
            "requires": [],
            "notes": "The \"Send password reset\" button on the Users screen stops working too; set new passwords from the user profile instead."
        },
        {
            "id": "disable-login-shake",
            "version": 1,
            "title": "Turn Off the Login Form Shake",
            "description": "Stops the login box from shaking after a wrong password, for a calmer login screen.",
            "category": "login",
            "tags": [
                "login",
                "animation",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'shake_error_codes', '__return_empty_array' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "redirect-logged-in-from-login",
            "version": 1,
            "title": "Send Logged-In Users Away From the Login Page",
            "description": "When someone who is already logged in opens wp-login.php, they go straight to the dashboard (or the homepage for subscribers) instead of seeing the form again.",
            "category": "login",
            "tags": [
                "login",
                "redirect",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_init', function () {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : 'login';\n\n    if ( 'login' !== $action || ! is_user_logged_in() || isset( $_REQUEST['interim-login'] ) || isset( $_REQUEST['reauth'] ) ) {\n        return;\n    }\n\n    if ( 'POST' === ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : 'GET' ) ) {\n        return;\n    }\n\n    $fallback = current_user_can( 'edit_posts' ) ? admin_url() : home_url( '/' );\n    $target   = isset( $_GET['redirect_to'] ) ? wp_validate_redirect( wp_unslash( $_GET['redirect_to'] ), $fallback ) : $fallback;\n\n    wp_safe_redirect( $target );\n    exit;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "login-return-to-previous-page",
            "version": 1,
            "title": "Return Users to the Page They Were On After Login",
            "description": "After logging in, visitors land back on the page they came from instead of the dashboard, which suits membership and community sites.",
            "category": "login",
            "tags": [
                "login",
                "redirect",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Set to true to send administrators back to the previous page too (instead of the dashboard).\n$wpide_return_admins_too = false;\n\nadd_action( 'login_form', function () {\n    $referer = wp_get_referer();\n\n    if ( ! $referer || false !== strpos( $referer, 'wp-login.php' ) || false !== strpos( $referer, '/wp-admin' ) ) {\n        return;\n    }\n\n    $referer = wp_validate_redirect( $referer, '' );\n\n    if ( $referer ) {\n        echo '<input type=\"hidden\" name=\"wpide_return_to\" value=\"' . esc_attr( $referer ) . '\">';\n    }\n} );\n\nadd_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) use ( $wpide_return_admins_too ) {\n    if ( ! ( $user instanceof WP_User ) || empty( $_POST['wpide_return_to'] ) ) {\n        return $redirect_to;\n    }\n\n    if ( ! $wpide_return_admins_too && user_can( $user, 'manage_options' ) ) {\n        return $redirect_to;\n    }\n\n    // Only override the default destination, never an explicit redirect_to.\n    if ( '' !== $requested && admin_url() !== $requested ) {\n        return $redirect_to;\n    }\n\n    return wp_validate_redirect( esc_url_raw( wp_unslash( $_POST['wpide_return_to'] ) ), $redirect_to );\n}, 20, 3 );",
            "requires": [],
            "notes": "Some browsers or privacy settings do not send the referring page; those users get the normal destination."
        },
        {
            "id": "dark-mode-login-page",
            "version": 1,
            "title": "Dark Mode for the Login Page",
            "description": "Switches the login page to dark colors automatically when the visitor's device is set to dark mode.",
            "category": "login",
            "tags": [
                "login",
                "dark-mode",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    $css = '@media (prefers-color-scheme: dark){'\n        . 'body.login{background:#16181d;color:#d7dae0;}'\n        . '.login form,.login .message,.login .notice,.login #login_error{background:#22252c;border-color:#33373f;color:#d7dae0;box-shadow:none;}'\n        . '.login label,.login .forgetmenot label{color:#d7dae0;}'\n        . '.login input[type=text],.login input[type=password],.login input[type=email]{background:#16181d;border-color:#3d424c;color:#f0f2f5;}'\n        . '.login #nav a,.login #backtoblog a,.login .privacy-policy-page-link a{color:#9ea7b3;}'\n        . '.login #nav a:hover,.login #backtoblog a:hover{color:#fff;}'\n        . '.login .button.wp-hide-pw .dashicons{color:#9ea7b3;}'\n        . '}';\n\n    wp_add_inline_style( 'login', $css );\n} );",
            "requires": [],
            "notes": "If you use a dark logo, also provide a light version for dark mode."
        },
        {
            "id": "change-login-form-labels",
            "version": 1,
            "title": "Change Login Form Labels and Button Text",
            "description": "Renames the field labels and the button on the login screen, for example \"Email\" instead of \"Username or Email Address\" and \"Sign in\" instead of \"Log In\".",
            "category": "login",
            "tags": [
                "login",
                "labels",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Original English text => your text.\n$wpide_login_labels = array(\n    'Username or Email Address' => 'Email or username',\n    'Password'                  => 'Password',\n    'Remember Me'               => 'Keep me signed in',\n    'Log In'                    => 'Sign in',\n    'Lost your password?'       => 'Forgot your password?',\n);\n\nadd_action( 'login_init', function () use ( $wpide_login_labels ) {\n    add_filter( 'gettext', function ( $translation, $text, $domain ) use ( $wpide_login_labels ) {\n        if ( 'default' === $domain && isset( $wpide_login_labels[ $text ] ) ) {\n            return $wpide_login_labels[ $text ];\n        }\n\n        return $translation;\n    }, 20, 3 );\n} );",
            "requires": [],
            "notes": "Matches the original English strings, so it also works when the site language is not English."
        },
        {
            "id": "login-page-browser-title",
            "version": 1,
            "title": "Change the Login Page Browser Title",
            "description": "Sets the browser tab title of the login screens to \"Log In | Your Site\" instead of the WordPress default.",
            "category": "login",
            "tags": [
                "login",
                "branding",
                "title"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_title', function ( $login_title, $title ) {\n    return wp_strip_all_tags( $title ) . ' | ' . get_bloginfo( 'name', 'display' );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-remember-me-checkbox",
            "version": 1,
            "title": "Hide the \"Remember Me\" Checkbox",
            "description": "Removes the Remember Me option so every login ends when the browser closes, which suits shared or public computers.",
            "category": "login",
            "tags": [
                "login",
                "sessions",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    wp_add_inline_style( 'login', '.login .forgetmenot{display:none;}' );\n} );\n\n// Ignore the field even if it is submitted.\nadd_action( 'login_init', function () {\n    unset( $_POST['rememberme'] );\n} );",
            "requires": [],
            "notes": "Do not combine with snippets that tick Remember Me by default."
        },
        {
            "id": "log-out-idle-users",
            "version": 1,
            "title": "Log Out Idle Users Automatically",
            "description": "Logs users out after a set time without any mouse, keyboard or scroll activity, counted across all their open tabs.",
            "category": "login",
            "tags": [
                "login",
                "sessions",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minutes of inactivity before logout.\n$wpide_idle_minutes = 30;\n\n$wpide_idle_script = function () use ( $wpide_idle_minutes ) {\n    if ( ! is_user_logged_in() ) {\n        return;\n    }\n\n    $logout = wp_logout_url( add_query_arg( 'wpide_idle', '1', wp_login_url() ) );\n    ?>\n    <script>\n    ( function () {\n        var limit = <?php echo (int) $wpide_idle_minutes; ?> * 60000, key = 'wpideLastActivity', local = Date.now();\n        function touch() {\n            local = Date.now();\n            try { localStorage.setItem( key, String( local ) ); } catch ( e ) {}\n        }\n        function last() {\n            var shared = 0;\n            try { shared = parseInt( localStorage.getItem( key ), 10 ) || 0; } catch ( e ) {}\n            return Math.max( shared, local );\n        }\n        [ 'mousemove', 'keydown', 'scroll', 'click', 'touchstart' ].forEach( function ( evt ) {\n            window.addEventListener( evt, function () { if ( Date.now() - local > 15000 ) { touch(); } }, { passive: true } );\n        } );\n        touch();\n        setInterval( function () {\n            if ( Date.now() - last() > limit ) {\n                window.location.href = <?php echo wp_json_encode( $logout ); ?>;\n            }\n        }, 30000 );\n    } )();\n    </script>\n    <?php\n};\n\nadd_action( 'admin_footer', $wpide_idle_script );\nadd_action( 'wp_footer', $wpide_idle_script );\n\nadd_filter( 'login_message', function ( $message ) use ( $wpide_idle_minutes ) {\n    if ( isset( $_GET['wpide_idle'] ) ) {\n        $message .= '<p class=\"message\">' . esc_html( sprintf( 'You were logged out after %d minutes of inactivity.', $wpide_idle_minutes ) ) . '</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "This runs in the browser; the login cookie itself keeps its normal lifetime."
        },
        {
            "id": "shorter-admin-sessions",
            "version": 1,
            "title": "Shorter Login Sessions for Administrators",
            "description": "Makes administrator logins expire sooner than everyone else's, limiting the damage of a forgotten logged-in browser.",
            "category": "login",
            "tags": [
                "login",
                "sessions",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Session length for administrators, with and without \"Remember Me\".\n$wpide_admin_session        = 8 * HOUR_IN_SECONDS;\n$wpide_admin_session_remember = 2 * DAY_IN_SECONDS;\n\nadd_filter( 'auth_cookie_expiration', function ( $length, $user_id, $remember ) use ( $wpide_admin_session, $wpide_admin_session_remember ) {\n    if ( user_can( $user_id, 'manage_options' ) ) {\n        return $remember ? $wpide_admin_session_remember : $wpide_admin_session;\n    }\n\n    return $length;\n}, 20, 3 );",
            "requires": [],
            "notes": "Applies from the next login; current sessions keep their expiry."
        },
        {
            "id": "login-form-shortcode",
            "version": 1,
            "title": "Login Form Shortcode",
            "description": "Adds a [wpide_login_form] shortcode that shows a login form on any page, or a \"you are logged in\" message with a logout link.",
            "category": "login",
            "tags": [
                "login",
                "shortcode",
                "form"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_login_form', function ( $atts ) {\n    $atts = shortcode_atts( array( 'redirect' => '' ), $atts, 'wpide_login_form' );\n\n    $current  = get_permalink() ? get_permalink() : home_url( '/' );\n    $redirect = $atts['redirect'] ? wp_validate_redirect( $atts['redirect'], $current ) : $current;\n\n    if ( is_user_logged_in() ) {\n        return sprintf(\n            '<p class=\"wpide-logged-in\">You are logged in as %1$s. <a href=\"%2$s\">Log out</a></p>',\n            esc_html( wp_get_current_user()->display_name ),\n            esc_url( wp_logout_url( $current ) )\n        );\n    }\n\n    $html  = '<div class=\"wpide-login-form\">';\n    $html .= isset( $_GET['login'] ) && 'failed' === $_GET['login'] ? '<p class=\"wpide-login-error\" role=\"alert\">Wrong username or password. Please try again.</p>' : '';\n    $html .= wp_login_form( array( 'echo' => false, 'redirect' => $redirect ) );\n    $html .= '<p><a href=\"' . esc_url( wp_lostpassword_url( $current ) ) . '\">Lost your password?</a></p>';\n    $html .= '</div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Optional attribute: [wpide_login_form redirect=\"/members/\"]."
        },
        {
            "id": "logout-without-confirmation",
            "version": 1,
            "title": "Log Out Without the Confirmation Screen",
            "description": "Logs users out right away from plain logout links (for example in menus) instead of showing \"Do you really want to log out?\".",
            "category": "login",
            "tags": [
                "login",
                "logout",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'check_admin_referer', function ( $action, $result ) {\n    if ( 'log-out' !== $action || $result || ! is_user_logged_in() ) {\n        return;\n    }\n\n    wp_logout();\n\n    $target = isset( $_REQUEST['redirect_to'] ) ? wp_validate_redirect( wp_unslash( $_REQUEST['redirect_to'] ), home_url( '/' ) ) : home_url( '/' );\n\n    wp_safe_redirect( $target );\n    exit;\n}, 10, 2 );",
            "requires": [],
            "notes": "Without the confirmation step, another site could log your users out with a link; that is an annoyance, not a data risk."
        },
        {
            "id": "login-logout-menu-item",
            "version": 1,
            "title": "Log In / Log Out Item in the Navigation Menu",
            "description": "Adds a Log in or Log out link at the end of a theme menu, depending on whether the visitor is logged in.",
            "category": "login",
            "tags": [
                "login",
                "menu",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// The theme menu location that gets the link (see Appearance > Menus > Manage Locations).\n$wpide_login_menu_location = 'primary';\n\nadd_filter( 'wp_nav_menu_items', function ( $items, $args ) use ( $wpide_login_menu_location ) {\n    if ( empty( $args->theme_location ) || $wpide_login_menu_location !== $args->theme_location ) {\n        return $items;\n    }\n\n    $current = home_url( add_query_arg( array() ) );\n\n    if ( is_user_logged_in() ) {\n        $link = '<a href=\"' . esc_url( wp_logout_url( home_url( '/' ) ) ) . '\">Log out</a>';\n    } else {\n        $link = '<a href=\"' . esc_url( wp_login_url( $current ) ) . '\">Log in</a>';\n    }\n\n    return $items . '<li class=\"menu-item wpide-login-item\">' . $link . '</li>';\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with classic themes that use wp_nav_menu(); block themes use the Navigation block instead."
        },
        {
            "id": "admins-only-login-lockdown",
            "version": 1,
            "title": "Allow Only Administrators to Log In",
            "description": "Temporarily blocks logins for everyone except administrators, for example during a migration or a security incident.",
            "category": "login",
            "tags": [
                "login",
                "maintenance",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_lockdown_message = 'Logins are paused for maintenance. Please try again later.';\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_lockdown_message ) {\n    if ( $user instanceof WP_User && ! user_can( $user, 'manage_options' ) ) {\n        return new WP_Error( 'wpide_login_lockdown', esc_html( $wpide_lockdown_message ) );\n    }\n\n    return $user;\n}, 100 );",
            "requires": [],
            "notes": "People who are already logged in stay logged in until their session ends."
        },
        {
            "id": "custom-login-page",
            "version": 1,
            "title": "Use a Custom Page for Logging In",
            "description": "Sends visitors who open wp-login.php to your own login page (for example one with the login form shortcode), and back there when a login fails.",
            "category": "login",
            "tags": [
                "login",
                "redirect",
                "custom-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Path of your login page. It must contain a login form, e.g. the [wpide_login_form] shortcode.\n$wpide_login_page_path = '/login/';\n\nadd_action( 'login_init', function () use ( $wpide_login_page_path ) {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : 'login';\n    $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( $_SERVER['REQUEST_METHOD'] ) ) : 'GET';\n\n    // Leave logout, password reset, registration, re-authentication and form posts on wp-login.php.\n    if ( 'login' !== $action || 'GET' !== $method || isset( $_GET['interim-login'] ) || isset( $_GET['reauth'] ) || isset( $_GET['loggedout'] ) || isset( $_GET['checkemail'] ) ) {\n        return;\n    }\n\n    $target = home_url( $wpide_login_page_path );\n\n    if ( ! empty( $_GET['redirect_to'] ) ) {\n        $target = add_query_arg( 'redirect_to', rawurlencode( wp_validate_redirect( wp_unslash( $_GET['redirect_to'] ), admin_url() ) ), $target );\n    }\n\n    wp_safe_redirect( $target );\n    exit;\n} );\n\nadd_action( 'wp_login_failed', function () use ( $wpide_login_page_path ) {\n    $referer = wp_get_referer();\n\n    if ( ! $referer || false === strpos( $referer, $wpide_login_page_path ) || 'wp-login.php' !== ( isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '' ) ) {\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 'login', 'failed', home_url( $wpide_login_page_path ) ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Create the page first and test logging in from a private window before you rely on it."
        },
        {
            "id": "login-prompt-mid-article",
            "version": 1,
            "title": "Log In Prompt in the Middle of Posts",
            "description": "Shows a small \"Log in or create an account\" box halfway through articles for visitors who are not logged in.",
            "category": "login",
            "tags": [
                "login",
                "members",
                "call-to-action"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_half",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() ) : ?>\n<aside class=\"wpide-login-prompt\" style=\"margin:2em 0;padding:1em 1.25em;border-left:4px solid currentColor;background:rgba(127,127,127,.08)\">\n    <p style=\"margin:0\">\n        <strong>Join the conversation.</strong>\n        <a href=\"<?php echo esc_url( wp_login_url( get_permalink() ) ); ?>\">Log in</a>\n        <?php if ( get_option( 'users_can_register' ) ) : ?>\n            or <a href=\"<?php echo esc_url( wp_registration_url() ); ?>\">create a free account</a>\n        <?php endif; ?>\n        to comment and get updates.\n    </p>\n</aside>\n<?php endif; ?>",
            "requires": [],
            "notes": "The \"create an account\" link only appears when \"Anyone can register\" is enabled."
        },
        {
            "id": "woo-login-links-before-cart",
            "version": 1,
            "title": "Log In Reminder Above the Cart",
            "description": "Reminds guests at the top of the WooCommerce cart that they can log in to use saved addresses and see their order history.",
            "category": "login",
            "tags": [
                "login",
                "woocommerce",
                "cart"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "woocommerce_before_cart",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() && function_exists( 'wc_get_page_permalink' ) ) : ?>\n<div class=\"woocommerce-info wpide-cart-login\">\n    Already have an account?\n    <a href=\"<?php echo esc_url( add_query_arg( 'redirect_to', rawurlencode( wc_get_cart_url() ), wc_get_page_permalink( 'myaccount' ) ) ); ?>\">Log in</a>\n    to check out faster with your saved details.\n</div>\n<?php endif; ?>",
            "requires": [
                "woocommerce"
            ],
            "notes": ""
        },
        {
            "id": "edd-login-before-checkout",
            "version": 1,
            "title": "Log In Link Above the Easy Digital Downloads Checkout",
            "description": "Invites returning customers to log in before they fill in the Easy Digital Downloads checkout form, so purchases are linked to their account.",
            "category": "login",
            "tags": [
                "login",
                "edd",
                "checkout"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "edd_before_purchase_form",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() && function_exists( 'edd_get_checkout_uri' ) ) : ?>\n<p class=\"wpide-edd-login\" style=\"padding:.75em 1em;background:rgba(127,127,127,.08);border-radius:4px\">\n    Bought from us before?\n    <a href=\"<?php echo esc_url( wp_login_url( edd_get_checkout_uri() ) ); ?>\">Log in</a>\n    so this purchase appears in your account and download history.\n</p>\n<?php endif; ?>",
            "requires": [
                "edd"
            ],
            "notes": ""
        },
        {
            "id": "memberpress-login-help-text",
            "version": 1,
            "title": "Help Text in the MemberPress Login Form",
            "description": "Adds a short hint above the MemberPress login button with a link to reset the password, which cuts down on \"I can't log in\" emails.",
            "category": "login",
            "tags": [
                "login",
                "memberpress",
                "help"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "mepr-login-form-before-submit",
            "insert_number": 1,
            "code": "<p class=\"wpide-mepr-login-help\" style=\"font-size:.9em;opacity:.85\">\n    Use the email address you signed up with.\n    Forgot your password? <a href=\"<?php echo esc_url( wp_lostpassword_url() ); ?>\">Reset it here</a>.\n</p>",
            "requires": [
                "memberpress"
            ],
            "notes": ""
        },
        {
            "id": "memberpress-unauthorized-login-link",
            "version": 1,
            "title": "Log In Link Under the MemberPress Unauthorized Message",
            "description": "Adds a \"Already a member? Log in\" link below the MemberPress access-denied message, returning members to the same page after login.",
            "category": "login",
            "tags": [
                "login",
                "memberpress",
                "members"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "mepr_unauthorized_message_after",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() ) : ?>\n<p class=\"wpide-mepr-login-link\">\n    Already a member?\n    <a href=\"<?php echo esc_url( wp_login_url( get_permalink() ? get_permalink() : home_url( '/' ) ) ); ?>\">Log in to continue reading</a>.\n</p>\n<?php endif; ?>",
            "requires": [
                "memberpress"
            ],
            "notes": ""
        },
        {
            "id": "one-session-per-user",
            "version": 1,
            "title": "Allow Only One Active Session per User",
            "description": "Logs a user out of every other browser and device as soon as they log in somewhere new, which stops account sharing.",
            "category": "login",
            "tags": [
                "login",
                "sessions",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that may stay logged in on several devices.\n$wpide_multi_session_roles = array( 'administrator' );\n\nadd_action( 'set_logged_in_cookie', function ( $cookie, $expire, $expiration, $user_id, $scheme, $token ) use ( $wpide_multi_session_roles ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user || ! $token || array_intersect( (array) $user->roles, $wpide_multi_session_roles ) ) {\n        return;\n    }\n\n    WP_Session_Tokens::get_instance( $user_id )->destroy_others( $token );\n}, 10, 6 );",
            "requires": [],
            "notes": "The newest login always wins; the older sessions are ended on their next page load."
        },
        {
            "id": "redirect-on-first-login",
            "version": 1,
            "title": "Redirect Users on Their First Login",
            "description": "Sends each user to a welcome or onboarding page the first time they log in, and to the usual place after that.",
            "category": "login",
            "tags": [
                "login",
                "redirect",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Where first-time users go.\n$wpide_first_login_url = home_url( '/welcome/' );\n\nadd_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) use ( $wpide_first_login_url ) {\n    if ( ! ( $user instanceof WP_User ) || get_user_meta( $user->ID, 'wpide_first_login_done', true ) ) {\n        return $redirect_to;\n    }\n\n    update_user_meta( $user->ID, 'wpide_first_login_done', time() );\n\n    return $wpide_first_login_url;\n}, 30, 3 );",
            "requires": [],
            "notes": "Existing users count as first-timers on their next login after you activate this."
        },
        {
            "id": "previous-login-notice",
            "version": 1,
            "title": "Show Your Previous Login Time After Logging In",
            "description": "Greets users in the admin with the date, time and IP address of their previous login, so they can spot logins they do not recognize.",
            "category": "login",
            "tags": [
                "login",
                "security",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_login', function ( $login, $user ) {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    update_user_meta( $user->ID, 'wpide_previous_login', get_user_meta( $user->ID, 'wpide_current_login', true ) );\n    update_user_meta( $user->ID, 'wpide_current_login', array( 'time' => time(), 'ip' => $ip ) );\n    update_user_meta( $user->ID, 'wpide_show_login_notice', 1 );\n}, 10, 2 );\n\nadd_action( 'admin_notices', function () {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id || ! get_user_meta( $user_id, 'wpide_show_login_notice', true ) ) {\n        return;\n    }\n\n    delete_user_meta( $user_id, 'wpide_show_login_notice' );\n    $previous = get_user_meta( $user_id, 'wpide_previous_login', true );\n\n    if ( empty( $previous['time'] ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-info is-dismissible\"><p>Welcome back. Your previous login was on %1$s from IP %2$s. Not you? <a href=\"%3$s\">Change your password</a>.</p></div>',\n        esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $previous['time'] ) ),\n        esc_html( $previous['ip'] ? $previous['ip'] : 'unknown' ),\n        esc_url( admin_url( 'profile.php#password' ) )\n    );\n} );",
            "requires": [],
            "notes": "Stores the IP address of the last two logins in user meta; mention it in your privacy policy."
        },
        {
            "id": "login-css-from-theme",
            "version": 1,
            "title": "Load a login.css File From Your Theme",
            "description": "Loads login.css from the active (child) theme folder on the login page, so you can style it with a regular stylesheet.",
            "category": "login",
            "tags": [
                "login",
                "css",
                "theme"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    $path = get_stylesheet_directory() . '/login.css';\n\n    if ( file_exists( $path ) ) {\n        wp_enqueue_style( 'wpide-theme-login', get_stylesheet_directory_uri() . '/login.css', array( 'login' ), (string) filemtime( $path ) );\n    }\n} );",
            "requires": [],
            "notes": "The file version updates automatically when you edit it, so browsers do not keep an old copy."
        },
        {
            "id": "login-field-placeholders",
            "version": 1,
            "title": "Placeholders in Login Form Fields",
            "description": "Adds hint text inside the username, password and email fields on the login, lost password and registration screens.",
            "category": "login",
            "tags": [
                "login",
                "form",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field ID => placeholder.\n$wpide_login_placeholders = array(\n    'user_login' => 'you@example.com',\n    'user_pass'  => 'Your password',\n    'user_email' => 'you@example.com',\n);\n\nadd_action( 'login_footer', function () use ( $wpide_login_placeholders ) {\n    ?>\n    <script>\n    ( function ( map ) {\n        Object.keys( map ).forEach( function ( id ) {\n            var field = document.getElementById( id );\n            if ( field && ! field.getAttribute( 'placeholder' ) ) {\n                field.setAttribute( 'placeholder', map[ id ] );\n            }\n        } );\n    } )( <?php echo wp_json_encode( $wpide_login_placeholders ); ?> );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-login-logo",
            "version": 1,
            "title": "Hide the Logo on the Login Page",
            "description": "Removes the logo above the login form entirely for a minimal login screen.",
            "category": "login",
            "tags": [
                "login",
                "logo",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_enqueue_scripts', function () {\n    wp_add_inline_style( 'login', '.login h1{display:none;} #login{padding-top:12vh;}' );\n} );",
            "requires": [],
            "notes": "Do not combine with a snippet that sets a custom login logo."
        },
        {
            "id": "post-type-counts-users-column",
            "version": 1,
            "title": "Custom Post Type Counts in the Users List",
            "description": "Adds a column per custom post type to the Users screen showing how many published items each user has, next to the built-in Posts count.",
            "category": "users",
            "tags": [
                "users",
                "columns",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types to count. Leave empty to use every public custom post type.\n$wpide_user_count_types = array();\n\n$wpide_user_count_types_resolved = function () use ( $wpide_user_count_types ) {\n    return $wpide_user_count_types ? $wpide_user_count_types : array_values( get_post_types( array( 'public' => true, '_builtin' => false ) ) );\n};\n\nadd_filter( 'manage_users_columns', function ( $columns ) use ( $wpide_user_count_types_resolved ) {\n    foreach ( $wpide_user_count_types_resolved() as $type ) {\n        $object = get_post_type_object( $type );\n        if ( $object ) {\n            $columns[ 'wpide_count_' . $type ] = esc_html( $object->labels->name );\n        }\n    }\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 0 !== strpos( $column, 'wpide_count_' ) ) {\n        return $output;\n    }\n\n    $type  = substr( $column, strlen( 'wpide_count_' ) );\n    $count = (int) count_user_posts( $user_id, $type, true );\n\n    if ( ! $count ) {\n        return '0';\n    }\n\n    return sprintf( '<a href=\"%s\">%d</a>', esc_url( admin_url( 'edit.php?post_type=' . $type . '&author=' . $user_id ) ), $count );\n}, 10, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "display-name-first-last",
            "version": 1,
            "title": "Use First and Last Name as the Display Name",
            "description": "Sets each user's public display name to \"First Last\" whenever both names are filled in, instead of showing their username.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "display-name"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_pre_insert_user_data', function ( $data, $update, $user_id, $userdata ) {\n    $first = isset( $userdata['first_name'] ) ? trim( $userdata['first_name'] ) : '';\n    $last  = isset( $userdata['last_name'] ) ? trim( $userdata['last_name'] ) : '';\n\n    if ( '' !== $first && '' !== $last ) {\n        $data['display_name'] = sanitize_text_field( $first . ' ' . $last );\n    }\n\n    return $data;\n}, 10, 4 );",
            "requires": [],
            "notes": "Applies when a user is created or their profile is saved; existing users change on their next profile update."
        },
        {
            "id": "remove-admin-color-scheme-picker",
            "version": 1,
            "title": "Remove the Admin Color Scheme Picker",
            "description": "Hides the Admin Color Scheme option on profile screens, keeping a consistent look for every user.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    remove_action( 'admin_color_scheme_picker', 'admin_color_scheme_picker' );\n} );",
            "requires": [],
            "notes": "Pair with a snippet that forces one color scheme if you want everyone on the same one."
        },
        {
            "id": "hide-unneeded-profile-fields",
            "version": 1,
            "title": "Hide Unneeded Profile Fields",
            "description": "Removes clutter from the profile screen, such as the website, biography, keyboard shortcuts and toolbar options, for non-administrators.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Profile rows to hide (CSS selectors on profile.php and user-edit.php).\n$wpide_hidden_profile_rows = array(\n    '.user-url-wrap',\n    '.user-description-wrap',\n    '.user-comment-shortcuts-wrap',\n    '.user-admin-bar-front-wrap',\n    '.user-rich-editing-wrap',\n    '.user-syntax-highlighting-wrap',\n    '#application-passwords-section',\n);\n\nadd_action( 'admin_head', function () use ( $wpide_hidden_profile_rows ) {\n    $screen = get_current_screen();\n\n    if ( ! $screen || ! in_array( $screen->id, array( 'profile', 'user-edit' ), true ) || current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    echo '<style>' . esc_html( implode( ',', $wpide_hidden_profile_rows ) ) . '{display:none !important;}</style>';\n} );",
            "requires": [],
            "notes": "Fields are hidden, not removed; their saved values are kept."
        },
        {
            "id": "editors-manage-users",
            "version": 1,
            "title": "Let Editors Manage Users (Except Administrators)",
            "description": "Allows editors to add, edit and remove users, while making sure they can never create administrators or touch administrator accounts.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "capabilities"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_user_caps = array( 'list_users', 'create_users', 'edit_users', 'delete_users', 'remove_users', 'promote_users' );\n\nadd_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) use ( $wpide_user_caps ) {\n    if ( in_array( 'editor', (array) $user->roles, true ) ) {\n        foreach ( $wpide_user_caps as $cap ) {\n            $allcaps[ $cap ] = true;\n        }\n    }\n\n    return $allcaps;\n}, 10, 4 );\n\n// Editors cannot hand out the administrator role.\nadd_filter( 'editable_roles', function ( $roles ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        unset( $roles['administrator'] );\n    }\n\n    return $roles;\n} );\n\n// Editors cannot edit, delete or demote administrators.\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) {\n    if ( ! in_array( $cap, array( 'edit_user', 'delete_user', 'remove_user', 'promote_user' ), true ) || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    if ( (int) $args[0] !== (int) $user_id && user_can( (int) $args[0], 'manage_options' ) && ! user_can( $user_id, 'manage_options' ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Capabilities are granted in memory only, so deactivating the snippet removes them again."
        },
        {
            "id": "editors-manage-menus-widgets",
            "version": 1,
            "title": "Let Editors Manage Menus and Widgets",
            "description": "Gives editors access to Appearance > Menus and Widgets without letting them switch themes or open the Customizer.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "menus"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) {\n    if ( in_array( 'editor', (array) $user->roles, true ) ) {\n        $allcaps['edit_theme_options'] = true;\n    }\n\n    return $allcaps;\n}, 10, 4 );\n\n// No Customizer for users who cannot switch themes.\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id ) {\n    if ( 'customize' === $cap && ! user_can( $user_id, 'switch_themes' ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 3 );\n\nadd_action( 'admin_menu', function () {\n    global $submenu;\n\n    if ( current_user_can( 'switch_themes' ) || empty( $submenu['themes.php'] ) ) {\n        return;\n    }\n\n    foreach ( $submenu['themes.php'] as $item ) {\n        if ( ! in_array( $item[2], array( 'nav-menus.php', 'widgets.php' ), true ) ) {\n            remove_submenu_page( 'themes.php', $item[2] );\n        }\n    }\n}, 999 );\n\nadd_action( 'load-themes.php', function () {\n    if ( ! current_user_can( 'switch_themes' ) ) {\n        wp_safe_redirect( admin_url( 'nav-menus.php' ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "On block themes, menus live in the Site Editor, which editors can also open with this capability."
        },
        {
            "id": "editors-edit-privacy-page",
            "version": 1,
            "title": "Let Editors Edit the Privacy Policy Page",
            "description": "WordPress only lets administrators edit the privacy policy page. This allows editors to update it too.",
            "category": "users",
            "tags": [
                "users",
                "privacy",
                "capabilities"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) {\n    if ( ! in_array( $cap, array( 'edit_post', 'edit_page', 'delete_post', 'delete_page' ), true ) || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $privacy_page = (int) get_option( 'wp_page_for_privacy_policy' );\n\n    if ( $privacy_page && (int) $args[0] === $privacy_page && in_array( 'manage_privacy_options', $caps, true ) && user_can( $user_id, 'edit_others_pages' ) ) {\n        $caps = array_diff( $caps, array( 'manage_privacy_options' ) );\n        $caps[] = 'edit_others_pages';\n    }\n\n    return array_values( $caps );\n}, 20, 4 );",
            "requires": [],
            "notes": "The Settings > Privacy screen itself stays administrator-only."
        },
        {
            "id": "registration-terms-checkbox",
            "version": 1,
            "title": "Terms and Privacy Checkbox on Registration",
            "description": "Adds a required \"I agree to the terms and privacy policy\" checkbox to the WordPress registration form and records when each user agreed.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// URL of your terms page (the privacy policy page is linked automatically when set).\n$wpide_terms_url = home_url( '/terms/' );\n\nadd_action( 'register_form', function () use ( $wpide_terms_url ) {\n    $privacy = get_privacy_policy_url();\n    ?>\n    <p style=\"margin-bottom:16px\">\n        <label>\n            <input type=\"checkbox\" name=\"wpide_accept_terms\" value=\"1\" <?php checked( ! empty( $_POST['wpide_accept_terms'] ) ); ?>>\n            I agree to the <a href=\"<?php echo esc_url( $wpide_terms_url ); ?>\" target=\"_blank\" rel=\"noopener\">terms of use</a>\n            <?php if ( $privacy ) : ?>and the <a href=\"<?php echo esc_url( $privacy ); ?>\" target=\"_blank\" rel=\"noopener\">privacy policy</a><?php endif; ?>.\n        </label>\n    </p>\n    <?php\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) {\n    if ( empty( $_POST['wpide_accept_terms'] ) ) {\n        $errors->add( 'wpide_terms', '<strong>Error:</strong> Please accept the terms to create an account.' );\n    }\n\n    return $errors;\n} );\n\nadd_action( 'user_register', function ( $user_id ) {\n    if ( ! empty( $_POST['wpide_accept_terms'] ) ) {\n        update_user_meta( $user_id, 'wpide_terms_accepted', current_time( 'mysql' ) );\n    }\n} );",
            "requires": [],
            "notes": "Covers the default wp-login.php registration form; shop or membership plugins have their own forms."
        },
        {
            "id": "registration-name-fields",
            "version": 1,
            "title": "First and Last Name on the Registration Form",
            "description": "Asks for first and last name when people register through the WordPress form and saves them to the new profile.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "profile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'register_form', function () {\n    $first = isset( $_POST['first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['first_name'] ) ) : '';\n    $last  = isset( $_POST['last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['last_name'] ) ) : '';\n    ?>\n    <p>\n        <label for=\"wpide_first_name\">First name</label>\n        <input type=\"text\" name=\"first_name\" id=\"wpide_first_name\" class=\"input\" value=\"<?php echo esc_attr( $first ); ?>\" autocomplete=\"given-name\" required>\n    </p>\n    <p>\n        <label for=\"wpide_last_name\">Last name</label>\n        <input type=\"text\" name=\"last_name\" id=\"wpide_last_name\" class=\"input\" value=\"<?php echo esc_attr( $last ); ?>\" autocomplete=\"family-name\" required>\n    </p>\n    <?php\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) {\n    if ( empty( $_POST['first_name'] ) || empty( $_POST['last_name'] ) ) {\n        $errors->add( 'wpide_names', '<strong>Error:</strong> Please enter your first and last name.' );\n    }\n\n    return $errors;\n} );\n\nadd_action( 'user_register', function ( $user_id ) {\n    if ( ! empty( $_POST['first_name'] ) && ! empty( $_POST['last_name'] ) && 'wp-login.php' === ( isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '' ) ) {\n        update_user_meta( $user_id, 'first_name', sanitize_text_field( wp_unslash( $_POST['first_name'] ) ) );\n        update_user_meta( $user_id, 'last_name', sanitize_text_field( wp_unslash( $_POST['last_name'] ) ) );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-disposable-email-registration",
            "version": 1,
            "title": "Block Disposable Email Addresses on Registration",
            "description": "Rejects registrations from throwaway email services, which are a common source of fake and spam accounts.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Domains to reject. Add more as you see them in your users list.\n$wpide_disposable_domains = array(\n    'mailinator.com', 'guerrillamail.com', 'guerrillamail.net', '10minutemail.com', 'tempmail.com', 'temp-mail.org',\n    'yopmail.com', 'trashmail.com', 'getnada.com', 'sharklasers.com', 'dispostable.com', 'maildrop.cc',\n    'throwawaymail.com', 'fakeinbox.com', 'mintemail.com', 'spamgourmet.com', 'emailondeck.com', 'moakt.com',\n);\n\nadd_filter( 'registration_errors', function ( $errors, $login, $email ) use ( $wpide_disposable_domains ) {\n    $domain = strtolower( (string) substr( strrchr( (string) $email, '@' ), 1 ) );\n\n    foreach ( $wpide_disposable_domains as $blocked ) {\n        if ( $domain === $blocked || ( strlen( $domain ) > strlen( $blocked ) && substr( $domain, -strlen( $blocked ) - 1 ) === '.' . $blocked ) ) {\n            $errors->add( 'wpide_disposable_email', '<strong>Error:</strong> Please use a permanent email address.' );\n            break;\n        }\n    }\n\n    return $errors;\n}, 10, 3 );",
            "requires": [],
            "notes": "Subdomains of listed domains are blocked as well."
        },
        {
            "id": "registration-allowed-email-domains",
            "version": 1,
            "title": "Only Allow Registration From Certain Email Domains",
            "description": "Limits sign-ups to email addresses at the domains you list, for example your company or school domain.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "intranet"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Email domains that may register.\n$wpide_allowed_email_domains = array( 'example.com', 'example.org' );\n\nadd_filter( 'registration_errors', function ( $errors, $login, $email ) use ( $wpide_allowed_email_domains ) {\n    $domain = strtolower( (string) substr( strrchr( (string) $email, '@' ), 1 ) );\n\n    if ( ! in_array( $domain, array_map( 'strtolower', $wpide_allowed_email_domains ), true ) ) {\n        $errors->add( 'wpide_email_domain', '<strong>Error:</strong> Registration is limited to approved email addresses.' );\n    }\n\n    return $errors;\n}, 10, 3 );",
            "requires": [],
            "notes": "Administrators can still create users with any email address from the Users screen."
        },
        {
            "id": "approve-new-users",
            "version": 1,
            "title": "Approve New Users Before They Can Log In",
            "description": "New sign-ups wait for an administrator to approve them on the Users screen before they can log in, and get an email once approved.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that need approval when they sign up (e.g. leave out \"customer\" so shop buyers are not blocked).\n$wpide_approval_roles = array( 'subscriber' );\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_approval_roles ) {\n    if ( current_user_can( 'create_users' ) ) {\n        return; // Created by an administrator (admin screen, REST API or WP-CLI).\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( $user && array_intersect( (array) $user->roles, $wpide_approval_roles ) ) {\n        update_user_meta( $user_id, 'wpide_pending_approval', 1 );\n    }\n} );\n\nadd_filter( 'authenticate', function ( $user ) {\n    if ( $user instanceof WP_User && get_user_meta( $user->ID, 'wpide_pending_approval', true ) ) {\n        return new WP_Error( 'wpide_pending_approval', '<strong>Your account is waiting for approval.</strong> You will get an email as soon as it is approved.' );\n    }\n\n    return $user;\n}, 50 );\n\nadd_filter( 'login_message', function ( $message ) {\n    if ( isset( $_GET['checkemail'] ) && 'registered' === $_GET['checkemail'] ) {\n        $message .= '<p class=\"message\">An administrator needs to approve your account before you can log in.</p>';\n    }\n\n    return $message;\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_approval'] = 'Approval';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_approval' !== $column ) {\n        return $output;\n    }\n\n    if ( ! get_user_meta( $user_id, 'wpide_pending_approval', true ) ) {\n        return 'Approved';\n    }\n\n    $url = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_approve_user&user_id=' . (int) $user_id ), 'wpide_approve_user_' . (int) $user_id );\n\n    return '<strong style=\"color:#b32d2e\">Pending</strong><br><a href=\"' . esc_url( $url ) . '\">Approve</a>';\n}, 10, 3 );\n\nadd_action( 'admin_post_wpide_approve_user', function () {\n    $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;\n\n    if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) ) {\n        wp_die( 'You are not allowed to approve this user.' );\n    }\n\n    check_admin_referer( 'wpide_approve_user_' . $user_id );\n    delete_user_meta( $user_id, 'wpide_pending_approval' );\n\n    $user = get_userdata( $user_id );\n    if ( $user ) {\n        wp_mail(\n            $user->user_email,\n            sprintf( '[%s] Your account has been approved', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n            sprintf( \"Hello %s,\\n\\nYour account is approved. You can log in here:\\n%s\", $user->display_name, wp_login_url() )\n        );\n    }\n\n    wp_safe_redirect( admin_url( 'users.php' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Users who registered before activation are treated as approved."
        },
        {
            "id": "initials-avatars",
            "version": 1,
            "title": "Initials Avatars Instead of Gravatar",
            "description": "Replaces Gravatar images with colored circles showing each person's initials, generated locally, so no visitor data is sent to Gravatar.",
            "category": "users",
            "tags": [
                "users",
                "avatars",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_get_avatar', function ( $html, $id_or_email, $args ) {\n    if ( null !== $html || ( empty( $args['force_display'] ) && ! get_option( 'show_avatars' ) ) ) {\n        return $html;\n    }\n\n    $name = '';\n\n    if ( $id_or_email instanceof WP_Comment ) {\n        $name = $id_or_email->user_id ? get_the_author_meta( 'display_name', $id_or_email->user_id ) : $id_or_email->comment_author;\n    } elseif ( $id_or_email instanceof WP_User ) {\n        $name = $id_or_email->display_name;\n    } elseif ( $id_or_email instanceof WP_Post ) {\n        $name = get_the_author_meta( 'display_name', $id_or_email->post_author );\n    } elseif ( is_numeric( $id_or_email ) ) {\n        $name = get_the_author_meta( 'display_name', (int) $id_or_email );\n    } elseif ( is_string( $id_or_email ) ) {\n        $user = get_user_by( 'email', $id_or_email );\n        $name = $user ? $user->display_name : strtok( $id_or_email, '@' );\n    }\n\n    $name     = trim( (string) $name );\n    $parts    = preg_split( '/[\\s._-]+/u', $name, -1, PREG_SPLIT_NO_EMPTY );\n    $initials = '';\n\n    foreach ( array_slice( (array) $parts, 0, 2 ) as $part ) {\n        $initials .= function_exists( 'mb_substr' ) ? mb_strtoupper( mb_substr( $part, 0, 1 ) ) : strtoupper( substr( $part, 0, 1 ) );\n    }\n\n    $initials = '' !== $initials ? $initials : '?';\n    $hue      = abs( crc32( $name ) ) % 360;\n    $width    = (int) ( ! empty( $args['width'] ) ? $args['width'] : $args['size'] );\n    $height   = (int) ( ! empty( $args['height'] ) ? $args['height'] : $args['size'] );\n\n    $svg = sprintf(\n        '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100\" height=\"100\" viewBox=\"0 0 100 100\"><rect width=\"100\" height=\"100\" fill=\"hsl(%d,45%%,45%%)\"/><text x=\"50\" y=\"50\" dy=\".35em\" text-anchor=\"middle\" font-family=\"Arial,Helvetica,sans-serif\" font-size=\"42\" fill=\"#fff\">%s</text></svg>',\n        $hue,\n        htmlspecialchars( $initials, ENT_QUOTES | ENT_XML1, 'UTF-8' )\n    );\n\n    $class = array( 'avatar', 'avatar-' . (int) $args['size'], 'photo' );\n    if ( ! empty( $args['class'] ) ) {\n        $class = array_merge( $class, (array) $args['class'] );\n    }\n\n    return sprintf(\n        '<img alt=\"%1$s\" src=\"%2$s\" class=\"%3$s\" height=\"%4$d\" width=\"%5$d\" %6$s>',\n        esc_attr( $args['alt'] ),\n        esc_attr( 'data:image/svg+xml;base64,' . base64_encode( $svg ) ),\n        esc_attr( implode( ' ', $class ) ),\n        $height,\n        $width,\n        isset( $args['extra_attr'] ) ? $args['extra_attr'] : ''\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Avatar URLs in the REST API still point to Gravatar; only images shown on your pages change."
        },
        {
            "id": "custom-default-avatar",
            "version": 1,
            "title": "Your Own Image as the Default Avatar",
            "description": "Adds your own image to the Default Avatar choices under Settings > Discussion, used for people without a Gravatar.",
            "category": "users",
            "tags": [
                "users",
                "avatars",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Full URL of the default avatar image (square, at least 256px).\n$wpide_default_avatar_url  = 'https://example.com/wp-content/uploads/default-avatar.png';\n$wpide_default_avatar_name = 'Site avatar';\n\nadd_filter( 'avatar_defaults', function ( $defaults ) use ( $wpide_default_avatar_url, $wpide_default_avatar_name ) {\n    $defaults[ $wpide_default_avatar_url ] = $wpide_default_avatar_name;\n\n    return $defaults;\n} );",
            "requires": [],
            "notes": "After activating, choose the new avatar in Settings > Discussion (Gravatar must be able to reach the image URL)."
        },
        {
            "id": "profile-picture-from-media-library",
            "version": 1,
            "title": "Profile Picture From the Media Library",
            "description": "Lets users pick a profile picture from the Media Library on their profile screen; it replaces their Gravatar everywhere.",
            "category": "users",
            "tags": [
                "users",
                "avatars",
                "profile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_enqueue_scripts', function ( $hook ) {\n    if ( in_array( $hook, array( 'profile.php', 'user-edit.php' ), true ) && current_user_can( 'upload_files' ) ) {\n        wp_enqueue_media();\n    }\n} );\n\n$wpide_avatar_field = function ( $user ) {\n    if ( ! current_user_can( 'upload_files' ) ) {\n        return;\n    }\n\n    $image_id = (int) get_user_meta( $user->ID, 'wpide_avatar_id', true );\n    ?>\n    <h2>Profile Picture</h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\">Picture</th>\n            <td>\n                <div id=\"wpide-avatar-preview\"><?php echo $image_id ? wp_get_attachment_image( $image_id, array( 96, 96 ) ) : ''; ?></div>\n                <input type=\"hidden\" name=\"wpide_avatar_id\" id=\"wpide-avatar-id\" value=\"<?php echo esc_attr( $image_id ); ?>\">\n                <button type=\"button\" class=\"button\" id=\"wpide-avatar-choose\">Choose image</button>\n                <button type=\"button\" class=\"button-link\" id=\"wpide-avatar-remove\">Remove</button>\n            </td>\n        </tr>\n    </table>\n    <script>\n    jQuery( function ( $ ) {\n        var frame;\n        $( '#wpide-avatar-choose' ).on( 'click', function () {\n            frame = frame || wp.media( { title: 'Profile picture', library: { type: 'image' }, multiple: false } );\n            frame.off( 'select' ).on( 'select', function () {\n                var image = frame.state().get( 'selection' ).first().toJSON();\n                var url = image.sizes && image.sizes.thumbnail ? image.sizes.thumbnail.url : image.url;\n                $( '#wpide-avatar-id' ).val( image.id );\n                $( '#wpide-avatar-preview' ).html( $( '<img>', { src: url, width: 96, height: 96 } ) );\n            } );\n            frame.open();\n        } );\n        $( '#wpide-avatar-remove' ).on( 'click', function () {\n            $( '#wpide-avatar-id' ).val( '' );\n            $( '#wpide-avatar-preview' ).empty();\n        } );\n    } );\n    </script>\n    <?php\n};\n\nadd_action( 'show_user_profile', $wpide_avatar_field );\nadd_action( 'edit_user_profile', $wpide_avatar_field );\n\n// WordPress verifies the profile form nonce before these actions run.\n$wpide_avatar_save = function ( $user_id ) {\n    if ( ! current_user_can( 'edit_user', $user_id ) || ! current_user_can( 'upload_files' ) || ! isset( $_POST['wpide_avatar_id'] ) ) {\n        return;\n    }\n\n    $image_id = absint( $_POST['wpide_avatar_id'] );\n\n    if ( $image_id && wp_attachment_is_image( $image_id ) ) {\n        update_user_meta( $user_id, 'wpide_avatar_id', $image_id );\n    } else {\n        delete_user_meta( $user_id, 'wpide_avatar_id' );\n    }\n};\n\nadd_action( 'personal_options_update', $wpide_avatar_save );\nadd_action( 'edit_user_profile_update', $wpide_avatar_save );\n\nadd_filter( 'pre_get_avatar_data', function ( $args, $id_or_email ) {\n    $user_id = 0;\n\n    if ( is_numeric( $id_or_email ) ) {\n        $user_id = (int) $id_or_email;\n    } elseif ( $id_or_email instanceof WP_User ) {\n        $user_id = $id_or_email->ID;\n    } elseif ( $id_or_email instanceof WP_Post ) {\n        $user_id = (int) $id_or_email->post_author;\n    } elseif ( $id_or_email instanceof WP_Comment ) {\n        $user_id = (int) $id_or_email->user_id;\n    } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) {\n        $user    = get_user_by( 'email', $id_or_email );\n        $user_id = $user ? $user->ID : 0;\n    }\n\n    $image_id = $user_id ? (int) get_user_meta( $user_id, 'wpide_avatar_id', true ) : 0;\n    $url      = $image_id ? wp_get_attachment_image_url( $image_id, array( (int) $args['size'], (int) $args['size'] ) ) : false;\n\n    if ( $url ) {\n        $args['url']          = $url;\n        $args['found_avatar'] = true;\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only users who can upload files (authors and up) see the picker; administrators can set pictures for anyone."
        },
        {
            "id": "lock-email-for-non-admins",
            "version": 1,
            "title": "Stop Users From Changing Their Email Address",
            "description": "Makes the email field read-only for everyone except administrators, for sites where the email is tied to a contract or a login policy.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'user_profile_update_errors', function ( $errors, $update, $user ) {\n    if ( ! $update || current_user_can( 'manage_options' ) || empty( $user->ID ) ) {\n        return;\n    }\n\n    $current = get_userdata( $user->ID );\n\n    if ( $current && isset( $user->user_email ) && strtolower( $user->user_email ) !== strtolower( $current->user_email ) ) {\n        $user->user_email = $current->user_email;\n        $errors->add( 'wpide_email_locked', '<strong>Error:</strong> Your email address can only be changed by an administrator.' );\n    }\n}, 10, 3 );\n\nadd_action( 'admin_footer', function () {\n    $screen = get_current_screen();\n\n    if ( ! $screen || 'profile' !== $screen->id || current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    echo '<script>(function(){var f=document.getElementById(\"email\");if(f){f.readOnly=true;f.insertAdjacentHTML(\"afterend\",\"<p class=\\\"description\\\">Contact an administrator to change your email address.</p>\");}})();</script>';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "allow-html-in-author-bios",
            "version": 1,
            "title": "Allow HTML in Author Bios",
            "description": "Keeps links and basic formatting in the Biographical Info field for users who are trusted with unfiltered HTML, instead of stripping it.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( current_user_can( 'unfiltered_html' ) ) {\n        remove_filter( 'pre_user_description', 'wp_filter_kses' );\n        add_filter( 'pre_user_description', 'wp_filter_post_kses' );\n    }\n} );",
            "requires": [],
            "notes": "Bios are filtered with the same rules as post content, so scripts are still removed."
        },
        {
            "id": "site-manager-role",
            "version": 1,
            "title": "Add a Site Manager Role (No Database Changes)",
            "description": "Adds a \"Site Manager\" role with everything an editor can do plus managing users and menus, defined in code so nothing is written to the database.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_add_site_manager = function ( $wp_roles ) {\n    if ( isset( $wp_roles->roles['site_manager'] ) ) {\n        return;\n    }\n\n    $editor = $wp_roles->get_role( 'editor' );\n    $caps   = array_merge(\n        $editor ? $editor->capabilities : array( 'read' => true ),\n        array(\n            'list_users'         => true,\n            'create_users'       => true,\n            'edit_users'         => true,\n            'delete_users'       => true,\n            'promote_users'      => true,\n            'edit_theme_options' => true,\n        )\n    );\n\n    $wp_roles->roles['site_manager']        = array( 'name' => 'Site Manager', 'capabilities' => $caps );\n    $wp_roles->role_objects['site_manager'] = new WP_Role( 'site_manager', $caps );\n    $wp_roles->role_names['site_manager']   = 'Site Manager';\n};\n\nadd_action( 'wp_roles_init', $wpide_add_site_manager );\n\nif ( did_action( 'wp_roles_init' ) ) {\n    $wpide_add_site_manager( wp_roles() );\n}\n\n// Site managers cannot create administrators or change administrator accounts.\nadd_filter( 'editable_roles', function ( $roles ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        unset( $roles['administrator'] );\n    }\n\n    return $roles;\n} );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) {\n    if ( in_array( $cap, array( 'edit_user', 'delete_user', 'remove_user', 'promote_user' ), true ) && ! empty( $args[0] ) && (int) $args[0] !== (int) $user_id && user_can( (int) $args[0], 'manage_options' ) && ! user_can( $user_id, 'manage_options' ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Users given this role lose their permissions while the snippet is inactive; reassign them before removing it."
        },
        {
            "id": "hide-administrator-role-option",
            "version": 1,
            "title": "Hide the Administrator Role From Non-Administrators",
            "description": "Removes Administrator from the role dropdowns for anyone who is not an administrator, so delegated user managers cannot promote themselves or others.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'editable_roles', function ( $roles ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        unset( $roles['administrator'] );\n    }\n\n    return $roles;\n} );",
            "requires": [],
            "notes": "Useful whenever you give a non-administrator the ability to manage users."
        },
        {
            "id": "active-sessions-users-column",
            "version": 1,
            "title": "Active Sessions Column With \"Log Out Everywhere\"",
            "description": "Shows how many devices each user is logged in on, with a link to end all of that user's sessions from the Users screen.",
            "category": "users",
            "tags": [
                "users",
                "sessions",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_sessions'] = 'Sessions';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_sessions' !== $column ) {\n        return $output;\n    }\n\n    $count = count( WP_Session_Tokens::get_instance( $user_id )->get_all() );\n\n    if ( ! $count ) {\n        return '0';\n    }\n\n    $output = (string) $count;\n\n    if ( current_user_can( 'edit_user', $user_id ) && get_current_user_id() !== (int) $user_id ) {\n        $url     = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_end_sessions&user_id=' . (int) $user_id ), 'wpide_end_sessions_' . (int) $user_id );\n        $output .= '<br><a href=\"' . esc_url( $url ) . '\">Log out everywhere</a>';\n    }\n\n    return $output;\n}, 10, 3 );\n\nadd_action( 'admin_post_wpide_end_sessions', function () {\n    $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;\n\n    if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) ) {\n        wp_die( 'You are not allowed to do this.' );\n    }\n\n    check_admin_referer( 'wpide_end_sessions_' . $user_id );\n    WP_Session_Tokens::get_instance( $user_id )->destroy_all();\n\n    wp_safe_redirect( add_query_arg( 'wpide_sessions_ended', 1, admin_url( 'users.php' ) ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_sessions_ended'] ) ) {\n        echo '<div class=\"notice notice-success is-dismissible\"><p>The user was logged out on all devices.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "registration-ip-column",
            "version": 1,
            "title": "Record and Show the Registration IP Address",
            "description": "Saves the IP address each new account was created from and shows it in the Users list, which helps spot bulk fake sign-ups.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'user_register', function ( $user_id ) {\n    if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {\n        $ip = filter_var( wp_unslash( $_SERVER['REMOTE_ADDR'] ), FILTER_VALIDATE_IP );\n        if ( $ip ) {\n            update_user_meta( $user_id, 'wpide_registration_ip', $ip );\n        }\n    }\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        $columns['wpide_reg_ip'] = 'Registration IP';\n    }\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_reg_ip' === $column ) {\n        $ip = get_user_meta( $user_id, 'wpide_registration_ip', true );\n\n        return $ip ? '<code>' . esc_html( $ip ) . '</code>' : '&#8212;';\n    }\n\n    return $output;\n}, 10, 3 );",
            "requires": [],
            "notes": "IP addresses are personal data, so mention them in your privacy policy (behind a proxy you may see the proxy address)."
        },
        {
            "id": "export-users-csv",
            "version": 1,
            "title": "Export Users to CSV",
            "description": "Adds an \"Export CSV\" button to the Users screen that downloads users with their name, email, role and registration date.",
            "category": "users",
            "tags": [
                "users",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'manage_users_extra_tablenav', function ( $which ) {\n    if ( 'top' !== $which || ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    $role = isset( $_GET['role'] ) ? sanitize_key( $_GET['role'] ) : '';\n    $url  = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_export_users&role=' . $role ), 'wpide_export_users' );\n\n    echo '<div class=\"alignleft actions\"><a class=\"button\" href=\"' . esc_url( $url ) . '\">Export CSV</a></div>';\n} );\n\nadd_action( 'admin_post_wpide_export_users', function () {\n    if ( ! current_user_can( 'list_users' ) ) {\n        wp_die( 'You are not allowed to export users.' );\n    }\n\n    check_admin_referer( 'wpide_export_users' );\n\n    $role  = isset( $_GET['role'] ) ? sanitize_key( $_GET['role'] ) : '';\n    $users = get_users( array( 'role' => $role, 'orderby' => 'registered', 'order' => 'ASC' ) );\n    $safe  = function ( $value ) {\n        $value = (string) $value;\n\n        return preg_match( '/^[=+\\-@\\t\\r]/', $value ) ? \"'\" . $value : $value;\n    };\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=users-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fputcsv( $out, array( 'ID', 'Username', 'Email', 'Display name', 'First name', 'Last name', 'Roles', 'Registered' ) );\n\n    foreach ( $users as $user ) {\n        fputcsv( $out, array_map( $safe, array(\n            $user->ID,\n            $user->user_login,\n            $user->user_email,\n            $user->display_name,\n            $user->first_name,\n            $user->last_name,\n            implode( ', ', (array) $user->roles ),\n            $user->user_registered,\n        ) ) );\n    }\n\n    fclose( $out );\n    exit;\n} );",
            "requires": [],
            "notes": "When a role filter is active on the Users screen, only that role is exported."
        },
        {
            "id": "phone-company-profile-fields",
            "version": 1,
            "title": "Phone and Company Fields in User Profiles",
            "description": "Adds Phone and Company fields to user profiles, editable by the user and by administrators.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Meta key => label.\n$wpide_profile_fields = array(\n    'wpide_phone'   => 'Phone',\n    'wpide_company' => 'Company',\n);\n\n$wpide_profile_fields_render = function ( $user ) use ( $wpide_profile_fields ) {\n    echo '<h2>Contact Details</h2><table class=\"form-table\" role=\"presentation\">';\n    foreach ( $wpide_profile_fields as $key => $label ) {\n        printf(\n            '<tr><th><label for=\"%1$s\">%2$s</label></th><td><input type=\"text\" class=\"regular-text\" name=\"%1$s\" id=\"%1$s\" value=\"%3$s\"></td></tr>',\n            esc_attr( $key ),\n            esc_html( $label ),\n            esc_attr( get_user_meta( $user->ID, $key, true ) )\n        );\n    }\n    echo '</table>';\n};\n\nadd_action( 'show_user_profile', $wpide_profile_fields_render );\nadd_action( 'edit_user_profile', $wpide_profile_fields_render );\n\n// WordPress verifies the profile form nonce before these actions run.\n$wpide_profile_fields_save = function ( $user_id ) use ( $wpide_profile_fields ) {\n    if ( ! current_user_can( 'edit_user', $user_id ) ) {\n        return;\n    }\n\n    foreach ( array_keys( $wpide_profile_fields ) as $key ) {\n        if ( isset( $_POST[ $key ] ) ) {\n            update_user_meta( $user_id, $key, sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) );\n        }\n    }\n};\n\nadd_action( 'personal_options_update', $wpide_profile_fields_save );\nadd_action( 'edit_user_profile_update', $wpide_profile_fields_save );",
            "requires": [],
            "notes": "Read a value in templates with get_user_meta( $user_id, 'wpide_phone', true )."
        },
        {
            "id": "last-post-date-users-column",
            "version": 1,
            "title": "Last Post Date Column in the Users List",
            "description": "Shows when each user last published a post, which makes inactive authors easy to find.",
            "category": "users",
            "tags": [
                "users",
                "columns",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_last_post'] = 'Last post';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_last_post' !== $column ) {\n        return $output;\n    }\n\n    $posts = get_posts( array(\n        'author'        => $user_id,\n        'numberposts'   => 1,\n        'post_status'   => 'publish',\n        'orderby'       => 'date',\n        'no_found_rows' => true,\n    ) );\n\n    if ( ! $posts ) {\n        return '&#8212;';\n    }\n\n    return sprintf(\n        '<a href=\"%1$s\">%2$s</a>',\n        esc_url( get_permalink( $posts[0] ) ),\n        esc_html( get_the_date( '', $posts[0] ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "protect-users-from-changes",
            "version": 1,
            "title": "Protect Specific Users From Editing and Deletion",
            "description": "Stops anyone but the users themselves from editing, demoting or deleting the accounts you list, such as the site owner or an agency account.",
            "category": "users",
            "tags": [
                "users",
                "protection",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IDs of protected users. Find an ID by hovering the user's name on the Users screen.\n$wpide_protected_users = array( 1 );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_protected_users ) {\n    if ( ! in_array( $cap, array( 'edit_user', 'delete_user', 'remove_user', 'promote_user' ), true ) || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $target = (int) $args[0];\n\n    if ( in_array( $target, array_map( 'intval', $wpide_protected_users ), true ) && $target !== (int) $user_id ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Protected users can still edit their own profile."
        },
        {
            "id": "reserved-usernames",
            "version": 1,
            "title": "Block Reserved Usernames",
            "description": "Prevents anyone from registering or being created with usernames like admin, support or root that look official or invite attacks.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Usernames nobody may use (case-insensitive).\n$wpide_reserved_usernames = array( 'admin', 'administrator', 'root', 'support', 'help', 'webmaster', 'info', 'test', 'sysadmin', 'wordpress', 'owner', 'staff' );\n\nadd_filter( 'illegal_user_logins', function ( $logins ) use ( $wpide_reserved_usernames ) {\n    return array_unique( array_merge( (array) $logins, $wpide_reserved_usernames ) );\n} );",
            "requires": [],
            "notes": "Existing users with these names are not affected."
        },
        {
            "id": "email-on-role-change",
            "version": 1,
            "title": "Email Users When Their Role Changes",
            "description": "Sends users a short email when an administrator changes their role, for example when a subscriber becomes an author.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'set_user_role', function ( $user_id, $role, $old_roles ) {\n    if ( empty( $old_roles ) || in_array( $role, (array) $old_roles, true ) ) {\n        return; // New registrations and unchanged roles.\n    }\n\n    $user  = get_userdata( $user_id );\n    $names = wp_roles()->get_names();\n\n    if ( ! $user || empty( $names[ $role ] ) ) {\n        return;\n    }\n\n    $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n\n    wp_mail(\n        $user->user_email,\n        sprintf( '[%s] Your role has changed', $site ),\n        sprintf( \"Hello %s,\\n\\nYour role on %s is now: %s.\\n\\nLog in: %s\", $user->display_name, $site, translate_user_role( $names[ $role ] ), wp_login_url() )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "private-admin-notes-on-users",
            "version": 1,
            "title": "Private Admin Notes on User Profiles",
            "description": "Adds a notes box to user profiles that only administrators can see and edit, for things like support history or account details.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_user_notes_render = function ( $user ) {\n    if ( ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n    ?>\n    <h2>Admin Notes</h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th><label for=\"wpide_admin_notes\">Private notes</label></th>\n            <td>\n                <textarea name=\"wpide_admin_notes\" id=\"wpide_admin_notes\" rows=\"5\" class=\"large-text\"><?php echo esc_textarea( get_user_meta( $user->ID, 'wpide_admin_notes', true ) ); ?></textarea>\n                <p class=\"description\">Only visible to users who can edit users.</p>\n            </td>\n        </tr>\n    </table>\n    <?php\n};\n\nadd_action( 'show_user_profile', $wpide_user_notes_render );\nadd_action( 'edit_user_profile', $wpide_user_notes_render );\n\n// WordPress verifies the profile form nonce before these actions run.\n$wpide_user_notes_save = function ( $user_id ) {\n    if ( current_user_can( 'edit_users' ) && current_user_can( 'edit_user', $user_id ) && isset( $_POST['wpide_admin_notes'] ) ) {\n        update_user_meta( $user_id, 'wpide_admin_notes', sanitize_textarea_field( wp_unslash( $_POST['wpide_admin_notes'] ) ) );\n    }\n};\n\nadd_action( 'personal_options_update', $wpide_user_notes_save );\nadd_action( 'edit_user_profile_update', $wpide_user_notes_save );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "authors-submit-for-review",
            "version": 1,
            "title": "Make Authors Submit Posts for Review",
            "description": "Takes away the authors' right to publish, so their posts go to Pending Review for an editor to approve.",
            "category": "users",
            "tags": [
                "users",
                "roles",
                "workflow"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that must submit for review instead of publishing.\n$wpide_review_roles = array( 'author' );\n\nadd_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) use ( $wpide_review_roles ) {\n    if ( array_intersect( (array) $user->roles, $wpide_review_roles ) ) {\n        $allcaps['publish_posts'] = false;\n    }\n\n    return $allcaps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Authors can still edit their already published posts; add a snippet to restrict that if needed."
        },
        {
            "id": "users-online-admin-bar",
            "version": 1,
            "title": "Users Online Count in the Admin Bar",
            "description": "Shows administrators how many logged-in users were active in the last five minutes, with their names in a dropdown.",
            "category": "users",
            "tags": [
                "users",
                "admin-bar",
                "activity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minutes a user counts as online after their last page view.\n$wpide_online_window = 5;\n\nadd_action( 'init', function () use ( $wpide_online_window ) {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id || wp_doing_cron() || wp_doing_ajax() ) {\n        return;\n    }\n\n    $online = get_transient( 'wpide_users_online' );\n    $online = is_array( $online ) ? $online : array();\n    $now    = time();\n\n    if ( isset( $online[ $user_id ] ) && $now - $online[ $user_id ] < MINUTE_IN_SECONDS ) {\n        return; // Updated less than a minute ago.\n    }\n\n    $online[ $user_id ] = $now;\n    $online = array_filter( $online, function ( $time ) use ( $now, $wpide_online_window ) {\n        return $now - $time < $wpide_online_window * MINUTE_IN_SECONDS;\n    } );\n\n    set_transient( 'wpide_users_online', $online, HOUR_IN_SECONDS );\n} );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_online_window ) {\n    if ( ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    $online = get_transient( 'wpide_users_online' );\n    $online = is_array( $online ) ? $online : array();\n    $now    = time();\n    $online = array_filter( $online, function ( $time ) use ( $now, $wpide_online_window ) {\n        return $now - $time < $wpide_online_window * MINUTE_IN_SECONDS;\n    } );\n\n    $wp_admin_bar->add_node( array(\n        'id'     => 'wpide-online',\n        'parent' => 'top-secondary',\n        'title'  => sprintf( '%d online', count( $online ) ),\n    ) );\n\n    foreach ( array_slice( array_keys( $online ), 0, 20 ) as $user_id ) {\n        $user = get_userdata( $user_id );\n        if ( $user ) {\n            $wp_admin_bar->add_node( array(\n                'id'     => 'wpide-online-' . $user_id,\n                'parent' => 'wpide-online',\n                'title'  => esc_html( $user->display_name ),\n                'href'   => get_edit_user_link( $user_id ),\n            ) );\n        }\n    }\n}, 5 );",
            "requires": [],
            "notes": "Writes one small transient at most once a minute per logged-in user."
        },
        {
            "id": "membership-signup-prompt",
            "version": 1,
            "title": "Sign-Up Prompt Near the End of Posts",
            "description": "Invites logged-out readers to create a free account three quarters of the way through each article, when they are most engaged.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "call-to-action"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_quarter_third",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() && get_option( 'users_can_register' ) ) : ?>\n<aside class=\"wpide-signup-prompt\" style=\"margin:2em 0;padding:1.25em;border-radius:8px;background:rgba(127,127,127,.08);text-align:center\">\n    <p style=\"margin:0 0 .75em\"><strong>Enjoying this article?</strong> Create a free account to get new posts and join the discussion.</p>\n    <a class=\"wp-element-button\" href=\"<?php echo esc_url( wp_registration_url() ); ?>\">Create my account</a>\n</aside>\n<?php endif; ?>",
            "requires": [],
            "notes": "Only shows when \"Anyone can register\" is enabled in Settings > General."
        },
        {
            "id": "woo-thankyou-create-account",
            "version": 1,
            "title": "Create-Account Invitation on the Order Thank-You Page",
            "description": "Suggests to guest buyers on the WooCommerce thank-you page that they create an account to track this and future orders.",
            "category": "users",
            "tags": [
                "users",
                "woocommerce",
                "accounts"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "woocommerce_before_thankyou",
            "insert_number": 1,
            "code": "<?php if ( ! is_user_logged_in() && function_exists( 'wc_get_page_permalink' ) && 'yes' === get_option( 'woocommerce_enable_myaccount_registration' ) ) : ?>\n<div class=\"woocommerce-info wpide-thankyou-account\">\n    Want to track this order and check out faster next time?\n    <a href=\"<?php echo esc_url( wc_get_page_permalink( 'myaccount' ) ); ?>\">Create an account</a> with the same email address.\n</div>\n<?php endif; ?>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only appears when account creation on the My Account page is enabled in WooCommerce settings."
        },
        {
            "id": "memberpress-subscription-help",
            "version": 1,
            "title": "Help Note Above MemberPress Subscriptions",
            "description": "Adds a short note above the subscriptions list in the MemberPress account page explaining how to change plans or get billing help.",
            "category": "users",
            "tags": [
                "users",
                "memberpress",
                "billing"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "mepr_before_account_subscriptions",
            "insert_number": 1,
            "code": "<div class=\"wpide-mepr-billing-help\" style=\"margin:0 0 1em;padding:.75em 1em;border-left:4px solid #2271b1;background:rgba(34,113,177,.06)\">\n    Need to upgrade, downgrade or update your card? Use the links next to your subscription below,\n    or email <a href=\"mailto:billing@example.com\">billing@example.com</a> and we will help.\n</div>",
            "requires": [
                "memberpress"
            ],
            "notes": "Replace the email address with your own."
        },
        {
            "id": "memberpress-profile-reminder",
            "version": 1,
            "title": "Profile Reminder on the MemberPress Account Page",
            "description": "Reminds members who have not filled in their first and last name to complete their profile, at the top of the MemberPress account page.",
            "category": "users",
            "tags": [
                "users",
                "memberpress",
                "profile"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "mepr-account-home-before-name",
            "insert_number": 1,
            "code": "<?php\n$wpide_member = wp_get_current_user();\nif ( $wpide_member->exists() && ( '' === trim( $wpide_member->first_name ) || '' === trim( $wpide_member->last_name ) ) ) :\n    ?>\n<p class=\"wpide-mepr-profile-reminder\" style=\"padding:.75em 1em;border-left:4px solid #dba617;background:rgba(219,166,23,.08)\">\n    Please add your first and last name below so we can personalize your account and invoices.\n</p>\n<?php endif; ?>",
            "requires": [
                "memberpress"
            ],
            "notes": ""
        },
        {
            "id": "disable-xmlrpc-multicall",
            "version": 1,
            "title": "Disable XML-RPC Multicall",
            "description": "Removes the XML-RPC system.multicall method, which attackers use to try hundreds of passwords in a single request, while XML-RPC keeps working for apps and Jetpack.",
            "category": "security",
            "tags": [
                "security",
                "xml-rpc",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'xmlrpc_methods', function ( $methods ) {\n    unset( $methods['system.multicall'] );\n\n    return $methods;\n} );",
            "requires": [],
            "notes": "Use the \"Disable XML-RPC\" snippet instead if nothing on your site needs XML-RPC."
        },
        {
            "id": "content-security-policy-report-only",
            "version": 1,
            "title": "Content Security Policy Header (Report-Only)",
            "description": "Sends a starter Content Security Policy in report-only mode, so you can see in the browser console what a strict policy would block before enforcing it.",
            "category": "security",
            "tags": [
                "security",
                "headers",
                "csp"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Adjust the sources to the services your site uses, then switch $wpide_csp_enforce to true.\n$wpide_csp = array(\n    \"default-src 'self'\",\n    \"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com\",\n    \"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com\",\n    \"font-src 'self' data: https://fonts.gstatic.com\",\n    \"img-src 'self' data: https:\",\n    \"connect-src 'self' https://www.google-analytics.com\",\n    \"frame-src 'self' https://www.youtube.com https://player.vimeo.com\",\n    \"frame-ancestors 'self'\",\n    \"base-uri 'self'\",\n    \"form-action 'self'\",\n    \"object-src 'none'\",\n);\n$wpide_csp_enforce = false;\n\nadd_action( 'send_headers', function () use ( $wpide_csp, $wpide_csp_enforce ) {\n    if ( headers_sent() || is_user_logged_in() ) {\n        return; // Logged-in users load editor and admin bar assets the policy may not cover.\n    }\n\n    header( ( $wpide_csp_enforce ? 'Content-Security-Policy: ' : 'Content-Security-Policy-Report-Only: ' ) . implode( '; ', $wpide_csp ) );\n} );",
            "requires": [],
            "notes": "Test page by page with the browser console open; an enforced policy that is too strict breaks scripts, embeds and fonts."
        },
        {
            "id": "protect-login-with-secret-key",
            "version": 1,
            "title": "Protect wp-login.php With a Secret Key",
            "description": "Hides the login page behind a secret link: wp-login.php returns \"Not Found\" unless you have opened it with your key once in this browser.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Open https://your-site.com/wp-login.php?access=YOUR-SECRET once per browser (remembered for 30 days).\n$wpide_login_key_param = 'access';\n$wpide_login_key       = 'change-this-to-a-long-random-secret';\n\nadd_action( 'login_init', function () use ( $wpide_login_key_param, $wpide_login_key ) {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : 'login';\n\n    // Logout, password-protected posts and password reset links from emails always work.\n    if ( is_user_logged_in() || in_array( $action, array( 'logout', 'postpass', 'rp', 'resetpass', 'confirmaction' ), true ) ) {\n        return;\n    }\n\n    $cookie   = 'wpide_login_access';\n    $expected = hash_hmac( 'sha256', $wpide_login_key, wp_salt( 'auth' ) );\n\n    if ( isset( $_GET[ $wpide_login_key_param ] ) && hash_equals( $wpide_login_key, (string) wp_unslash( $_GET[ $wpide_login_key_param ] ) ) ) {\n        setcookie( $cookie, $expected, time() + 30 * DAY_IN_SECONDS, SITECOOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );\n        return;\n    }\n\n    if ( isset( $_COOKIE[ $cookie ] ) && hash_equals( $expected, (string) wp_unslash( $_COOKIE[ $cookie ] ) ) ) {\n        return;\n    }\n\n    status_header( 404 );\n    nocache_headers();\n    header( 'Content-Type: text/html; charset=utf-8' );\n    echo '<!DOCTYPE html><html><head><title>404 Not Found</title><meta name=\"robots\" content=\"noindex\"></head><body><h1>Not Found</h1></body></html>';\n    exit;\n} );",
            "requires": [],
            "notes": "Bookmark the secret link, and if you lose it turn the snippet off from WPIDE safe mode."
        },
        {
            "id": "redirect-wp-admin-for-guests",
            "version": 1,
            "title": "Redirect wp-admin to the Homepage for Logged-Out Visitors",
            "description": "Sends logged-out visitors who open /wp-admin/ to the homepage instead of the login page, so bots probing wp-admin do not learn where to log in.",
            "category": "security",
            "tags": [
                "security",
                "admin",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    global $pagenow;\n\n    if ( ! is_admin() || is_user_logged_in() || wp_doing_ajax() || wp_doing_cron() ) {\n        return;\n    }\n\n    // Entry points that must stay reachable for visitors.\n    if ( in_array( $pagenow, array( 'admin-ajax.php', 'admin-post.php', 'async-upload.php' ), true ) ) {\n        return;\n    }\n\n    wp_safe_redirect( home_url( '/' ) );\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "You then need to open wp-login.php directly to log in."
        },
        {
            "id": "log-failed-logins",
            "version": 1,
            "title": "Log Failed Logins",
            "description": "Keeps a log of the last 200 failed login attempts with username, IP address and time, viewable under Tools > Failed Logins.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "logging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How many entries to keep.\n$wpide_failed_log_max = 200;\n\nadd_action( 'wp_login_failed', function ( $username ) use ( $wpide_failed_log_max ) {\n    $log   = get_option( 'wpide_failed_logins', array() );\n    $log   = is_array( $log ) ? $log : array();\n    $log[] = array(\n        'time' => time(),\n        'user' => substr( sanitize_user( (string) $username ), 0, 60 ),\n        'ip'   => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n    );\n\n    update_option( 'wpide_failed_logins', array_slice( $log, -$wpide_failed_log_max ), false );\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Failed Logins', 'Failed Logins', 'manage_options', 'wpide-failed-logins', function () {\n        if ( isset( $_POST['wpide_clear_failed'] ) && check_admin_referer( 'wpide_clear_failed' ) ) {\n            delete_option( 'wpide_failed_logins' );\n        }\n\n        $log = array_reverse( (array) get_option( 'wpide_failed_logins', array() ) );\n        echo '<div class=\"wrap\"><h1>Failed Logins</h1>';\n        echo '<form method=\"post\">';\n        wp_nonce_field( 'wpide_clear_failed' );\n        echo '<p><button class=\"button\" name=\"wpide_clear_failed\" value=\"1\">Clear log</button></p></form>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>Time</th><th>Username tried</th><th>IP address</th></tr></thead><tbody>';\n        if ( ! $log ) {\n            echo '<tr><td colspan=\"3\">No failed logins recorded.</td></tr>';\n        }\n        foreach ( $log as $row ) {\n            printf(\n                '<tr><td>%1$s</td><td>%2$s</td><td><code>%3$s</code></td></tr>',\n                esc_html( wp_date( 'Y-m-d H:i:s', (int) $row['time'] ) ),\n                esc_html( $row['user'] ),\n                esc_html( $row['ip'] )\n            );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "The log is stored in the wpide_failed_logins option (not autoloaded)."
        },
        {
            "id": "login-math-challenge",
            "version": 1,
            "title": "Simple Math Question on the Login Form",
            "description": "Adds a small addition question to wp-login.php that bots fail, without third-party CAPTCHA services or cookies.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_form', function () {\n    $a     = wp_rand( 1, 9 );\n    $b     = wp_rand( 1, 9 );\n    $time  = time();\n    $token = hash_hmac( 'sha256', ( $a + $b ) . '|' . $time, wp_salt( 'nonce' ) );\n    ?>\n    <p>\n        <label for=\"wpide_math\"><?php echo esc_html( sprintf( 'What is %d + %d?', $a, $b ) ); ?></label>\n        <input type=\"number\" name=\"wpide_math\" id=\"wpide_math\" class=\"input\" inputmode=\"numeric\" autocomplete=\"off\" required>\n        <input type=\"hidden\" name=\"wpide_math_time\" value=\"<?php echo esc_attr( $time ); ?>\">\n        <input type=\"hidden\" name=\"wpide_math_token\" value=\"<?php echo esc_attr( $token ); ?>\">\n    </p>\n    <?php\n} );\n\nadd_filter( 'authenticate', function ( $user ) {\n    // Only the wp-login.php form; XML-RPC, REST and other login forms are left alone.\n    if ( 'wp-login.php' !== ( isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '' ) || empty( $_POST['log'] ) ) {\n        return $user;\n    }\n\n    $answer = isset( $_POST['wpide_math'] ) ? (int) $_POST['wpide_math'] : -1;\n    $time   = isset( $_POST['wpide_math_time'] ) ? (int) $_POST['wpide_math_time'] : 0;\n    $token  = isset( $_POST['wpide_math_token'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_math_token'] ) ) : '';\n\n    $valid = $time > time() - HOUR_IN_SECONDS && hash_equals( hash_hmac( 'sha256', $answer . '|' . $time, wp_salt( 'nonce' ) ), $token );\n\n    return $valid ? $user : new WP_Error( 'wpide_math', '<strong>Error:</strong> Please answer the math question correctly.' );\n}, 25 );",
            "requires": [],
            "notes": "Custom login forms (shop or membership plugins) do not show the question and are not checked."
        },
        {
            "id": "admin-login-ip-allowlist",
            "version": 1,
            "title": "Allow Administrator Logins Only From Trusted IPs",
            "description": "Administrators can only log in from the IP addresses or ranges you list; everyone else logs in as usual.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "ip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Trusted IPs. Single addresses or IPv4 ranges in CIDR form (203.0.113.0/24).\n$wpide_admin_ips = array( '127.0.0.1', '203.0.113.0/24' );\n\n$wpide_ip_in_list = function ( $ip, $list ) {\n    foreach ( $list as $entry ) {\n        if ( false === strpos( $entry, '/' ) ) {\n            if ( $ip === $entry ) {\n                return true;\n            }\n            continue;\n        }\n\n        list( $subnet, $bits ) = explode( '/', $entry, 2 );\n\n        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n            $mask = -1 << ( 32 - (int) $bits );\n            if ( ( ip2long( $ip ) & $mask ) === ( ip2long( $subnet ) & $mask ) ) {\n                return true;\n            }\n        }\n    }\n\n    return false;\n};\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_admin_ips, $wpide_ip_in_list ) {\n    if ( ! ( $user instanceof WP_User ) || ! user_can( $user, 'manage_options' ) ) {\n        return $user;\n    }\n\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';\n\n    if ( $wpide_ip_in_list( $ip, $wpide_admin_ips ) ) {\n        return $user;\n    }\n\n    return new WP_Error( 'wpide_admin_ip', '<strong>Error:</strong> Administrator logins are not allowed from this location.' );\n}, 100 );",
            "requires": [],
            "notes": "Add your own IP before activating (behind a proxy or CDN that may be the proxy address), or you will lock yourself out."
        },
        {
            "id": "slow-down-failed-logins",
            "version": 1,
            "title": "Slow Down Failed Login Attempts",
            "description": "Waits a second or two before answering a failed login, which makes password-guessing bots far slower without bothering real users.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_login_failed', function () {\n    // Random 1 to 2 second delay.\n    usleep( wp_rand( 1000000, 2000000 ) );\n} );",
            "requires": [],
            "notes": "Each delayed request keeps a PHP worker busy; combine with a login limiter on busy sites."
        },
        {
            "id": "block-common-passwords",
            "version": 1,
            "title": "Block Common and Guessable Passwords",
            "description": "Rejects the most common passwords, and passwords containing the username or site name, when users set or reset their password.",
            "category": "security",
            "tags": [
                "security",
                "passwords",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_common_passwords = array(\n    '123456', '1234567', '12345678', '123456789', '1234567890', 'password', 'password1', 'password123', 'qwerty', 'qwerty123',\n    'abc123', '111111', '000000', 'iloveyou', 'letmein', 'welcome', 'welcome1', 'admin', 'admin123', 'monkey',\n    'dragon', 'sunshine', 'football', 'baseball', 'master', 'login', 'princess', 'starwars', 'passw0rd', 'trustno1',\n);\n\n$wpide_password_problem = function ( $password, $username ) use ( $wpide_common_passwords ) {\n    $lower = strtolower( (string) $password );\n\n    if ( '' === $lower ) {\n        return '';\n    }\n\n    if ( in_array( $lower, $wpide_common_passwords, true ) ) {\n        return 'This password is one of the most common ones. Please choose something less predictable.';\n    }\n\n    foreach ( array( $username, get_bloginfo( 'name' ) ) as $word ) {\n        $word = strtolower( trim( (string) $word ) );\n        if ( strlen( $word ) >= 3 && false !== strpos( $lower, $word ) ) {\n            return 'Your password must not contain your username or the site name.';\n        }\n    }\n\n    return '';\n};\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_password_problem ) {\n    if ( ! empty( $user->user_pass ) ) {\n        $problem = $wpide_password_problem( $user->user_pass, isset( $user->user_login ) ? $user->user_login : '' );\n        if ( $problem ) {\n            $errors->add( 'wpide_weak_password', '<strong>Error:</strong> ' . esc_html( $problem ) );\n        }\n    }\n}, 10, 3 );\n\nadd_action( 'validate_password_reset', function ( $errors, $user ) use ( $wpide_password_problem ) {\n    if ( ! empty( $_POST['pass1'] ) && $user instanceof WP_User ) {\n        $problem = $wpide_password_problem( wp_unslash( $_POST['pass1'] ), $user->user_login );\n        if ( $problem ) {\n            $errors->add( 'wpide_weak_password', '<strong>Error:</strong> ' . esc_html( $problem ) );\n        }\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-malicious-request-patterns",
            "version": 1,
            "title": "Block Malicious Request Patterns",
            "description": "A tiny firewall that returns 403 Forbidden for URLs containing typical attack strings, such as directory traversal, SQL injection and PHP stream wrappers.",
            "category": "security",
            "tags": [
                "security",
                "firewall",
                "requests"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Patterns checked against the decoded URL (path and query string).\n$wpide_firewall_patterns = array(\n    '#\\.\\./#',\n    '#(?:/|%2f)etc/passwd#i',\n    '#(?:php|phar|zip|data|expect)://#i',\n    '#union(?:\\s|\\+|/\\*.*\\*/)+(?:all(?:\\s|\\+)+)?select#i',\n    '#\\b(?:base64_decode|eval|assert|shell_exec|passthru|system)\\s*\\(#i',\n    '#<\\s*script#i',\n    '#(?:GLOBALS|_REQUEST|_SERVER)\\s*\\[#',\n    '#wp-config\\.php#i',\n);\n\nadd_action( 'init', function () use ( $wpide_firewall_patterns ) {\n    if ( empty( $_SERVER['REQUEST_URI'] ) || ( is_user_logged_in() && current_user_can( 'unfiltered_html' ) ) ) {\n        return;\n    }\n\n    $uri = rawurldecode( rawurldecode( (string) wp_unslash( $_SERVER['REQUEST_URI'] ) ) );\n\n    foreach ( $wpide_firewall_patterns as $pattern ) {\n        if ( preg_match( $pattern, $uri ) ) {\n            status_header( 403 );\n            nocache_headers();\n            exit( 'Forbidden' );\n        }\n    }\n}, 0 );",
            "requires": [],
            "notes": "Form submissions are not inspected, so editors keep working, and it complements rather than replaces a real web application firewall."
        },
        {
            "id": "block-bad-bots",
            "version": 1,
            "title": "Block Bad Bots by User Agent",
            "description": "Returns 403 Forbidden to aggressive SEO crawlers and scrapers that waste server resources, matched by their user agent.",
            "category": "security",
            "tags": [
                "security",
                "bots",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Case-insensitive parts of user agent strings to block. AI crawlers are listed but commented out.\n$wpide_blocked_bots = array(\n    'MJ12bot', 'AhrefsBot', 'SemrushBot', 'DotBot', 'BLEXBot', 'PetalBot', 'MegaIndex', 'serpstatbot',\n    'DataForSeoBot', 'ZoominfoBot', 'SeekportBot', 'Barkrowler', 'Bytespider',\n    // 'GPTBot', 'ClaudeBot', 'CCBot', 'Google-Extended',\n);\n\nadd_action( 'init', function () use ( $wpide_blocked_bots ) {\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';\n\n    if ( '' === $agent ) {\n        return;\n    }\n\n    foreach ( $wpide_blocked_bots as $bot ) {\n        if ( false !== stripos( $agent, $bot ) ) {\n            status_header( 403 );\n            header( 'Content-Type: text/plain; charset=utf-8' );\n            exit( 'Forbidden' );\n        }\n    }\n}, 0 );",
            "requires": [],
            "notes": "Only honest bots are stopped (others fake their user agent), and blocked SEO tools will no longer report on your site."
        },
        {
            "id": "block-ip-addresses",
            "version": 1,
            "title": "Block IP Addresses and Ranges",
            "description": "Denies access to the whole site for the IP addresses and IPv4 ranges you list, for example a persistent spammer or attacker.",
            "category": "security",
            "tags": [
                "security",
                "ip",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Single addresses (IPv4 or IPv6) or IPv4 ranges in CIDR form.\n$wpide_blocked_ips = array( '192.0.2.10', '198.51.100.0/24' );\n\n$wpide_visitor_ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';\n\nif ( $wpide_visitor_ip && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n    foreach ( $wpide_blocked_ips as $wpide_entry ) {\n        $wpide_match = false;\n\n        if ( false === strpos( $wpide_entry, '/' ) ) {\n            $wpide_match = ( $wpide_visitor_ip === $wpide_entry );\n        } elseif ( filter_var( $wpide_visitor_ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n            list( $wpide_subnet, $wpide_bits ) = explode( '/', $wpide_entry, 2 );\n            $wpide_mask  = -1 << ( 32 - (int) $wpide_bits );\n            $wpide_match = ( ip2long( $wpide_visitor_ip ) & $wpide_mask ) === ( ip2long( $wpide_subnet ) & $wpide_mask );\n        }\n\n        if ( $wpide_match ) {\n            status_header( 403 );\n            header( 'Content-Type: text/plain; charset=utf-8' );\n            exit( 'Access denied.' );\n        }\n    }\n}",
            "requires": [],
            "notes": "Behind Cloudflare or another proxy, REMOTE_ADDR is the proxy; configure your server to pass the real visitor IP first."
        },
        {
            "id": "redirect-to-https",
            "version": 1,
            "title": "Redirect All Traffic to HTTPS",
            "description": "Permanently redirects every plain HTTP request to the HTTPS version of the same URL, for sites whose host does not do it already.",
            "category": "security",
            "tags": [
                "security",
                "https",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( is_ssl() || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() || empty( $_SERVER['HTTP_HOST'] ) ) {\n        return;\n    }\n\n    // Many proxies and load balancers terminate HTTPS and pass this header instead.\n    if ( isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) && 'https' === strtolower( (string) $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) {\n        return;\n    }\n\n    $host = wp_parse_url( home_url(), PHP_URL_HOST );\n    $uri  = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';\n\n    wp_safe_redirect( 'https://' . $host . $uri, 301 );\n    exit;\n}, 0 );",
            "requires": [],
            "notes": "Make sure your SSL certificate works first, and set both site URLs in Settings > General to https://."
        },
        {
            "id": "allow-only-safe-upload-types",
            "version": 1,
            "title": "Allow Only Safe File Types to Be Uploaded",
            "description": "Limits uploads to common images, documents, audio and video, and rejects files with a hidden second extension such as photo.php.jpg.",
            "category": "security",
            "tags": [
                "security",
                "uploads",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extensions (as WordPress lists them) that may be uploaded.\n$wpide_safe_upload_types = array( 'jpg|jpeg|jpe', 'png', 'gif', 'webp', 'avif', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt|pps', 'pptx', 'odt', 'txt', 'csv', 'mp3|m4a|m4b', 'mp4|m4v', 'mov|qt', 'webm' );\n\nadd_filter( 'upload_mimes', function ( $mimes ) use ( $wpide_safe_upload_types ) {\n    return array_intersect_key( $mimes, array_flip( $wpide_safe_upload_types ) );\n}, 99 );\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) {\n    $parts = explode( '.', strtolower( (string) $file['name'] ) );\n    array_pop( $parts ); // The real extension.\n\n    foreach ( $parts as $part ) {\n        if ( in_array( $part, array( 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'pht', 'shtml', 'cgi', 'exe', 'htaccess' ), true ) ) {\n            $file['error'] = 'This file name is not allowed. Please rename the file and try again.';\n            break;\n        }\n    }\n\n    return $file;\n} );",
            "requires": [],
            "notes": "Remove this snippet or extend the list before uploading other file types, such as ZIP archives or fonts."
        },
        {
            "id": "plugin-change-email-alert",
            "version": 1,
            "title": "Email Alert When Plugins Are Activated or Deactivated",
            "description": "Emails the site administrator whenever a plugin is turned on or off, with who did it and when.",
            "category": "security",
            "tags": [
                "security",
                "plugins",
                "alerts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Who receives the alerts.\n$wpide_plugin_alert_to = get_option( 'admin_email' );\n\n$wpide_plugin_alert = function ( $plugin, $what ) use ( $wpide_plugin_alert_to ) {\n    $data = function_exists( 'get_plugin_data' ) && file_exists( WP_PLUGIN_DIR . '/' . $plugin ) ? get_plugin_data( WP_PLUGIN_DIR . '/' . $plugin, false, false ) : array();\n    $name = ! empty( $data['Name'] ) ? $data['Name'] : $plugin;\n    $user = wp_get_current_user();\n    $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    $by   = $user->exists() ? $user->user_login : ( ( defined( 'WP_CLI' ) && WP_CLI ) ? 'WP-CLI' : 'system' );\n\n    wp_mail(\n        $wpide_plugin_alert_to,\n        sprintf( '[%s] Plugin %s: %s', $site, $what, $name ),\n        sprintf( \"The plugin \\\"%s\\\" was %s on %s.\\n\\nBy: %s\\nTime: %s\", $name, $what, home_url( '/' ), $by, wp_date( 'Y-m-d H:i:s T' ) )\n    );\n};\n\nadd_action( 'activated_plugin', function ( $plugin ) use ( $wpide_plugin_alert ) {\n    $wpide_plugin_alert( $plugin, 'activated' );\n} );\n\nadd_action( 'deactivated_plugin', function ( $plugin ) use ( $wpide_plugin_alert ) {\n    $wpide_plugin_alert( $plugin, 'deactivated' );\n} );",
            "requires": [],
            "notes": "Bulk changes send one email per plugin."
        },
        {
            "id": "new-administrator-email-alert",
            "version": 1,
            "title": "Email Alert When a New Administrator Appears",
            "description": "Emails the site owner whenever an account is created as, or promoted to, administrator, which is often the first sign of a compromise.",
            "category": "security",
            "tags": [
                "security",
                "users",
                "alerts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_new_admin_alert = function ( $user_id ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $actor = wp_get_current_user();\n    $site  = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] New administrator: %s', $site, $user->user_login ),\n        sprintf(\n            \"The account \\\"%s\\\" (%s) now has the administrator role on %s.\\n\\nChanged by: %s\\nTime: %s\\nIP: %s\\n\\nIf you did not expect this, review your users immediately.\",\n            $user->user_login,\n            $user->user_email,\n            home_url( '/' ),\n            $actor->exists() ? $actor->user_login : 'unknown',\n            wp_date( 'Y-m-d H:i:s T' ),\n            isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'n/a'\n        )\n    );\n};\n\nadd_action( 'set_user_role', function ( $user_id, $role, $old_roles ) use ( $wpide_new_admin_alert ) {\n    if ( 'administrator' === $role && ! in_array( 'administrator', (array) $old_roles, true ) ) {\n        $wpide_new_admin_alert( $user_id );\n    }\n}, 10, 3 );\n\nadd_action( 'add_user_role', function ( $user_id, $role ) use ( $wpide_new_admin_alert ) {\n    if ( 'administrator' === $role ) {\n        $wpide_new_admin_alert( $user_id );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "admin-activity-log",
            "version": 1,
            "title": "Simple Admin Activity Log",
            "description": "Records logins, plugin and theme changes, updates, role changes and deleted content, and lists the last 300 events under Tools > Activity Log.",
            "category": "security",
            "tags": [
                "security",
                "logging",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_activity = function ( $message ) {\n    $user  = wp_get_current_user();\n    $log   = get_option( 'wpide_activity_log', array() );\n    $log   = is_array( $log ) ? $log : array();\n    $log[] = array(\n        'time' => time(),\n        'user' => $user->exists() ? $user->user_login : '',\n        'ip'   => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n        'msg'  => $message,\n    );\n\n    update_option( 'wpide_activity_log', array_slice( $log, -300 ), false );\n};\n\nadd_action( 'wp_login', function ( $login ) use ( $wpide_activity ) {\n    $wpide_activity( sprintf( 'Logged in as %s', $login ) );\n} );\nadd_action( 'activated_plugin', function ( $plugin ) use ( $wpide_activity ) {\n    $wpide_activity( 'Activated plugin ' . $plugin );\n} );\nadd_action( 'deactivated_plugin', function ( $plugin ) use ( $wpide_activity ) {\n    $wpide_activity( 'Deactivated plugin ' . $plugin );\n} );\nadd_action( 'switch_theme', function ( $name ) use ( $wpide_activity ) {\n    $wpide_activity( 'Switched theme to ' . $name );\n} );\nadd_action( 'upgrader_process_complete', function ( $upgrader, $extra ) use ( $wpide_activity ) {\n    if ( ! empty( $extra['type'] ) && ! empty( $extra['action'] ) ) {\n        $wpide_activity( sprintf( 'Ran %s for %s', $extra['action'], $extra['type'] ) );\n    }\n}, 10, 2 );\nadd_action( 'set_user_role', function ( $user_id, $role, $old ) use ( $wpide_activity ) {\n    if ( $old ) {\n        $user = get_userdata( $user_id );\n        $wpide_activity( sprintf( 'Changed role of %s to %s', $user ? $user->user_login : '#' . $user_id, $role ) );\n    }\n}, 10, 3 );\nadd_action( 'deleted_user', function ( $user_id ) use ( $wpide_activity ) {\n    $wpide_activity( 'Deleted user #' . $user_id );\n} );\nadd_action( 'before_delete_post', function ( $post_id, $post ) use ( $wpide_activity ) {\n    if ( $post && ! in_array( $post->post_type, array( 'revision', 'customize_changeset', 'oembed_cache', 'wp_global_styles' ), true ) && 'auto-draft' !== $post->post_status ) {\n        $wpide_activity( sprintf( 'Permanently deleted %s \"%s\"', $post->post_type, $post->post_title ) );\n    }\n}, 10, 2 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Activity Log', 'Activity Log', 'manage_options', 'wpide-activity-log', function () {\n        $log = array_reverse( (array) get_option( 'wpide_activity_log', array() ) );\n        echo '<div class=\"wrap\"><h1>Activity Log</h1><table class=\"widefat striped\"><thead><tr><th>Time</th><th>User</th><th>Event</th><th>IP</th></tr></thead><tbody>';\n        if ( ! $log ) {\n            echo '<tr><td colspan=\"4\">Nothing recorded yet.</td></tr>';\n        }\n        foreach ( $log as $row ) {\n            printf(\n                '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td><td><code>%4$s</code></td></tr>',\n                esc_html( wp_date( 'Y-m-d H:i:s', (int) $row['time'] ) ),\n                esc_html( $row['user'] ? $row['user'] : 'system' ),\n                esc_html( $row['msg'] ),\n                esc_html( $row['ip'] )\n            );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "A lightweight log for small sites; use a dedicated audit plugin when you need search, export or retention rules."
        },
        {
            "id": "lock-registration-settings",
            "version": 1,
            "title": "Lock Registration and Default Role Settings",
            "description": "Forces \"Anyone can register\" and the default new user role to fixed values in code, so an attacker or a mistake cannot open admin-level sign-ups.",
            "category": "security",
            "tags": [
                "security",
                "registration",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Whether visitors may register, and the role new users get.\n$wpide_allow_registration = false;\n$wpide_locked_default_role = 'subscriber';\n\nadd_filter( 'pre_option_users_can_register', function () use ( $wpide_allow_registration ) {\n    return $wpide_allow_registration ? '1' : '0';\n} );\n\nadd_filter( 'pre_option_default_role', function () use ( $wpide_locked_default_role ) {\n    return $wpide_locked_default_role;\n} );",
            "requires": [],
            "notes": "The settings screen shows the locked values; change them here instead."
        },
        {
            "id": "lock-site-url-admin-email",
            "version": 1,
            "title": "Lock the Site URL and Admin Email",
            "description": "Ignores changes to the WordPress Address, Site Address and administration email made from the dashboard, a common target after an account takeover.",
            "category": "security",
            "tags": [
                "security",
                "settings",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "foreach ( array( 'siteurl', 'home', 'admin_email', 'new_admin_email' ) as $wpide_locked_option ) {\n    add_filter( \"pre_update_option_{$wpide_locked_option}\", function ( $value, $old_value ) {\n        return $old_value;\n    }, 99, 2 );\n}\n\nadd_action( 'admin_notices', function () {\n    $screen = get_current_screen();\n\n    if ( $screen && 'options-general' === $screen->id ) {\n        echo '<div class=\"notice notice-info\"><p>The site addresses and administration email are locked by a code snippet and cannot be changed here.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Deactivate the snippet before a planned domain change or admin email update."
        },
        {
            "id": "prevent-deactivating-essential-plugins",
            "version": 1,
            "title": "Prevent Deactivating Essential Plugins",
            "description": "Removes the Deactivate option for the plugins you list, for every user, so key functionality cannot be switched off by accident.",
            "category": "security",
            "tags": [
                "security",
                "plugins",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Plugin files that must stay active (folder/main-file.php).\n$wpide_essential_plugins = array( 'woocommerce/woocommerce.php' );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_essential_plugins ) {\n    if ( in_array( $cap, array( 'deactivate_plugin', 'delete_plugins' ), true ) && ! empty( $args[0] ) && in_array( $args[0], $wpide_essential_plugins, true ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );\n\nadd_filter( 'plugin_action_links', function ( $actions, $plugin_file ) use ( $wpide_essential_plugins ) {\n    if ( in_array( $plugin_file, $wpide_essential_plugins, true ) ) {\n        unset( $actions['deactivate'] );\n        $actions['wpide_locked'] = '<span style=\"color:#646970\">Required</span>';\n    }\n\n    return $actions;\n}, 10, 2 );",
            "requires": [],
            "notes": "The plugin can still be deactivated through FTP or WP-CLI."
        },
        {
            "id": "shorter-nonce-lifetime",
            "version": 1,
            "title": "Shorter Nonce Lifetime",
            "description": "Makes WordPress security tokens (nonces) expire after 4 hours instead of up to 24, shrinking the window in which a leaked link can be reused.",
            "category": "security",
            "tags": [
                "security",
                "nonces",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Lifetime in seconds. A nonce stays valid for between half and all of this time.\n$wpide_nonce_life = 4 * HOUR_IN_SECONDS;\n\nadd_filter( 'nonce_life', function () use ( $wpide_nonce_life ) {\n    return $wpide_nonce_life;\n} );",
            "requires": [],
            "notes": "Pages left open longer than this may show \"The link you followed has expired\" on submit; cached pages with forms are affected too."
        },
        {
            "id": "strong-passwords-for-staff",
            "version": 1,
            "title": "Require Strong Passwords for Editors and Administrators",
            "description": "Requires at least 12 characters with letters, numbers and a symbol or mixed case for users who can edit other people's content.",
            "category": "security",
            "tags": [
                "security",
                "passwords",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_staff_password_length = 12;\n\n$wpide_password_is_strong = function ( $password ) use ( $wpide_staff_password_length ) {\n    return strlen( $password ) >= $wpide_staff_password_length\n        && preg_match( '/[a-z]/i', $password )\n        && preg_match( '/\\d/', $password )\n        && ( preg_match( '/[^a-z0-9]/i', $password ) || ( preg_match( '/[a-z]/', $password ) && preg_match( '/[A-Z]/', $password ) ) );\n};\n\n$wpide_strong_message = sprintf( '<strong>Error:</strong> Staff passwords need at least %d characters, including letters, numbers and a symbol or upper- and lowercase letters.', $wpide_staff_password_length );\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_password_is_strong, $wpide_strong_message ) {\n    if ( empty( $user->user_pass ) ) {\n        return;\n    }\n\n    $role     = isset( $user->role ) ? $user->role : '';\n    $is_staff = in_array( $role, array( 'administrator', 'editor' ), true ) || ( ! empty( $user->ID ) && user_can( $user->ID, 'edit_others_posts' ) );\n\n    if ( $is_staff && ! $wpide_password_is_strong( $user->user_pass ) ) {\n        $errors->add( 'wpide_strong_password', $wpide_strong_message );\n    }\n}, 10, 3 );\n\nadd_action( 'validate_password_reset', function ( $errors, $user ) use ( $wpide_password_is_strong, $wpide_strong_message ) {\n    if ( $user instanceof WP_User && ! empty( $_POST['pass1'] ) && user_can( $user, 'edit_others_posts' ) && ! $wpide_password_is_strong( wp_unslash( $_POST['pass1'] ) ) ) {\n        $errors->add( 'wpide_strong_password', $wpide_strong_message );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Existing passwords are not checked until the user changes or resets it."
        },
        {
            "id": "limit-password-reset-requests",
            "version": 1,
            "title": "Limit Password Reset Requests",
            "description": "Allows only a few password reset requests per hour from the same IP address, which stops reset-email flooding.",
            "category": "security",
            "tags": [
                "security",
                "passwords",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Requests allowed per IP address per hour.\n$wpide_reset_limit = 3;\n\nadd_action( 'lostpassword_post', function ( $errors ) use ( $wpide_reset_limit ) {\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : 'unknown';\n    $key = 'wpide_reset_' . md5( $ip );\n    $hits = (int) get_transient( $key );\n\n    if ( $hits >= $wpide_reset_limit ) {\n        $errors->add( 'wpide_reset_limit', '<strong>Error:</strong> Too many password reset requests. Please try again in an hour.' );\n        return;\n    }\n\n    set_transient( $key, $hits + 1, HOUR_IN_SECONDS );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-account-existence-on-reset",
            "version": 1,
            "title": "Hide Whether an Account Exists on Password Reset",
            "description": "Shows the same \"check your email\" screen for unknown usernames and emails on the lost password form, so it cannot be used to find valid accounts.",
            "category": "security",
            "tags": [
                "security",
                "passwords",
                "enumeration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'lostpassword_errors', function ( $errors, $user_data ) {\n    if ( $user_data || 'wp-login.php' !== ( isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '' ) ) {\n        return $errors;\n    }\n\n    $codes = $errors->get_error_codes();\n\n    // Unknown account: pretend the email was sent.\n    if ( empty( $codes ) || array( 'invalid_email' ) === $codes ) {\n        wp_safe_redirect( add_query_arg( 'checkemail', 'confirm', wp_login_url() ) );\n        exit;\n    }\n\n    return $errors;\n}, 99, 2 );",
            "requires": [],
            "notes": "Registration forms can still reveal existing emails; disable public registration if that matters."
        },
        {
            "id": "hide-rest-api-index",
            "version": 1,
            "title": "Hide the REST API Index From Visitors",
            "description": "Stops logged-out visitors from listing every REST API route and plugin namespace at /wp-json/, while the routes themselves keep working.",
            "category": "security",
            "tags": [
                "security",
                "rest-api",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_strip_rest_index = function ( $response ) {\n    if ( is_user_logged_in() || ! ( $response instanceof WP_REST_Response ) ) {\n        return $response;\n    }\n\n    $data = $response->get_data();\n    unset( $data['routes'], $data['namespaces'], $data['authentication'] );\n    $response->set_data( $data );\n\n    return $response;\n};\n\nadd_filter( 'rest_index', $wpide_strip_rest_index );\nadd_filter( 'rest_namespace_index', $wpide_strip_rest_index );\nadd_filter( 'rest_jsonp_enabled', '__return_false' );",
            "requires": [],
            "notes": "Some apps discover routes through the index; test integrations that connect to your site anonymously."
        },
        {
            "id": "hide-php-errors-from-visitors",
            "version": 1,
            "title": "Hide PHP Errors From Visitors",
            "description": "Turns off on-screen PHP warnings and errors for everyone except logged-in administrators, so file paths are never shown to the public.",
            "category": "security",
            "tags": [
                "security",
                "errors",
                "debug"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! ( is_user_logged_in() && current_user_can( 'manage_options' ) ) ) {\n    ini_set( 'display_errors', '0' );\n}",
            "requires": [],
            "notes": "Errors are still written to the error log if logging is enabled."
        },
        {
            "id": "honeypot-login-registration",
            "version": 1,
            "title": "Honeypot Trap for Login and Registration Forms",
            "description": "Adds an invisible field to the login and registration forms; bots that fill it in are rejected while people never see it.",
            "category": "security",
            "tags": [
                "security",
                "spam",
                "honeypot"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_hp_field = function () {\n    echo '<p style=\"position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden\" aria-hidden=\"true\">';\n    echo '<label for=\"wpide_company_site\">Company website</label>';\n    echo '<input type=\"text\" name=\"wpide_company_site\" id=\"wpide_company_site\" value=\"\" tabindex=\"-1\" autocomplete=\"off\">';\n    echo '</p>';\n};\n\nadd_action( 'login_form', $wpide_hp_field );\nadd_action( 'register_form', $wpide_hp_field );\n\nadd_filter( 'authenticate', function ( $user ) {\n    if ( 'wp-login.php' === ( isset( $GLOBALS['pagenow'] ) ? $GLOBALS['pagenow'] : '' ) && ! empty( $_POST['wpide_company_site'] ) ) {\n        return new WP_Error( 'wpide_honeypot', '<strong>Error:</strong> Login failed.' );\n    }\n\n    return $user;\n}, 1 );\n\nadd_filter( 'registration_errors', function ( $errors ) {\n    if ( ! empty( $_POST['wpide_company_site'] ) ) {\n        $errors->add( 'wpide_honeypot', '<strong>Error:</strong> Registration failed.' );\n    }\n\n    return $errors;\n} );",
            "requires": [],
            "notes": "Password managers rarely fill hidden fields, but test your own login once after activating."
        },
        {
            "id": "daily-core-integrity-check",
            "version": 1,
            "title": "Daily Core File Integrity Check",
            "description": "Compares your WordPress core files with the official checksums once a day and emails the administrator if any file was changed or is missing.",
            "category": "security",
            "tags": [
                "security",
                "integrity",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_core_integrity_check' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_core_integrity_check' );\n    }\n} );\n\nadd_action( 'wpide_core_integrity_check', function () {\n    global $wp_version, $wp_local_package;\n\n    require_once ABSPATH . 'wp-admin/includes/update.php';\n\n    $checksums = get_core_checksums( $wp_version, isset( $wp_local_package ) ? $wp_local_package : 'en_US' );\n\n    if ( ! is_array( $checksums ) ) {\n        return; // The checksum service could not be reached; try again tomorrow.\n    }\n\n    $problems = array();\n    $optional = array( 'readme.html', 'license.txt', 'wp-config-sample.php', 'xmlrpc.php' ); // Often removed on purpose.\n\n    foreach ( $checksums as $file => $md5 ) {\n        if ( 0 === strpos( $file, 'wp-content/' ) || ( in_array( $file, $optional, true ) && ! file_exists( ABSPATH . $file ) ) ) {\n            continue;\n        }\n\n        $path = ABSPATH . $file;\n\n        if ( ! file_exists( $path ) ) {\n            $problems[] = 'Missing: ' . $file;\n        } elseif ( md5_file( $path ) !== $md5 ) {\n            $problems[] = 'Modified: ' . $file;\n        }\n    }\n\n    if ( $problems ) {\n        wp_mail(\n            get_option( 'admin_email' ),\n            sprintf( '[%s] WordPress core files changed', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n            \"These core files differ from the official WordPress \" . $wp_version . \" release:\\n\\n\" . implode( \"\\n\", array_slice( $problems, 0, 100 ) ) . \"\\n\\nIf you did not change them, reinstall WordPress from Dashboard > Updates and scan the site.\"\n        );\n    }\n} );",
            "requires": [],
            "notes": "Contacts api.wordpress.org once a day, and the scheduled event stays registered but idle after deactivation."
        },
        {
            "id": "hide-email-addresses-from-bots",
            "version": 1,
            "title": "Hide Email Addresses From Spam Bots",
            "description": "Encodes every email address and mailto link in posts, widgets and comments so people see them normally but simple harvesting bots cannot read them.",
            "category": "security",
            "tags": [
                "security",
                "spam",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_protect_emails = function ( $content ) {\n    if ( false === strpos( (string) $content, '@' ) ) {\n        return $content;\n    }\n\n    $parts     = preg_split( '/(<[^>]+>)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE );\n    $in_script = false;\n\n    foreach ( $parts as $i => $part ) {\n        if ( '' === $part ) {\n            continue;\n        }\n\n        if ( '<' === $part[0] ) {\n            if ( preg_match( '#^<(script|style|textarea)\\b#i', $part ) ) {\n                $in_script = true;\n            } elseif ( preg_match( '#^</(script|style|textarea)>#i', $part ) ) {\n                $in_script = false;\n            }\n\n            $parts[ $i ] = preg_replace_callback( '/href=([\"\\'])mailto:([^\"\\']+)\\1/i', function ( $m ) {\n                return 'href=' . $m[1] . 'mailto:' . antispambot( $m[2] ) . $m[1];\n            }, $part );\n            continue;\n        }\n\n        if ( ! $in_script ) {\n            $parts[ $i ] = preg_replace_callback( '/[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}/i', function ( $m ) {\n                return antispambot( $m[0] );\n            }, $part );\n        }\n    }\n\n    return implode( '', $parts );\n};\n\nadd_filter( 'the_content', $wpide_protect_emails, 99 );\nadd_filter( 'widget_text', $wpide_protect_emails, 99 );\nadd_filter( 'widget_block_content', $wpide_protect_emails, 99 );\nadd_filter( 'comment_text', $wpide_protect_emails, 99 );",
            "requires": [],
            "notes": "Determined scrapers that decode HTML entities can still read the addresses."
        },
        {
            "id": "end-sessions-on-ip-change",
            "version": 1,
            "title": "End Admin Sessions When the IP Address Changes",
            "description": "Logs an administrator out if their session suddenly comes from a different IP address than the one they logged in from, which blocks stolen cookies.",
            "category": "security",
            "tags": [
                "security",
                "sessions",
                "cookies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// 'subnet' tolerates changes inside the same /24 (IPv4) or /64 (IPv6) network; 'exact' requires the same IP.\n$wpide_ip_match = 'subnet';\n\n$wpide_ip_prefix = function ( $ip ) use ( $wpide_ip_match ) {\n    if ( 'exact' === $wpide_ip_match ) {\n        return $ip;\n    }\n\n    if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n        return implode( '.', array_slice( explode( '.', $ip ), 0, 3 ) );\n    }\n\n    if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {\n        return $ip;\n    }\n\n    return bin2hex( substr( inet_pton( $ip ), 0, 8 ) );\n};\n\nadd_action( 'init', function () use ( $wpide_ip_prefix ) {\n    if ( ! is_user_logged_in() || ! current_user_can( 'manage_options' ) || empty( $_SERVER['REMOTE_ADDR'] ) ) {\n        return;\n    }\n\n    $token   = wp_get_session_token();\n    $manager = WP_Session_Tokens::get_instance( get_current_user_id() );\n    $session = $token ? $manager->get( $token ) : null;\n\n    if ( empty( $session['ip'] ) ) {\n        return;\n    }\n\n    if ( $wpide_ip_prefix( $session['ip'] ) !== $wpide_ip_prefix( (string) $_SERVER['REMOTE_ADDR'] ) ) {\n        $manager->destroy( $token );\n        wp_clear_auth_cookie();\n        wp_safe_redirect( wp_login_url() );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "Mobile and some office networks change IP often; switch to subnet matching or remove the snippet if admins get logged out too much."
        },
        {
            "id": "application-passwords-admins-only",
            "version": 1,
            "title": "Application Passwords for Administrators Only",
            "description": "Keeps Application Passwords available for administrators (for integrations and apps) but removes them for every other user.",
            "category": "security",
            "tags": [
                "security",
                "rest-api",
                "passwords"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_is_application_passwords_available_for_user', function ( $available, $user ) {\n    return $available && $user instanceof WP_User && user_can( $user, 'manage_options' );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "serve-security-txt",
            "version": 1,
            "title": "Serve a security.txt File",
            "description": "Publishes /.well-known/security.txt so security researchers know how to report a vulnerability on your site responsibly.",
            "category": "security",
            "tags": [
                "security",
                "disclosure",
                "standards"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_security_txt = array(\n    'Contact'             => 'mailto:security@example.com',\n    'Preferred-Languages' => 'en',\n    'Policy'              => home_url( '/security-policy/' ),\n);\n\nadd_action( 'init', function () use ( $wpide_security_txt ) {\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $base = rtrim( (string) wp_parse_url( home_url(), PHP_URL_PATH ), '/' );\n\n    if ( $path !== $base . '/.well-known/security.txt' ) {\n        return;\n    }\n\n    $lines = array();\n    foreach ( $wpide_security_txt as $field => $value ) {\n        $lines[] = $field . ': ' . str_replace( array( \"\\r\", \"\\n\" ), '', wp_strip_all_tags( (string) $value ) );\n    }\n    $lines[] = 'Expires: ' . gmdate( 'Y-m-d\\TH:i:s\\Z', time() + 180 * DAY_IN_SECONDS );\n    $lines[] = 'Canonical: ' . home_url( '/.well-known/security.txt' );\n\n    status_header( 200 );\n    header( 'Content-Type: text/plain; charset=utf-8' );\n    echo implode( \"\\n\", $lines ) . \"\\n\"; // phpcs:ignore WordPress.Security.EscapeOutput -- plain text response.\n    exit;\n} );",
            "requires": [],
            "notes": "If your web server answers /.well-known/ itself, place a static file there instead."
        },
        {
            "id": "remove-usernames-from-css-classes",
            "version": 1,
            "title": "Remove Usernames From CSS Classes",
            "description": "Strips the author and commenter usernames WordPress adds to body and comment classes, closing a quiet way to discover login names.",
            "category": "security",
            "tags": [
                "security",
                "enumeration",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'comment_class', function ( $classes ) {\n    return array_values( array_filter( $classes, function ( $class ) {\n        return 0 !== strpos( $class, 'comment-author-' );\n    } ) );\n} );\n\nadd_filter( 'body_class', function ( $classes ) {\n    if ( ! is_author() ) {\n        return $classes;\n    }\n\n    $author = get_queried_object();\n\n    if ( $author instanceof WP_User ) {\n        $classes = array_diff( $classes, array( 'author-' . sanitize_html_class( $author->user_nicename, $author->ID ) ) );\n    }\n\n    return array_values( $classes );\n} );",
            "requires": [],
            "notes": "Check that your theme CSS does not rely on these classes."
        },
        {
            "id": "ban-attack-usernames",
            "version": 1,
            "title": "Ban IPs That Try Common Attack Usernames",
            "description": "Blocks an IP address from logging in for a day as soon as it tries a username like \"admin\" or \"test\" that does not exist on your site.",
            "category": "security",
            "tags": [
                "security",
                "login",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Usernames that only bots try (they must not exist on your site).\n$wpide_trap_usernames = array( 'admin', 'administrator', 'root', 'test', 'user', 'demo', 'wordpress', 'wp' );\n$wpide_trap_ban_time  = DAY_IN_SECONDS;\n\n$wpide_trap_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : 'unknown';\n\n    return 'wpide_trap_' . md5( $ip );\n};\n\nadd_action( 'wp_login_failed', function ( $username ) use ( $wpide_trap_usernames, $wpide_trap_ban_time, $wpide_trap_key ) {\n    $username = strtolower( trim( (string) $username ) );\n\n    if ( in_array( $username, $wpide_trap_usernames, true ) && ! username_exists( $username ) ) {\n        set_transient( $wpide_trap_key(), 1, $wpide_trap_ban_time );\n    }\n} );\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_trap_key ) {\n    if ( get_transient( $wpide_trap_key() ) ) {\n        return new WP_Error( 'wpide_trap_banned', '<strong>Error:</strong> Login is temporarily blocked from your network.' );\n    }\n\n    return $user;\n}, 1 );",
            "requires": [],
            "notes": "Never list a username that a real person on your site uses."
        },
        {
            "id": "remind-admins-old-passwords",
            "version": 1,
            "title": "Remind Administrators to Change Old Passwords",
            "description": "Shows administrators a reminder when they have not changed their password for 180 days.",
            "category": "security",
            "tags": [
                "security",
                "passwords",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days before the reminder appears.\n$wpide_password_max_age = 180;\n\nadd_action( 'after_password_reset', function ( $user ) {\n    update_user_meta( $user->ID, 'wpide_password_changed', time() );\n} );\n\nadd_action( 'profile_update', function ( $user_id, $old_user ) {\n    $user = get_userdata( $user_id );\n\n    if ( $user && $old_user && $user->user_pass !== $old_user->user_pass ) {\n        update_user_meta( $user_id, 'wpide_password_changed', time() );\n    }\n}, 10, 2 );\n\nadd_action( 'admin_notices', function () use ( $wpide_password_max_age ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $user_id = get_current_user_id();\n    $changed = (int) get_user_meta( $user_id, 'wpide_password_changed', true );\n\n    if ( ! $changed ) {\n        update_user_meta( $user_id, 'wpide_password_changed', time() ); // Start counting from today.\n        return;\n    }\n\n    if ( time() - $changed > $wpide_password_max_age * DAY_IN_SECONDS ) {\n        printf(\n            '<div class=\"notice notice-warning\"><p>You have not changed your password in over %1$d days. <a href=\"%2$s\">Choose a new one</a>.</p></div>',\n            (int) $wpide_password_max_age,\n            esc_url( admin_url( 'profile.php#password' ) )\n        );\n    }\n} );",
            "requires": [],
            "notes": "The count starts the first time each administrator opens the dashboard after activation."
        },
        {
            "id": "warn-too-many-administrators",
            "version": 1,
            "title": "Warn When There Are Too Many Administrators",
            "description": "Shows a dashboard notice listing the administrator accounts when there are more than you expect, a prompt to downgrade old or unused ones.",
            "category": "security",
            "tags": [
                "security",
                "users",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Highest number of administrators you consider normal.\n$wpide_max_admins = 3;\n\nadd_action( 'admin_notices', function () use ( $wpide_max_admins ) {\n    $screen = get_current_screen();\n\n    if ( ! $screen || 'dashboard' !== $screen->id || ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    $admins = get_users( array( 'role' => 'administrator', 'fields' => array( 'user_login' ), 'number' => 50 ) );\n\n    if ( count( $admins ) <= $wpide_max_admins ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-warning\"><p><strong>%1$d administrator accounts:</strong> %2$s. Give people the lowest role they need. <a href=\"%3$s\">Review administrators</a>.</p></div>',\n        count( $admins ),\n        esc_html( implode( ', ', wp_list_pluck( $admins, 'user_login' ) ) ),\n        esc_url( admin_url( 'users.php?role=administrator' ) )\n    );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "warn-public-debug-log",
            "version": 1,
            "title": "Warn About a Public debug.log File",
            "description": "Alerts administrators when WordPress writes errors to wp-content/debug.log, a file anyone can usually download and read.",
            "category": "security",
            "tags": [
                "security",
                "debug",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_notices', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $file = WP_CONTENT_DIR . '/debug.log';\n\n    if ( ! file_exists( $file ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-warning\"><p><strong>Public debug log found</strong> (%1$s). Visitors may be able to read it at /wp-content/debug.log. Delete it and set WP_DEBUG_LOG to a path outside the web root in wp-config.php.</p></div>',\n        esc_html( size_format( (int) filesize( $file ) ) )\n    );\n} );",
            "requires": [],
            "notes": "Some hosts block access to .log files; the notice cannot tell, so check the URL yourself."
        },
        {
            "id": "rate-limit-rest-api-visitors",
            "version": 1,
            "title": "Rate-Limit the REST API for Visitors",
            "description": "Limits how many REST API requests a logged-out visitor can make per minute and answers \"429 Too Many Requests\" beyond that, slowing scrapers and scanners.",
            "category": "security",
            "tags": [
                "security",
                "rest-api",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Requests per minute per IP address for logged-out visitors.\n$wpide_rest_limit = 120;\n\nadd_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) use ( $wpide_rest_limit ) {\n    if ( null !== $result || is_user_logged_in() || empty( $_SERVER['REMOTE_ADDR'] ) ) {\n        return $result;\n    }\n\n    $key  = 'wpide_rest_' . md5( (string) $_SERVER['REMOTE_ADDR'] . '|' . floor( time() / 60 ) );\n    $hits = (int) get_transient( $key ) + 1;\n    set_transient( $key, $hits, 2 * MINUTE_IN_SECONDS );\n\n    if ( $hits > $wpide_rest_limit ) {\n        return new WP_Error( 'wpide_rate_limited', 'Too many requests. Please slow down.', array( 'status' => 429 ) );\n    }\n\n    return $result;\n}, 10, 3 );",
            "requires": [],
            "notes": "Writes a short-lived transient per visitor per minute; use it with a persistent object cache on busy sites."
        },
        {
            "id": "block-fake-googlebot",
            "version": 1,
            "title": "Block Fake Googlebot Visits",
            "description": "Checks visitors that claim to be Googlebot with a reverse DNS lookup and blocks the impostors, which are usually scrapers or vulnerability scanners.",
            "category": "security",
            "tags": [
                "security",
                "bots",
                "dns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? (string) $_SERVER['HTTP_USER_AGENT'] : '';\n    $ip    = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';\n\n    if ( false === stripos( $agent, 'Googlebot' ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {\n        return;\n    }\n\n    $key     = 'wpide_gbot_' . md5( $ip );\n    $genuine = get_transient( $key );\n\n    if ( false === $genuine ) {\n        $host    = gethostbyaddr( $ip );\n        $forward = array();\n\n        if ( $host && preg_match( '/\\.(googlebot|google)\\.com$/i', $host ) ) {\n            if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n                $forward = (array) gethostbynamel( $host );\n            } else {\n                foreach ( (array) dns_get_record( $host, DNS_AAAA ) as $record ) {\n                    if ( ! empty( $record['ipv6'] ) ) {\n                        $forward[] = inet_ntop( inet_pton( $record['ipv6'] ) );\n                    }\n                }\n                $ip = inet_ntop( inet_pton( $ip ) );\n            }\n        }\n\n        $genuine = in_array( $ip, $forward, true ) ? 'yes' : 'no';\n        set_transient( $key, $genuine, DAY_IN_SECONDS );\n    }\n\n    if ( 'no' === $genuine ) {\n        status_header( 403 );\n        exit( 'Forbidden' );\n    }\n}, 0 );",
            "requires": [],
            "notes": "The DNS check runs once per IP per day and adds a short delay to that first request."
        },
        {
            "id": "secure-checkout-reassurance",
            "version": 1,
            "title": "Secure Checkout Note Above the Pay Button",
            "description": "Reassures shoppers right above the WooCommerce payment button that the connection is encrypted and card details are handled by the payment provider.",
            "category": "security",
            "tags": [
                "security",
                "woocommerce",
                "checkout"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_checkout_order_review_19",
            "insert_number": 1,
            "code": "<p class=\"wpide-secure-checkout\" style=\"display:flex;gap:.5em;align-items:center;margin:1em 0;font-size:.9em;opacity:.85\">\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" d=\"M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 1 1 6 0v3H9z\"/></svg>\n    Secure checkout: your connection is encrypted and card details go straight to our payment provider. We never store them.\n</p>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only keep the last sentence if it is true for the payment methods you use."
        },
        {
            "id": "memberpress-password-tip",
            "version": 1,
            "title": "Strong Password Tip on the MemberPress Sign-Up Form",
            "description": "Adds a short tip about choosing a strong, unique password just above the MemberPress checkout button.",
            "category": "security",
            "tags": [
                "security",
                "memberpress",
                "passwords"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "mepr-checkout-before-submit",
            "insert_number": 1,
            "code": "<p class=\"wpide-mepr-password-tip\" style=\"font-size:.9em;opacity:.85\">\n    Tip: use a password you do not use anywhere else, at least 12 characters long. A password manager makes this easy.\n</p>",
            "requires": [
                "memberpress"
            ],
            "notes": ""
        },
        {
            "id": "load-block-styles-separately",
            "version": 1,
            "title": "Load Block Styles Only for Blocks on the Page",
            "description": "Replaces the single large block stylesheet with small per-block styles that load only when a block is actually used, which helps classic themes most.",
            "category": "performance",
            "tags": [
                "performance",
                "css",
                "blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'should_load_separate_core_block_assets', '__return_true' );",
            "requires": [],
            "notes": "Block themes already do this; on classic themes, check pages with widgets or page builders afterwards."
        },
        {
            "id": "woo-disable-cart-fragments",
            "version": 1,
            "title": "Disable WooCommerce Cart Fragments Outside the Shop",
            "description": "Stops the cart fragments script, which fires an extra uncached AJAX request on every page, everywhere except the shop, product, cart and checkout pages.",
            "category": "performance",
            "tags": [
                "performance",
                "woocommerce",
                "ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! function_exists( 'is_woocommerce' ) ) {\n        return;\n    }\n\n    if ( is_woocommerce() || is_cart() || is_checkout() || is_account_page() ) {\n        return;\n    }\n\n    wp_dequeue_script( 'wc-cart-fragments' );\n}, 999 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "A mini-cart in the header will not update live on other pages until the visitor reaches a shop page."
        },
        {
            "id": "preload-featured-image",
            "version": 1,
            "title": "Preload the Featured Image on Single Posts",
            "description": "Tells the browser to fetch the featured image of the current post or page right away, which improves Largest Contentful Paint when it is shown at the top.",
            "category": "performance",
            "tags": [
                "performance",
                "images",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// The image size your theme shows at the top of single posts.\n$wpide_preload_image_size = 'large';\n\nadd_filter( 'wp_preload_resources', function ( $resources ) use ( $wpide_preload_image_size ) {\n    if ( ! is_singular() || ! has_post_thumbnail() ) {\n        return $resources;\n    }\n\n    $image_id = get_post_thumbnail_id();\n    $image    = wp_get_attachment_image_src( $image_id, $wpide_preload_image_size );\n\n    if ( ! $image ) {\n        return $resources;\n    }\n\n    $resource = array(\n        'href'          => $image[0],\n        'as'            => 'image',\n        'fetchpriority' => 'high',\n    );\n\n    $srcset = wp_get_attachment_image_srcset( $image_id, $wpide_preload_image_size );\n    if ( $srcset ) {\n        $resource['imagesrcset'] = $srcset;\n        $resource['imagesizes']  = (string) wp_get_attachment_image_sizes( $image_id, $wpide_preload_image_size );\n    }\n\n    $resources[] = $resource;\n\n    return $resources;\n} );",
            "requires": [],
            "notes": "Skip this if your theme shows the featured image lower on the page; preloading an image that is not above the fold wastes bandwidth."
        },
        {
            "id": "preload-web-fonts",
            "version": 1,
            "title": "Preload Key Web Fonts",
            "description": "Preloads the font files used above the fold, so text appears in the right font sooner and the layout shifts less.",
            "category": "performance",
            "tags": [
                "performance",
                "fonts",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Full URLs of the WOFF2 files to preload (usually one or two: body text and headings).\n$wpide_preload_fonts = array(\n    get_stylesheet_directory_uri() . '/assets/fonts/inter-regular.woff2',\n);\n\nadd_filter( 'wp_preload_resources', function ( $resources ) use ( $wpide_preload_fonts ) {\n    foreach ( $wpide_preload_fonts as $url ) {\n        $resources[] = array(\n            'href'        => $url,\n            'as'          => 'font',\n            'type'        => 'font/woff2',\n            'crossorigin' => 'anonymous',\n        );\n    }\n\n    return $resources;\n} );",
            "requires": [],
            "notes": "Copy the exact font URLs from your browser's Network panel and preload no more than one or two."
        },
        {
            "id": "empty-trash-sooner",
            "version": 1,
            "title": "Empty the Trash After 7 Days",
            "description": "Permanently removes trashed posts, pages and comments after 7 days instead of 30, keeping the database lean.",
            "category": "performance",
            "tags": [
                "performance",
                "database",
                "trash"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days before trashed items are deleted for good (0 disables the trash entirely, so avoid it).\nif ( ! defined( 'EMPTY_TRASH_DAYS' ) ) {\n    define( 'EMPTY_TRASH_DAYS', 7 );\n}",
            "requires": [],
            "notes": "Has no effect if EMPTY_TRASH_DAYS is already set in wp-config.php."
        },
        {
            "id": "async-load-stylesheets",
            "version": 1,
            "title": "Load Selected Stylesheets Without Blocking Rendering",
            "description": "Loads the stylesheets you choose in the background so they no longer delay the first paint, ideal for CSS of things below the fold like sliders or forms.",
            "category": "performance",
            "tags": [
                "performance",
                "css",
                "render-blocking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Style handles to load asynchronously. Find handles in the page source: id=\"HANDLE-css\".\n$wpide_async_styles = array( 'contact-form-7' );\n\nadd_filter( 'style_loader_tag', function ( $tag, $handle ) use ( $wpide_async_styles ) {\n    if ( ! in_array( $handle, $wpide_async_styles, true ) || false !== strpos( $tag, 'onload=' ) ) {\n        return $tag;\n    }\n\n    $async = preg_replace( \"/media=(['\\\"])(all|screen)\\\\1/\", 'media=\"print\" onload=\"this.media=\\'all\\';this.onload=null;\"', $tag, 1, $count );\n\n    return $count ? $async . '<noscript>' . $tag . '</noscript>' : $tag;\n}, 10, 2 );",
            "requires": [],
            "notes": "Do not use it for the main theme stylesheet, or visitors will briefly see unstyled content."
        },
        {
            "id": "move-scripts-to-footer",
            "version": 1,
            "title": "Move Selected Scripts to the Footer",
            "description": "Moves the scripts you list from the page head to the footer, so they stop blocking the page from rendering.",
            "category": "performance",
            "tags": [
                "performance",
                "javascript",
                "render-blocking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Script handles to print in the footer. Find handles in the page source: id=\"HANDLE-js\".\n$wpide_footer_scripts = array( 'some-slider', 'some-plugin-frontend' );\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_footer_scripts ) {\n    $scripts = wp_scripts();\n\n    foreach ( $wpide_footer_scripts as $handle ) {\n        if ( isset( $scripts->registered[ $handle ] ) ) {\n            $scripts->add_data( $handle, 'group', 1 );\n        }\n    }\n}, 999 );",
            "requires": [],
            "notes": "A script stays in the head if another head script depends on it, so test interactive features afterwards."
        },
        {
            "id": "disable-wp-cron-page-loads",
            "version": 1,
            "title": "Run WP-Cron From a Real Cron Job",
            "description": "Stops WordPress from checking for scheduled tasks on every page view; you trigger wp-cron.php from your server's cron instead, which is faster and more reliable.",
            "category": "performance",
            "tags": [
                "performance",
                "cron",
                "server"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// After enabling, add a server cron job, for example every 5 minutes:\n// */5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null\nif ( ! defined( 'DISABLE_WP_CRON' ) ) {\n    define( 'DISABLE_WP_CRON', true );\n}",
            "requires": [],
            "notes": "Without the server cron job, scheduled posts, backups and emails stop running."
        },
        {
            "id": "heartbeat-editor-only",
            "version": 1,
            "title": "Disable the Heartbeat API Except in the Post Editor",
            "description": "Keeps the Heartbeat API (autosave and post locking) in the editor but turns it off on every other admin and front-end page to save server requests.",
            "category": "performance",
            "tags": [
                "performance",
                "heartbeat",
                "admin-ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_enqueue_scripts', function ( $hook ) {\n    if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {\n        wp_deregister_script( 'heartbeat' );\n    }\n}, 1 );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    wp_deregister_script( 'heartbeat' );\n}, 1 );",
            "requires": [],
            "notes": "Some plugins rely on Heartbeat on their own screens; and the \"session expired\" login prompt only appears in the editor."
        },
        {
            "id": "remove-google-fonts",
            "version": 1,
            "title": "Remove Google Fonts Loaded by Themes and Plugins",
            "description": "Dequeues every stylesheet loaded from Google Fonts and removes the related preconnect hints, for speed and GDPR compliance.",
            "category": "performance",
            "tags": [
                "performance",
                "fonts",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_drop_google_fonts = function () {\n    $styles = wp_styles();\n\n    foreach ( $styles->registered as $handle => $style ) {\n        if ( is_string( $style->src ) && false !== strpos( $style->src, 'fonts.googleapis.com' ) ) {\n            wp_dequeue_style( $handle );\n            wp_deregister_style( $handle );\n        }\n    }\n};\n\nadd_action( 'wp_enqueue_scripts', $wpide_drop_google_fonts, 999 );\nadd_action( 'wp_print_styles', $wpide_drop_google_fonts, 1 );\n\nadd_filter( 'wp_resource_hints', function ( $urls ) {\n    return array_values( array_filter( $urls, function ( $url ) {\n        $href = is_array( $url ) ? ( isset( $url['href'] ) ? $url['href'] : '' ) : $url;\n\n        return false === strpos( (string) $href, 'fonts.googleapis.com' ) && false === strpos( (string) $href, 'fonts.gstatic.com' );\n    } ) );\n}, 99 );",
            "requires": [],
            "notes": "Your theme falls back to system fonts unless you host the fonts yourself, and page builders that inline fonts need their own setting."
        },
        {
            "id": "speed-up-custom-fields-box",
            "version": 1,
            "title": "Speed Up the Custom Fields Meta Box",
            "description": "Skips the slow database query that fills the Custom Fields key dropdown, which can freeze the classic editor on sites with lots of post meta.",
            "category": "performance",
            "tags": [
                "performance",
                "admin",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Keys to offer in the dropdown. Leave empty to show a plain text field for the key.\n$wpide_custom_field_keys = array();\n\nadd_filter( 'postmeta_form_keys', function ( $keys ) use ( $wpide_custom_field_keys ) {\n    return null === $keys ? $wpide_custom_field_keys : $keys;\n} );",
            "requires": [],
            "notes": "Existing custom fields on a post are still listed and editable."
        },
        {
            "id": "remove-admin-months-dropdown",
            "version": 1,
            "title": "Remove the Months Filter From Admin Lists",
            "description": "Drops the \"All dates\" dropdown from post and media lists, saving a slow query on sites with many thousands of posts or files.",
            "category": "performance",
            "tags": [
                "performance",
                "admin",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'disable_months_dropdown', '__return_true' );\n\n// The media library runs the same kind of query for its date filter.\nadd_filter( 'media_library_months_with_files', '__return_empty_array' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "click-to-play-youtube",
            "version": 1,
            "title": "Click-to-Play YouTube Embeds",
            "description": "Shows a lightweight thumbnail instead of the full YouTube player until the visitor clicks play, saving around a megabyte of scripts per video.",
            "category": "performance",
            "tags": [
                "performance",
                "video",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'embed_oembed_html', function ( $html, $url ) {\n    if ( is_feed() || wp_is_json_request() || ! preg_match( '#(?:youtube\\.com/(?:watch\\?v=|embed/|shorts/)|youtu\\.be/)([A-Za-z0-9_-]{11})#', $url, $match ) ) {\n        return $html;\n    }\n\n    $id    = $match[1];\n    $title = preg_match( '/title=\"([^\"]*)\"/', $html, $t ) ? html_entity_decode( $t[1], ENT_QUOTES ) : 'YouTube video';\n\n    $GLOBALS['wpide_lite_youtube_used'] = true;\n\n    return sprintf(\n        '<div class=\"wpide-yt\" data-id=\"%1$s\" data-title=\"%2$s\" style=\"position:relative;aspect-ratio:16/9;background:#000 url(https://i.ytimg.com/vi/%1$s/hqdefault.jpg) center/cover no-repeat;cursor:pointer\">' .\n        '<button type=\"button\" aria-label=\"%3$s\" style=\"position:absolute;inset:0;margin:auto;width:68px;height:48px;border:0;border-radius:12px;background:rgba(255,0,0,.9);color:#fff;font-size:22px;cursor:pointer\">&#9658;</button></div>',\n        esc_attr( $id ),\n        esc_attr( $title ),\n        esc_attr( 'Play: ' . $title )\n    );\n}, 10, 2 );\n\nadd_action( 'wp_footer', function () {\n    if ( empty( $GLOBALS['wpide_lite_youtube_used'] ) ) {\n        return;\n    }\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var box = event.target.closest( '.wpide-yt' );\n        if ( ! box ) {\n            return;\n        }\n        var frame = document.createElement( 'iframe' );\n        frame.src = 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent( box.dataset.id ) + '?autoplay=1';\n        frame.title = box.dataset.title;\n        frame.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';\n        frame.allowFullscreen = true;\n        frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0';\n        box.innerHTML = '';\n        box.appendChild( frame );\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Works for YouTube links in posts and Embed blocks, loading the thumbnail from i.ytimg.com and the player from youtube-nocookie.com."
        },
        {
            "id": "cache-headers-for-visitors",
            "version": 1,
            "title": "Browser and CDN Caching Headers for Visitors",
            "description": "Sends Cache-Control headers on public pages for logged-out visitors, so a CDN or proxy can cache them for a few minutes.",
            "category": "performance",
            "tags": [
                "performance",
                "caching",
                "cdn"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Seconds browsers (max-age) and shared caches like CDNs (s-maxage) may reuse a page.\n$wpide_browser_cache = 300;\n$wpide_cdn_cache     = 600;\n\nadd_action( 'template_redirect', function () use ( $wpide_browser_cache, $wpide_cdn_cache ) {\n    if ( headers_sent() || is_user_logged_in() || is_search() || is_404() || is_preview() || post_password_required() ) {\n        return;\n    }\n\n    if ( 'GET' !== ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '' ) || ! empty( $_COOKIE['woocommerce_items_in_cart'] ) ) {\n        return;\n    }\n\n    if ( function_exists( 'is_woocommerce' ) && ( is_cart() || is_checkout() || is_account_page() ) ) {\n        return;\n    }\n\n    foreach ( array_keys( $_COOKIE ) as $cookie ) {\n        if ( 0 === strpos( $cookie, 'comment_author_' ) || 0 === strpos( $cookie, 'wp-postpass_' ) ) {\n            return;\n        }\n    }\n\n    foreach ( headers_list() as $header ) {\n        if ( 0 === stripos( $header, 'Cache-Control:' ) ) {\n            return; // Something else already decided.\n        }\n    }\n\n    header( sprintf( 'Cache-Control: public, max-age=%d, s-maxage=%d', $wpide_browser_cache, $wpide_cdn_cache ) );\n}, 99 );",
            "requires": [],
            "notes": "Only useful with a CDN or caching proxy in front of the site, and pages with personal content for visitors must be excluded."
        },
        {
            "id": "skip-slow-site-health-tests",
            "version": 1,
            "title": "Skip Slow Site Health Tests",
            "description": "Removes the Site Health checks that make HTTP requests back to your own site, which are slow or time out on some hosts.",
            "category": "performance",
            "tags": [
                "performance",
                "site-health",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Test names to skip.\n$wpide_skip_health_tests = array( 'loopback_requests', 'https_status', 'authorization_header', 'background_updates', 'dotorg_communication', 'page_cache' );\n\nadd_filter( 'site_status_tests', function ( $tests ) use ( $wpide_skip_health_tests ) {\n    foreach ( array( 'direct', 'async' ) as $group ) {\n        if ( isset( $tests[ $group ] ) ) {\n            $tests[ $group ] = array_diff_key( $tests[ $group ], array_flip( $wpide_skip_health_tests ) );\n        }\n    }\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Skipped tests are simply not shown, so problems they would detect stay hidden."
        },
        {
            "id": "raise-admin-memory-limit",
            "version": 1,
            "title": "Raise the Memory Limit for Admin Tasks",
            "description": "Gives memory-hungry admin tasks like image processing and imports more room, without raising the limit for every front-end visit.",
            "category": "performance",
            "tags": [
                "performance",
                "memory",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Memory available to admin screens, image editing and similar heavy tasks.\n$wpide_admin_memory = '512M';\n\nadd_filter( 'admin_memory_limit', function () use ( $wpide_admin_memory ) {\n    return $wpide_admin_memory;\n} );\n\nadd_filter( 'image_memory_limit', function () use ( $wpide_admin_memory ) {\n    return $wpide_admin_memory;\n} );",
            "requires": [],
            "notes": "Your host's PHP limit still applies; this cannot go above it."
        },
        {
            "id": "cap-srcset-image-width",
            "version": 1,
            "title": "Cap the Largest Image Size in srcset",
            "description": "Stops browsers on large screens from downloading huge image versions by leaving very wide sizes out of the responsive srcset list.",
            "category": "performance",
            "tags": [
                "performance",
                "images",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Widest image (in pixels) a browser may pick from srcset.\n$wpide_max_srcset_width = 1600;\n\nadd_filter( 'max_srcset_image_width', function () use ( $wpide_max_srcset_width ) {\n    return $wpide_max_srcset_width;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "fast-404-static-files",
            "version": 1,
            "title": "Fast 404 for Missing Images, Scripts and Styles",
            "description": "Answers requests for missing images, CSS, JavaScript and font files with a tiny 404 instead of loading a full theme page, saving server time.",
            "category": "performance",
            "tags": [
                "performance",
                "404",
                "server"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// File extensions that should never be handled by WordPress pages.\n$wpide_static_extensions = array( 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'css', 'js', 'map', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'mp4', 'webm', 'mp3' );\n\nif ( ! is_admin() && ! ( defined( 'WP_CLI' ) && WP_CLI ) && isset( $_SERVER['REQUEST_URI'] ) ) {\n    $wpide_path = (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH );\n    $wpide_ext  = strtolower( pathinfo( $wpide_path, PATHINFO_EXTENSION ) );\n\n    if ( $wpide_ext && in_array( $wpide_ext, $wpide_static_extensions, true ) ) {\n        status_header( 404 );\n        nocache_headers();\n        header( 'Content-Type: text/plain; charset=utf-8' );\n        exit( 'Not found' );\n    }\n}",
            "requires": [],
            "notes": "Do not use it with plugins that create image sizes on the fly when a file is missing."
        },
        {
            "id": "comment-reply-script-when-needed",
            "version": 1,
            "title": "Load comment-reply.js Only When Needed",
            "description": "Stops the threaded comments script from loading on pages that have no open comments or where threading is turned off.",
            "category": "performance",
            "tags": [
                "performance",
                "comments",
                "javascript"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    $needed = is_singular() && comments_open() && get_option( 'thread_comments' );\n\n    if ( ! $needed ) {\n        wp_dequeue_script( 'comment-reply' );\n    }\n}, 100 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "skip-pdf-preview-images",
            "version": 1,
            "title": "Skip PDF Preview Images on Upload",
            "description": "Stops WordPress from generating preview images for every uploaded PDF, which speeds up uploads and saves disk space.",
            "category": "performance",
            "tags": [
                "performance",
                "media",
                "pdf"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array' );",
            "requires": [],
            "notes": "PDFs then show a generic document icon in the Media Library."
        },
        {
            "id": "cf7-assets-only-with-forms",
            "version": 1,
            "title": "Load Contact Form 7 Assets Only Where a Form Is Used",
            "description": "Keeps Contact Form 7 scripts and styles off every page except those that contain a form, removing two requests from most pages.",
            "category": "performance",
            "tags": [
                "performance",
                "forms",
                "contact-form-7"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of pages that show a form outside the post content (in a widget or the footer, for example).\n$wpide_cf7_extra_pages = array();\n\nadd_filter( 'wpcf7_load_js', '__return_false' );\nadd_filter( 'wpcf7_load_css', '__return_false' );\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_cf7_extra_pages ) {\n    if ( ! function_exists( 'wpcf7_enqueue_scripts' ) ) {\n        return;\n    }\n\n    $post = get_post();\n    $uses = $post && is_singular() && ( has_shortcode( $post->post_content, 'contact-form-7' ) || has_block( 'contact-form-7/contact-form-selector', $post ) );\n\n    if ( $uses || ( $wpide_cf7_extra_pages && is_page( $wpide_cf7_extra_pages ) ) ) {\n        wpcf7_enqueue_scripts();\n        if ( function_exists( 'wpcf7_enqueue_styles' ) ) {\n            wpcf7_enqueue_styles();\n        }\n    }\n}, 20 );",
            "requires": [],
            "notes": "Does nothing unless Contact Form 7 is active."
        },
        {
            "id": "woo-disable-admin-features",
            "version": 1,
            "title": "Disable WooCommerce Analytics and Admin Extras",
            "description": "Turns off WooCommerce Analytics, the React-based admin home, onboarding and marketing screens, which noticeably speeds up the admin on smaller stores.",
            "category": "performance",
            "tags": [
                "performance",
                "woocommerce",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_admin_disabled', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Orders, products and the classic reports keep working; Analytics pages disappear."
        },
        {
            "id": "preload-site-logo",
            "version": 1,
            "title": "Preload the Site Logo",
            "description": "Preloads the logo set in the theme or Site Editor so it appears with the first paint of the header.",
            "category": "performance",
            "tags": [
                "performance",
                "images",
                "logo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_preload_resources', function ( $resources ) {\n    $logo_id = (int) get_theme_mod( 'custom_logo' );\n    $logo_id = $logo_id ? $logo_id : (int) get_option( 'site_logo' );\n    $image   = $logo_id ? wp_get_attachment_image_src( $logo_id, 'full' ) : false;\n\n    if ( $image ) {\n        $resources[] = array(\n            'href'          => $image[0],\n            'as'            => 'image',\n            'fetchpriority' => 'high',\n        );\n    }\n\n    return $resources;\n} );",
            "requires": [],
            "notes": "Best for logos that are images (PNG, JPG, WebP); inline SVG logos need no preloading."
        },
        {
            "id": "cache-oembed-longer",
            "version": 1,
            "title": "Cache oEmbed Results Longer",
            "description": "Keeps embedded content like videos and social posts cached for 30 days instead of re-fetching it from the provider every day.",
            "category": "performance",
            "tags": [
                "performance",
                "embeds",
                "caching"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How long embed data stays cached.\n$wpide_oembed_ttl = 30 * DAY_IN_SECONDS;\n\nadd_filter( 'oembed_ttl', function () use ( $wpide_oembed_ttl ) {\n    return $wpide_oembed_ttl;\n} );",
            "requires": [],
            "notes": "Changes to an embedded item (a renamed video, for example) take longer to show."
        },
        {
            "id": "cache-user-counts",
            "version": 1,
            "title": "Cache User Counts on Large Sites",
            "description": "Caches the per-role user counts shown on the Users screen and dashboard for 12 hours, avoiding a heavy query on sites with many users.",
            "category": "performance",
            "tags": [
                "performance",
                "users",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_count_users', function ( $result, $strategy, $site_id ) {\n    static $busy = false;\n\n    if ( null !== $result || $busy ) {\n        return $result;\n    }\n\n    $key    = 'wpide_user_counts_' . (int) $site_id . '_' . $strategy;\n    $cached = get_transient( $key );\n\n    if ( is_array( $cached ) ) {\n        return $cached;\n    }\n\n    $busy   = true;\n    $counts = count_users( $strategy, $site_id );\n    $busy   = false;\n\n    set_transient( $key, $counts, 12 * HOUR_IN_SECONDS );\n\n    return $counts;\n}, 10, 3 );\n\n$wpide_flush_user_counts = function () {\n    foreach ( array( 'time', 'memory' ) as $strategy ) {\n        delete_transient( 'wpide_user_counts_' . get_current_blog_id() . '_' . $strategy );\n    }\n};\n\nadd_action( 'user_register', $wpide_flush_user_counts );\nadd_action( 'deleted_user', $wpide_flush_user_counts );\nadd_action( 'set_user_role', $wpide_flush_user_counts );",
            "requires": [],
            "notes": "Counts refresh when users are added, deleted or change role, and at least every 12 hours."
        },
        {
            "id": "elementor-no-google-fonts",
            "version": 1,
            "title": "Stop Elementor From Loading Google Fonts",
            "description": "Prevents Elementor from requesting fonts from Google's servers, for faster pages and fewer third-party connections.",
            "category": "performance",
            "tags": [
                "performance",
                "fonts",
                "elementor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'elementor/frontend/print_google_fonts', '__return_false' );",
            "requires": [],
            "notes": "Elementor designs fall back to system fonts unless you upload them as custom fonts (no effect without Elementor)."
        },
        {
            "id": "spawn-wp-cron-less-often",
            "version": 1,
            "title": "Spawn WP-Cron Less Often",
            "description": "Lets WordPress start its background task runner at most every five minutes instead of every minute, reducing extra requests on busy sites.",
            "category": "performance",
            "tags": [
                "performance",
                "cron",
                "requests"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum seconds between two WP-Cron runs.\nif ( ! defined( 'WP_CRON_LOCK_TIMEOUT' ) ) {\n    define( 'WP_CRON_LOCK_TIMEOUT', 5 * MINUTE_IN_SECONDS );\n}",
            "requires": [],
            "notes": "Scheduled tasks can run up to five minutes late."
        },
        {
            "id": "click-to-load-google-maps",
            "version": 1,
            "title": "Click-to-Load Google Maps Embeds",
            "description": "Replaces embedded Google Maps in your content with a placeholder that loads the map only when clicked, for faster pages and fewer third-party cookies.",
            "category": "performance",
            "tags": [
                "performance",
                "maps",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === strpos( $content, 'google.com/maps' ) || is_feed() ) {\n        return $content;\n    }\n\n    $count   = 0;\n    $content = preg_replace_callback( '#<iframe[^>]+src=([\"\\'])(https://www\\.google\\.com/maps/embed[^\"\\']*)\\1[^>]*>\\s*</iframe>#i', function ( $m ) {\n        $width  = preg_match( '/width=[\"\\']?(\\d+)/', $m[0], $w ) ? (int) $w[1] : 4;\n        $height = preg_match( '/height=[\"\\']?(\\d+)/', $m[0], $h ) ? (int) $h[1] : 3;\n\n        return sprintf(\n            '<div class=\"wpide-map\" data-src=\"%1$s\" style=\"position:relative;aspect-ratio:%2$d/%3$d;max-width:100%%;background:#e8eaed;display:flex;align-items:center;justify-content:center\"><button type=\"button\" class=\"wp-element-button\">Show map</button></div>',\n            esc_attr( html_entity_decode( $m[2], ENT_QUOTES ) ),\n            max( 1, $width ),\n            max( 1, $height )\n        );\n    }, $content, -1, $count );\n\n    if ( $count ) {\n        $GLOBALS['wpide_lazy_maps_used'] = true;\n    }\n\n    return $content;\n}, 20 );\n\nadd_action( 'wp_footer', function () {\n    if ( empty( $GLOBALS['wpide_lazy_maps_used'] ) ) {\n        return;\n    }\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var box = event.target.closest( '.wpide-map' );\n        if ( ! box || 0 !== box.dataset.src.indexOf( 'https://www.google.com/maps/embed' ) ) {\n            return;\n        }\n        var frame = document.createElement( 'iframe' );\n        frame.src = box.dataset.src;\n        frame.loading = 'lazy';\n        frame.referrerPolicy = 'no-referrer-when-downgrade';\n        frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0';\n        box.innerHTML = '';\n        box.appendChild( frame );\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Only maps pasted as embed iframes in post content are replaced."
        },
        {
            "id": "unload-assets-except-on-pages",
            "version": 1,
            "title": "Unload Plugin Assets Except on the Pages That Need Them",
            "description": "Removes the scripts and styles you list from every page except the ones you choose, for plugins that load their files site-wide.",
            "category": "performance",
            "tags": [
                "performance",
                "assets",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// 'script:handle' or 'style:handle' => page IDs or slugs where the file is still needed.\n$wpide_assets_only_on = array(\n    'style:some-gallery-style'   => array( 'gallery' ),\n    'script:some-booking-script' => array( 'book-now', 42 ),\n);\n\n$wpide_unload_assets = function () use ( $wpide_assets_only_on ) {\n    foreach ( $wpide_assets_only_on as $asset => $pages ) {\n        list( $type, $handle ) = array_pad( explode( ':', $asset, 2 ), 2, '' );\n\n        if ( '' === $handle || ( $pages && ( is_page( $pages ) || is_single( $pages ) ) ) ) {\n            continue;\n        }\n\n        if ( 'script' === $type ) {\n            wp_dequeue_script( $handle );\n        } else {\n            wp_dequeue_style( $handle );\n        }\n    }\n};\n\nadd_action( 'wp_enqueue_scripts', $wpide_unload_assets, 999 );\nadd_action( 'wp_print_footer_scripts', $wpide_unload_assets, 1 );",
            "requires": [],
            "notes": "Find handles in the page source (id=\"HANDLE-css\" or id=\"HANDLE-js\") and test the pages that still need them."
        },
        {
            "id": "revisions-per-post-type",
            "version": 1,
            "title": "Revision Limits per Post Type",
            "description": "Keeps a different number of revisions for each content type, for example many for posts and none for products.",
            "category": "performance",
            "tags": [
                "performance",
                "revisions",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => revisions to keep (0 = none, -1 = unlimited). Other types keep the site default.\n$wpide_revisions_by_type = array(\n    'post'    => 10,\n    'page'    => 5,\n    'product' => 0,\n);\n\nadd_filter( 'wp_revisions_to_keep', function ( $num, $post ) use ( $wpide_revisions_by_type ) {\n    return isset( $wpide_revisions_by_type[ $post->post_type ] ) ? (int) $wpide_revisions_by_type[ $post->post_type ] : $num;\n}, 10, 2 );",
            "requires": [],
            "notes": "Older revisions beyond the limit are removed the next time the item is saved."
        },
        {
            "id": "disable-customizer",
            "version": 1,
            "title": "Disable the Customizer",
            "description": "Removes the Customizer for every user, including its admin menu and admin bar links, on sites that are configured elsewhere.",
            "category": "disable-features",
            "tags": [
                "disable",
                "customizer",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap ) {\n    if ( 'customize' === $cap ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 2 );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    $wp_admin_bar->remove_node( 'customize' );\n}, 999 );",
            "requires": [],
            "notes": "Classic themes that keep their logo, colors or menus in the Customizer lose access to those settings."
        },
        {
            "id": "disable-post-categories",
            "version": 1,
            "title": "Disable Categories for Posts",
            "description": "Removes categories from posts: the Categories menu, the editor panel and the column in the posts list.",
            "category": "disable-features",
            "tags": [
                "disable",
                "categories",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    unregister_taxonomy_for_object_type( 'category', 'post' );\n}, 99 );",
            "requires": [],
            "notes": "Existing assignments stay in the database; do not use it if your permalinks include %category%."
        },
        {
            "id": "disable-post-formats",
            "version": 1,
            "title": "Disable Post Formats",
            "description": "Removes the Post Format option (aside, gallery, quote and so on) from the editor when your theme supports formats you do not use.",
            "category": "disable-features",
            "tags": [
                "disable",
                "post-formats",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    remove_theme_support( 'post-formats' );\n}, 100 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-posts-pages-only",
            "version": 1,
            "title": "Disable Posts for a Pages-Only Site",
            "description": "Hides the built-in Posts type everywhere in the admin and on the front end, for brochure sites that only use pages.",
            "category": "disable-features",
            "tags": [
                "disable",
                "posts",
                "brochure"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'register_post_type_args', function ( $args, $post_type ) {\n    if ( 'post' !== $post_type ) {\n        return $args;\n    }\n\n    return array_merge( $args, array(\n        'public'              => false,\n        'publicly_queryable'  => false,\n        'exclude_from_search' => true,\n        'show_ui'             => false,\n        'show_in_menu'        => false,\n        'show_in_admin_bar'   => false,\n        'show_in_nav_menus'   => false,\n    ) );\n}, 10, 2 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'dashboard_quick_press', 'dashboard', 'side' );\n}, 99 );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    $wp_admin_bar->remove_node( 'new-post' );\n}, 999 );",
            "requires": [],
            "notes": "Existing posts return 404, so set a static homepage in Settings > Reading first."
        },
        {
            "id": "minor-core-updates-only",
            "version": 1,
            "title": "Allow Only Minor Core Auto-Updates",
            "description": "Keeps automatic security and maintenance releases of WordPress (like 6.8.1 to 6.8.2) but stops automatic major upgrades.",
            "category": "disable-features",
            "tags": [
                "disable",
                "updates",
                "core"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'allow_major_auto_core_updates', '__return_false' );\nadd_filter( 'allow_minor_auto_core_updates', '__return_true' );",
            "requires": [],
            "notes": "Plan major upgrades yourself after testing them on staging."
        },
        {
            "id": "disable-translation-auto-updates",
            "version": 1,
            "title": "Disable Automatic Translation Updates",
            "description": "Stops WordPress from installing updated language files on its own, for sites with customized translations.",
            "category": "disable-features",
            "tags": [
                "disable",
                "updates",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'auto_update_translation', '__return_false' );",
            "requires": [],
            "notes": "Translation updates remain available under Dashboard > Updates."
        },
        {
            "id": "disable-site-health",
            "version": 1,
            "title": "Disable Site Health",
            "description": "Hides the Site Health screen, its dashboard widget and its menu entry for every user.",
            "category": "disable-features",
            "tags": [
                "disable",
                "site-health",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap ) {\n    if ( 'view_site_health_checks' === $cap ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 2 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'dashboard_site_health', 'dashboard', 'normal' );\n}, 99 );",
            "requires": [],
            "notes": "The weekly background check still runs; only the screens are removed."
        },
        {
            "id": "prevent-theme-switching",
            "version": 1,
            "title": "Prevent Switching Themes",
            "description": "Stops anyone, administrators included, from activating a different theme, so a client cannot change the site design by accident.",
            "category": "disable-features",
            "tags": [
                "disable",
                "themes",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap ) {\n    if ( 'switch_themes' === $cap ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 2 );",
            "requires": [],
            "notes": "Deactivate the snippet when you do want to change themes."
        },
        {
            "id": "disable-font-library",
            "version": 1,
            "title": "Disable the Font Library",
            "description": "Removes the Font Library from the Site Editor, so fonts cannot be installed or uploaded from the editor.",
            "category": "disable-features",
            "tags": [
                "disable",
                "fonts",
                "site-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings ) {\n    $settings['fontLibraryEnabled'] = false;\n\n    return $settings;\n} );",
            "requires": [],
            "notes": "Fonts that are already installed keep working."
        },
        {
            "id": "disable-image-lightbox",
            "version": 1,
            "title": "Disable the Image Lightbox Option",
            "description": "Removes the \"Expand on click\" lightbox setting from Image blocks and turns the lightbox off.",
            "category": "disable-features",
            "tags": [
                "disable",
                "images",
                "blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => class_exists( 'WP_Theme_JSON' ) && defined( 'WP_Theme_JSON::LATEST_SCHEMA' ) ? WP_Theme_JSON::LATEST_SCHEMA : 3,\n        'settings' => array(\n            'blocks' => array(\n                'core/image' => array(\n                    'lightbox' => array(\n                        'allowEditing' => false,\n                        'enabled'      => false,\n                    ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-command-palette",
            "version": 1,
            "title": "Disable the Command Palette in wp-admin",
            "description": "Turns off the Ctrl+K / Cmd+K command palette on regular admin screens.",
            "category": "disable-features",
            "tags": [
                "disable",
                "admin",
                "keyboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    remove_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' );\n} );",
            "requires": [],
            "notes": "The block editor and Site Editor keep their own command menu."
        },
        {
            "id": "disable-post-via-email-settings",
            "version": 1,
            "title": "Hide the Post via Email Settings",
            "description": "Removes the rarely used \"Post via email\" section from Settings > Writing.",
            "category": "disable-features",
            "tags": [
                "disable",
                "settings",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'enable_post_by_email_configuration', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "stop-update-service-pings",
            "version": 1,
            "title": "Stop Pinging Update Services",
            "description": "Stops WordPress from notifying ping services such as Ping-O-Matic every time you publish, which most sites no longer need.",
            "category": "disable-features",
            "tags": [
                "disable",
                "pings",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'do_all_pings', 'generic_ping', 10 );\n} );\n\nadd_filter( 'pre_option_ping_sites', '__return_empty_string' );",
            "requires": [],
            "notes": "Pingbacks and trackbacks are separate; use the pingbacks snippet to turn those off."
        },
        {
            "id": "disable-auto-embeds",
            "version": 1,
            "title": "Stop Turning Pasted URLs Into Embeds",
            "description": "Keeps links pasted on their own line as plain links instead of turning them into embedded videos, tweets or posts.",
            "category": "disable-features",
            "tags": [
                "disable",
                "embeds",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    global $wp_embed;\n\n    if ( $wp_embed instanceof WP_Embed ) {\n        remove_filter( 'the_content', array( $wp_embed, 'autoembed' ), 8 );\n        remove_filter( 'widget_text_content', array( $wp_embed, 'autoembed' ), 8 );\n        remove_filter( 'widget_block_content', array( $wp_embed, 'autoembed' ), 8 );\n    }\n} );",
            "requires": [],
            "notes": "Embed blocks added on purpose in the block editor still work."
        },
        {
            "id": "turn-off-avatars",
            "version": 1,
            "title": "Turn Off Avatars Everywhere",
            "description": "Stops avatars from being shown in comments, the admin bar and user lists, saving the image requests.",
            "category": "disable-features",
            "tags": [
                "disable",
                "avatars",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_show_avatars', function () {\n    return '0';\n} );",
            "requires": [],
            "notes": "Same as unticking \"Show Avatars\" in Settings > Discussion, but locked in code."
        },
        {
            "id": "disable-404-url-guessing",
            "version": 1,
            "title": "Stop WordPress Guessing URLs on 404 Pages",
            "description": "Turns off the automatic redirect WordPress makes when a URL does not exist but looks similar to a post slug, so wrong URLs show a real 404.",
            "category": "disable-features",
            "tags": [
                "disable",
                "404",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'do_redirect_guess_404_permalink', '__return_false' );",
            "requires": [],
            "notes": "Useful when the guessing sends visitors to unrelated pages."
        },
        {
            "id": "hide-admin-notices-non-admins",
            "version": 1,
            "title": "Hide Admin Notices From Non-Administrators",
            "description": "Removes plugin and theme notices, update nags and promotions from the admin for everyone who is not an administrator.",
            "category": "disable-features",
            "tags": [
                "disable",
                "notices",
                "client"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'in_admin_header', function () {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    remove_all_actions( 'admin_notices' );\n    remove_all_actions( 'all_admin_notices' );\n}, 999 );",
            "requires": [],
            "notes": "Messages like \"Post updated\" are not admin notices and keep showing."
        },
        {
            "id": "ignore-sticky-posts",
            "version": 1,
            "title": "Ignore Sticky Posts on the Blog Page",
            "description": "Shows posts in plain date order on the blog page, even if some are marked \"Stick to the top\".",
            "category": "disable-features",
            "tags": [
                "disable",
                "sticky-posts",
                "blog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {\n        $query->set( 'ignore_sticky_posts', true );\n    }\n} );",
            "requires": [],
            "notes": "Blocks and widgets with their own sticky setting are not affected."
        },
        {
            "id": "remove-private-protected-title-prefix",
            "version": 1,
            "title": "Remove \"Private:\" and \"Protected:\" From Titles",
            "description": "Shows private and password-protected posts with their plain title, without the prefix WordPress adds.",
            "category": "disable-features",
            "tags": [
                "disable",
                "titles",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_plain_title_format = function () {\n    return '%s';\n};\n\nadd_filter( 'private_title_format', $wpide_plain_title_format );\nadd_filter( 'protected_title_format', $wpide_plain_title_format );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-text-smilies",
            "version": 1,
            "title": "Stop Converting Text Smileys to Images",
            "description": "Leaves text like :) and ;-) as typed instead of replacing it with smiley images or emoji.",
            "category": "disable-features",
            "tags": [
                "disable",
                "smilies",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_use_smilies', function () {\n    return '0';\n} );\n\nadd_action( 'init', function () {\n    remove_filter( 'the_content', 'convert_smilies', 20 );\n    remove_filter( 'the_excerpt', 'convert_smilies' );\n    remove_filter( 'comment_text', 'convert_smilies', 20 );\n    remove_filter( 'widget_text_content', 'convert_smilies', 20 );\n    remove_filter( 'the_post_thumbnail_caption', 'convert_smilies' );\n} );",
            "requires": [],
            "notes": "Emoji you type directly are not affected; use the emoji snippet to stop the emoji script."
        },
        {
            "id": "remove-block-style-variations",
            "version": 1,
            "title": "Remove Unwanted Block Style Variations",
            "description": "Hides block styles you do not want editors to use, such as the outline button or rounded image, from the block editor.",
            "category": "disable-features",
            "tags": [
                "disable",
                "blocks",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Block name => style names to remove.\n$wpide_remove_block_styles = array(\n    'core/button'    => array( 'outline' ),\n    'core/image'     => array( 'rounded' ),\n    'core/quote'     => array( 'plain' ),\n    'core/separator' => array( 'dots', 'wide' ),\n);\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_remove_block_styles ) {\n    wp_register_script( 'wpide-remove-block-styles', false, array( 'wp-blocks', 'wp-dom-ready' ), '1.0', true );\n    wp_enqueue_script( 'wpide-remove-block-styles' );\n    wp_add_inline_script(\n        'wpide-remove-block-styles',\n        sprintf(\n            'wp.domReady(function(){var map=%s;Object.keys(map).forEach(function(block){map[block].forEach(function(style){wp.blocks.unregisterBlockStyle(block,style);});});});',\n            wp_json_encode( $wpide_remove_block_styles )\n        )\n    );\n} );",
            "requires": [],
            "notes": "Blocks already using a removed style keep it until edited."
        },
        {
            "id": "limit-embed-block-variations",
            "version": 1,
            "title": "Hide Embed Blocks You Do Not Use",
            "description": "Keeps only the embed providers you choose, like YouTube and Vimeo, in the block inserter instead of dozens of services.",
            "category": "disable-features",
            "tags": [
                "disable",
                "blocks",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Embed variations to keep in the inserter.\n$wpide_keep_embeds = array( 'youtube', 'vimeo', 'spotify' );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_keep_embeds ) {\n    wp_register_script( 'wpide-limit-embeds', false, array( 'wp-blocks', 'wp-dom-ready' ), '1.0', true );\n    wp_enqueue_script( 'wpide-limit-embeds' );\n    wp_add_inline_script(\n        'wpide-limit-embeds',\n        sprintf(\n            'wp.domReady(function(){var keep=%s;(wp.blocks.getBlockVariations(\"core/embed\")||[]).forEach(function(v){if(keep.indexOf(v.name)===-1){wp.blocks.unregisterBlockVariation(\"core/embed\",v.name);}});});',\n            wp_json_encode( array_values( $wpide_keep_embeds ) )\n        )\n    );\n} );",
            "requires": [],
            "notes": "Pasting a URL from a hidden provider may still create an embed."
        },
        {
            "id": "disable-responsive-srcset",
            "version": 1,
            "title": "Disable Responsive Image srcset",
            "description": "Stops WordPress from adding srcset and sizes attributes to images, for sites where an image CDN already serves the right size.",
            "category": "disable-features",
            "tags": [
                "disable",
                "images",
                "srcset"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_calculate_image_srcset', '__return_false' );",
            "requires": [],
            "notes": "Without a CDN that resizes images, phones will download full-size images."
        },
        {
            "id": "disable-auto-sizes",
            "version": 1,
            "title": "Disable \"sizes=auto\" on Lazy-Loaded Images",
            "description": "Turns off the automatic sizes=\"auto\" attribute WordPress adds to lazy-loaded images, which some themes and sliders display incorrectly.",
            "category": "disable-features",
            "tags": [
                "disable",
                "images",
                "lazy-loading"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_img_tag_add_auto_sizes', '__return_false' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-visual-tab-classic-editor",
            "version": 1,
            "title": "Turn Off the Visual Tab in the Classic Editor",
            "description": "Makes the classic editor open in Text (HTML) mode only, for sites where the visual editor keeps changing hand-written markup.",
            "category": "disable-features",
            "tags": [
                "disable",
                "classic-editor",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'user_can_richedit', function ( $can ) {\n    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;\n\n    if ( $screen && method_exists( $screen, 'is_block_editor' ) && $screen->is_block_editor() ) {\n        return $can; // Leave the block editor alone.\n    }\n\n    return false;\n} );",
            "requires": [],
            "notes": "Only affects the classic editor; the block editor is unchanged."
        },
        {
            "id": "disable-additional-css",
            "version": 1,
            "title": "Disable Additional CSS",
            "description": "Removes the Additional CSS panel from the Customizer and the custom CSS option in the Site Editor, so styles are only changed in code.",
            "category": "disable-features",
            "tags": [
                "disable",
                "css",
                "customizer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'map_meta_cap', function ( $caps, $cap ) {\n    if ( 'edit_css' === $cap ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 2 );",
            "requires": [],
            "notes": "CSS that was already saved keeps loading on the site."
        },
        {
            "id": "disable-default-favicon",
            "version": 1,
            "title": "Stop WordPress Serving Its Logo as favicon.ico",
            "description": "When no Site Icon is set, WordPress answers /favicon.ico with its own logo. This returns a plain 404 instead, so your tab never shows the WordPress logo.",
            "category": "disable-features",
            "tags": [
                "disable",
                "favicon",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'do_faviconico', function () {\n    if ( ! has_site_icon() ) {\n        status_header( 404 );\n        nocache_headers();\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "If a real favicon.ico file exists in the site root, your web server serves it before WordPress is involved."
        },
        {
            "id": "disable-old-slug-redirects",
            "version": 1,
            "title": "Stop Redirecting Old Post Slugs",
            "description": "Stops WordPress from redirecting a post's previous URLs after its slug changes, for when you want an old address to be free or truly gone.",
            "category": "disable-features",
            "tags": [
                "disable",
                "redirects",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'template_redirect', 'wp_old_slug_redirect' );\n} );",
            "requires": [],
            "notes": "Links and search results that point to old slugs will return 404, which can hurt SEO."
        },
        {
            "id": "lock-editor-to-theme-styles",
            "version": 1,
            "title": "Lock the Editor to Theme Colors and Font Sizes",
            "description": "Removes the custom color picker, default palettes and custom font sizes from the block editor, so editors can only use your theme's brand styles.",
            "category": "disable-features",
            "tags": [
                "disable",
                "editor",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => class_exists( 'WP_Theme_JSON' ) && defined( 'WP_Theme_JSON::LATEST_SCHEMA' ) ? WP_Theme_JSON::LATEST_SCHEMA : 3,\n        'settings' => array(\n            'color'      => array(\n                'custom'          => false,\n                'customGradient'  => false,\n                'customDuotone'   => false,\n                'defaultPalette'  => false,\n                'defaultGradients' => false,\n                'defaultDuotone'  => false,\n            ),\n            'typography' => array(\n                'customFontSize'   => false,\n                'defaultFontSizes' => false,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "If your theme defines no palette or font sizes of its own, editors are left with none to choose from."
        },
        {
            "id": "disable-comments-for-post-types",
            "version": 1,
            "title": "Disable Comments on Specific Post Types",
            "description": "Turns comments and pings off for the post types you choose, such as pages or products, while keeping them on blog posts.",
            "category": "comments",
            "tags": [
                "comments",
                "post-types",
                "disable"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types without comments.\n$wpide_no_comment_types = array( 'page', 'attachment' );\n\nadd_action( 'init', function () use ( $wpide_no_comment_types ) {\n    foreach ( $wpide_no_comment_types as $type ) {\n        remove_post_type_support( $type, 'comments' );\n        remove_post_type_support( $type, 'trackbacks' );\n    }\n}, 100 );\n\n$wpide_close_for_types = function ( $open, $post_id ) use ( $wpide_no_comment_types ) {\n    return in_array( get_post_type( $post_id ), $wpide_no_comment_types, true ) ? false : $open;\n};\n\nadd_filter( 'comments_open', $wpide_close_for_types, 20, 2 );\nadd_filter( 'pings_open', $wpide_close_for_types, 20, 2 );",
            "requires": [],
            "notes": "Existing comments stay in the database; hide them too with a comments_array filter if needed."
        },
        {
            "id": "spam-comments-with-many-links",
            "version": 1,
            "title": "Send Comments With Too Many Links to Spam",
            "description": "Marks a comment as spam when it contains more links than you allow, catching most link-dropping spam before it reaches the moderation queue.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Most links a genuine comment may contain.\n$wpide_max_comment_links = 2;\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_max_comment_links ) {\n    if ( is_wp_error( $approved ) || 'spam' === $approved || 'trash' === $approved || ! empty( $commentdata['user_id'] ) ) {\n        return $approved;\n    }\n\n    $links = preg_match_all( '#(https?://|www\\.)#i', (string) $commentdata['comment_content'] );\n\n    return $links > $wpide_max_comment_links ? 'spam' : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Logged-in users are never affected; check the Spam folder now and then for false positives."
        },
        {
            "id": "stricter-comment-flood-protection",
            "version": 1,
            "title": "Stricter Comment Flood Protection",
            "description": "Requires a longer pause between two comments from the same person, 60 seconds instead of WordPress's 15, to slow down comment bursts.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Seconds that must pass between two comments from the same IP or email.\n$wpide_comment_flood_seconds = 60;\n\nadd_filter( 'comment_flood_filter', function ( $is_flood, $time_last, $time_new ) use ( $wpide_comment_flood_seconds ) {\n    if ( $is_flood || current_user_can( 'moderate_comments' ) ) {\n        return $is_flood;\n    }\n\n    return ( $time_new - $time_last ) < $wpide_comment_flood_seconds;\n}, 20, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "reject-too-fast-comments",
            "version": 1,
            "title": "Reject Comments Submitted Too Fast",
            "description": "Rejects comments sent within a few seconds of the page loading, which only bots manage, using a signed timestamp in the form.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "bots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum seconds between loading the page and submitting a comment.\n$wpide_comment_min_seconds = 5;\n\nadd_action( 'comment_form', function () {\n    if ( is_user_logged_in() ) {\n        return;\n    }\n\n    $time = time();\n    printf(\n        '<input type=\"hidden\" name=\"wpide_ct\" value=\"%1$d\"><input type=\"hidden\" name=\"wpide_cs\" value=\"%2$s\">',\n        (int) $time,\n        esc_attr( hash_hmac( 'sha256', (string) $time, wp_salt( 'nonce' ) ) )\n    );\n} );\n\nadd_filter( 'preprocess_comment', function ( $commentdata ) use ( $wpide_comment_min_seconds ) {\n    $type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : '';\n\n    if ( is_user_logged_in() || ( '' !== $type && 'comment' !== $type ) ) {\n        return $commentdata;\n    }\n\n    $time = isset( $_POST['wpide_ct'] ) ? (int) $_POST['wpide_ct'] : 0;\n    $sig  = isset( $_POST['wpide_cs'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_cs'] ) ) : '';\n\n    if ( ! $time || ! hash_equals( hash_hmac( 'sha256', (string) $time, wp_salt( 'nonce' ) ), $sig ) || time() - $time < $wpide_comment_min_seconds ) {\n        wp_die( 'Your comment could not be submitted. Please go back, wait a moment and try again.', 'Comment rejected', array( 'response' => 403, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n}, 1 );",
            "requires": [],
            "notes": "Works with page caching because the check only sets a minimum age."
        },
        {
            "id": "reply-notifications-opt-in",
            "version": 1,
            "title": "Email Commenters When Someone Replies (Opt-In)",
            "description": "Adds a \"Notify me of replies\" checkbox to the comment form and emails the commenter once a reply to their comment is approved.",
            "category": "comments",
            "tags": [
                "comments",
                "email",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_reply_checkbox = function () {\n    echo '<p class=\"comment-form-wpide-notify\"><label><input type=\"checkbox\" name=\"wpide_notify_replies\" value=\"1\"> Email me when someone replies to my comment</label></p>';\n};\n\nadd_action( 'comment_form_after_fields', $wpide_reply_checkbox );\nadd_action( 'comment_form_logged_in_after', $wpide_reply_checkbox );\n\n$wpide_notify_parent = function ( $comment ) {\n    $comment = get_comment( $comment );\n\n    if ( ! $comment || ! $comment->comment_parent || '1' !== (string) $comment->comment_approved || get_comment_meta( $comment->comment_ID, 'wpide_reply_notified', true ) ) {\n        return;\n    }\n\n    $parent = get_comment( $comment->comment_parent );\n\n    if ( ! $parent || ! get_comment_meta( $parent->comment_ID, 'wpide_notify_replies', true ) || ! is_email( $parent->comment_author_email ) || $parent->comment_author_email === $comment->comment_author_email ) {\n        return;\n    }\n\n    update_comment_meta( $comment->comment_ID, 'wpide_reply_notified', 1 );\n\n    wp_mail(\n        $parent->comment_author_email,\n        sprintf( 'New reply to your comment on \"%s\"', wp_specialchars_decode( get_the_title( $comment->comment_post_ID ), ENT_QUOTES ) ),\n        sprintf(\n            \"Hi %s,\\n\\n%s replied to your comment:\\n\\n%s\\n\\nRead the conversation: %s\",\n            $parent->comment_author,\n            $comment->comment_author,\n            wp_trim_words( wp_strip_all_tags( $comment->comment_content ), 60 ),\n            get_comment_link( $comment )\n        )\n    );\n};\n\nadd_action( 'comment_post', function ( $comment_id, $approved ) use ( $wpide_notify_parent ) {\n    if ( ! empty( $_POST['wpide_notify_replies'] ) ) {\n        add_comment_meta( $comment_id, 'wpide_notify_replies', 1, true );\n    }\n\n    if ( 1 === $approved ) {\n        $wpide_notify_parent( $comment_id );\n    }\n}, 10, 2 );\n\nadd_action( 'transition_comment_status', function ( $new_status, $old_status, $comment ) use ( $wpide_notify_parent ) {\n    if ( 'approved' === $new_status && 'approved' !== $old_status ) {\n        $wpide_notify_parent( $comment );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Each reply sends at most one email, and replies by the original commenter are skipped."
        },
        {
            "id": "unlink-comment-author-names",
            "version": 1,
            "title": "Remove Links From Comment Author Names",
            "description": "Shows commenter names as plain text instead of links to their websites, removing the main reward for comment spammers.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_comment_author_link', function ( $link, $author ) {\n    return esc_html( $author );\n}, 10, 2 );",
            "requires": [],
            "notes": "Combine with removing the website field so new commenters are not asked for a URL."
        },
        {
            "id": "comment-form-texts",
            "version": 1,
            "title": "Change the Comment Form Title, Note and Button Text",
            "description": "Replaces \"Leave a Reply\", the \"Your email address will not be published\" note and the \"Post Comment\" button with your own wording.",
            "category": "comments",
            "tags": [
                "comments",
                "form",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_texts = array(\n    'title_reply'          => 'Join the discussion',\n    'title_reply_to'       => 'Reply to %s',\n    'label_submit'         => 'Publish comment',\n    'comment_notes_before' => '<p class=\"comment-notes\">Your email stays private. Comments are moderated.</p>',\n);\n\nadd_filter( 'comment_form_defaults', function ( $defaults ) use ( $wpide_comment_texts ) {\n    return array_merge( $defaults, $wpide_comment_texts );\n} );",
            "requires": [],
            "notes": "Leave out any key you want to keep as it is."
        },
        {
            "id": "remove-comment-email-field",
            "version": 1,
            "title": "Remove the Email Field From Comments",
            "description": "Lets visitors comment with just a name, for communities where asking for an email address puts people off.",
            "category": "comments",
            "tags": [
                "comments",
                "form",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_default_fields', function ( $fields ) {\n    unset( $fields['email'] );\n\n    return $fields;\n} );\n\n// WordPress would otherwise insist on both name and email.\nadd_filter( 'pre_option_require_name_email', function () {\n    return '0';\n} );\n\nadd_filter( 'preprocess_comment', function ( $commentdata ) {\n    $type = isset( $commentdata['comment_type'] ) ? $commentdata['comment_type'] : '';\n\n    if ( ! is_user_logged_in() && ( '' === $type || 'comment' === $type ) && '' === trim( (string) $commentdata['comment_author'] ) ) {\n        wp_die( 'Please enter your name.', 'Name required', array( 'response' => 400, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n} );",
            "requires": [],
            "notes": "Without email addresses, Gravatars, reply notifications and some spam filters work less well."
        },
        {
            "id": "disable-comment-cookies",
            "version": 1,
            "title": "Stop Saving Commenter Details in Cookies",
            "description": "Stops WordPress from remembering commenters' name, email and website in cookies, and removes the related consent checkbox.",
            "category": "comments",
            "tags": [
                "comments",
                "cookies",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'set_comment_cookies', 'wp_set_comment_cookies' );\n} );\n\nadd_filter( 'comment_form_default_fields', function ( $fields ) {\n    unset( $fields['cookies'] );\n\n    return $fields;\n} );",
            "requires": [],
            "notes": "Returning visitors have to type their details again; pending comments are no longer shown to their author."
        },
        {
            "id": "show-comments-on-click",
            "version": 1,
            "title": "Show Comments Only After a Click",
            "description": "Collapses the comments section behind a \"Show comments\" button, so long discussions do not distract from the article or load avatars until wanted.",
            "category": "comments",
            "tags": [
                "comments",
                "ux",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// CSS selector of the comments section (classic themes use #comments, block themes .wp-block-comments).\n$wpide_comments_selector = '#comments, .wp-block-comments';\n\nadd_action( 'wp_footer', function () use ( $wpide_comments_selector ) {\n    if ( ! is_singular() || ! get_comments_number() ) {\n        return;\n    }\n\n    $label = sprintf( 'Show comments (%s)', number_format_i18n( get_comments_number() ) );\n    ?>\n    <script>\n    ( function () {\n        var box = document.querySelector( <?php echo wp_json_encode( $wpide_comments_selector ); ?> );\n        if ( ! box || /^#(comment|respond)/.test( window.location.hash ) ) {\n            return;\n        }\n        var button = document.createElement( 'button' );\n        button.type = 'button';\n        button.className = 'wp-element-button wpide-show-comments';\n        button.textContent = <?php echo wp_json_encode( $label ); ?>;\n        button.setAttribute( 'aria-expanded', 'false' );\n        box.hidden = true;\n        box.parentNode.insertBefore( button, box );\n        button.addEventListener( 'click', function () {\n            box.hidden = false;\n            button.remove();\n        } );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Links to a specific comment or to the form open the section automatically."
        },
        {
            "id": "recent-comments-shortcode",
            "version": 1,
            "title": "Recent Comments Shortcode",
            "description": "Adds a [wpide_recent_comments] shortcode that lists the latest approved comments with the commenter's name, post title and a short excerpt.",
            "category": "comments",
            "tags": [
                "comments",
                "shortcode",
                "widgets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_recent_comments', function ( $atts ) {\n    $atts = shortcode_atts( array( 'number' => 5, 'words' => 15 ), $atts, 'wpide_recent_comments' );\n\n    $comments = get_comments( array(\n        'number'      => min( 20, absint( $atts['number'] ) ),\n        'status'      => 'approve',\n        'type'        => 'comment',\n        'post_status' => 'publish',\n    ) );\n\n    if ( ! $comments ) {\n        return '';\n    }\n\n    $html = '<ul class=\"wpide-recent-comments\">';\n    foreach ( $comments as $comment ) {\n        $html .= sprintf(\n            '<li><strong>%1$s</strong> on <a href=\"%2$s\">%3$s</a><br><span>%4$s</span></li>',\n            esc_html( get_comment_author( $comment ) ),\n            esc_url( get_comment_link( $comment ) ),\n            esc_html( get_the_title( $comment->comment_post_ID ) ),\n            esc_html( wp_trim_words( wp_strip_all_tags( $comment->comment_content ), absint( $atts['words'] ) ) )\n        );\n    }\n    $html .= '</ul>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Options: [wpide_recent_comments number=\"8\" words=\"20\"]."
        },
        {
            "id": "top-commenters-shortcode",
            "version": 1,
            "title": "Top Commenters Shortcode",
            "description": "Adds a [wpide_top_commenters] shortcode listing the people who left the most approved comments, a simple way to thank your community.",
            "category": "comments",
            "tags": [
                "comments",
                "shortcode",
                "community"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_top_commenters', function ( $atts ) {\n    global $wpdb;\n\n    $atts   = shortcode_atts( array( 'number' => 10, 'days' => 365 ), $atts, 'wpide_top_commenters' );\n    $number = min( 50, absint( $atts['number'] ) );\n    $days   = absint( $atts['days'] );\n    $key    = 'wpide_top_commenters_' . $number . '_' . $days;\n    $rows   = get_transient( $key );\n\n    if ( false === $rows ) {\n        $rows = $wpdb->get_results( $wpdb->prepare(\n            \"SELECT comment_author AS name, COUNT(*) AS total\n             FROM {$wpdb->comments}\n             WHERE comment_approved = '1' AND comment_type IN ('comment', '') AND comment_author_email <> ''\n               AND comment_date_gmt > %s\n             GROUP BY comment_author_email, comment_author\n             ORDER BY total DESC\n             LIMIT %d\",\n            gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ),\n            $number\n        ) );\n        set_transient( $key, $rows, 12 * HOUR_IN_SECONDS );\n    }\n\n    if ( ! $rows ) {\n        return '';\n    }\n\n    $html = '<ol class=\"wpide-top-commenters\">';\n    foreach ( $rows as $row ) {\n        $html .= sprintf( '<li>%1$s <span>(%2$s)</span></li>', esc_html( $row->name ), esc_html( number_format_i18n( $row->total ) ) );\n    }\n\n    return $html . '</ol>';\n} );",
            "requires": [],
            "notes": "Results are cached for 12 hours; options: [wpide_top_commenters number=\"5\" days=\"90\"]."
        },
        {
            "id": "comment-urls-not-clickable",
            "version": 1,
            "title": "Stop Turning URLs in Comments Into Links",
            "description": "Keeps web addresses typed in comments as plain text, so spam links are not clickable.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_filter( 'comment_text', 'make_clickable', 9 );\n} );",
            "requires": [],
            "notes": "Links written as HTML anchors are not affected; use the \"Strip HTML from comments\" snippet for those."
        },
        {
            "id": "extra-comment-moderation-recipients",
            "version": 1,
            "title": "Send Comment Moderation Emails to More People",
            "description": "Sends \"comment awaiting moderation\" emails to extra addresses, such as your community manager, besides the site admin and post author.",
            "category": "comments",
            "tags": [
                "comments",
                "moderation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra recipients for moderation emails.\n$wpide_extra_moderators = array( 'community@example.com' );\n\nadd_filter( 'comment_moderation_recipients', function ( $emails ) use ( $wpide_extra_moderators ) {\n    return array_values( array_unique( array_merge( (array) $emails, array_filter( $wpide_extra_moderators, 'is_email' ) ) ) );\n} );",
            "requires": [],
            "notes": "Emails are only sent while \"A comment is held for moderation\" is ticked in Settings > Discussion."
        },
        {
            "id": "hold-all-caps-comments",
            "version": 1,
            "title": "Hold Comments Written in All Capitals",
            "description": "Sends comments that are mostly in capital letters to the moderation queue instead of publishing them straight away.",
            "category": "comments",
            "tags": [
                "comments",
                "moderation",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Share of uppercase letters (0 to 1) above which a comment is held.\n$wpide_caps_ratio = 0.7;\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_caps_ratio ) {\n    if ( 1 !== $approved && '1' !== $approved ) {\n        return $approved;\n    }\n\n    $letters = preg_replace( '/[^\\p{L}]/u', '', (string) $commentdata['comment_content'] );\n    $upper   = preg_replace( '/[^\\p{Lu}]/u', '', $letters );\n\n    if ( strlen( $letters ) >= 20 && strlen( $upper ) / strlen( $letters ) > $wpide_caps_ratio ) {\n        return 0;\n    }\n\n    return $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Short comments (under 20 letters) are never held."
        },
        {
            "id": "comment-character-counter",
            "version": 1,
            "title": "Character Counter Under the Comment Box",
            "description": "Shows how many characters are left while typing a comment and stops input at the limit you set.",
            "category": "comments",
            "tags": [
                "comments",
                "form",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Maximum comment length in characters.\n$wpide_comment_max_chars = 1500;\n\nadd_filter( 'comment_form_field_comment', function ( $field ) use ( $wpide_comment_max_chars ) {\n    $field = preg_replace( '/<textarea\\b/', '<textarea maxlength=\"' . (int) $wpide_comment_max_chars . '\"', $field, 1 );\n\n    return $field . '<p class=\"wpide-comment-counter\" aria-live=\"polite\" style=\"font-size:.85em;opacity:.75;margin-top:.25em\"></p>';\n} );\n\nadd_action( 'wp_footer', function () use ( $wpide_comment_max_chars ) {\n    if ( ! is_singular() || ! comments_open() ) {\n        return;\n    }\n    ?>\n    <script>\n    ( function () {\n        var field = document.getElementById( 'comment' );\n        var out = document.querySelector( '.wpide-comment-counter' );\n        if ( ! field || ! out ) {\n            return;\n        }\n        var max = <?php echo (int) $wpide_comment_max_chars; ?>;\n        function update() {\n            out.textContent = ( max - field.value.length ) + ' characters left';\n        }\n        field.addEventListener( 'input', update );\n        update();\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Pair it with the minimum and maximum comment length snippet for a server-side check."
        },
        {
            "id": "auto-approve-logged-in-comments",
            "version": 1,
            "title": "Auto-Approve Comments From Logged-In Users",
            "description": "Publishes comments from registered, logged-in users right away, while comments from guests still follow your moderation settings.",
            "category": "comments",
            "tags": [
                "comments",
                "moderation",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_comment_approved', function ( $approved, $commentdata ) {\n    // Only lift \"held for moderation\"; spam and trash decisions are kept.\n    if ( 0 !== $approved && '0' !== $approved ) {\n        return $approved;\n    }\n\n    return ! empty( $commentdata['user_id'] ) && user_can( (int) $commentdata['user_id'], 'read' ) ? 1 : $approved;\n}, 99, 2 );",
            "requires": [],
            "notes": "Anyone who can register can then publish comments instantly; keep registrations under control."
        },
        {
            "id": "hide-comments-admin-non-moderators",
            "version": 1,
            "title": "Hide Comments in the Admin From Users Who Cannot Moderate",
            "description": "Removes the Comments menu, admin bar bubble and screen for users without moderation rights, such as authors and contributors.",
            "category": "comments",
            "tags": [
                "comments",
                "admin",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        remove_menu_page( 'edit-comments.php' );\n    }\n}, 999 );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        $wp_admin_bar->remove_node( 'comments' );\n    }\n}, 999 );\n\nadd_action( 'load-edit-comments.php', function () {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        wp_safe_redirect( admin_url() );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "modern-comment-list-styles",
            "version": 1,
            "title": "Modern Comment List Styles",
            "description": "Restyles the comment list as soft cards with round avatars, a highlighted post author and thin lines that show reply threads.",
            "category": "comments",
            "tags": [
                "comments",
                "design",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "// Style settings.\n$accent: #2271b1;\n$card-bg: rgba(127, 127, 127, .07);\n$radius: 10px;\n$indent: 1.5rem;\n\n.comment-list,\n.wp-block-comment-template {\n    list-style: none;\n    padding-left: 0;\n\n    .comment-body,\n    li > .wp-block-columns,\n    li > .wp-block-group {\n        padding: 1rem 1.25rem;\n        margin-bottom: 1rem;\n        background: $card-bg;\n        border-radius: $radius;\n    }\n\n    .children {\n        list-style: none;\n        margin-left: $indent;\n        padding-left: $indent;\n        border-left: 2px solid rgba($accent, .25);\n    }\n\n    .avatar {\n        border-radius: 50%;\n    }\n\n    .bypostauthor > .comment-body,\n    .bypostauthor > .wp-block-group,\n    .bypostauthor > .wp-block-columns {\n        box-shadow: inset 3px 0 0 $accent;\n    }\n}",
            "requires": [],
            "notes": "Theme styles with higher specificity may win; inspect the comment markup of your theme if something does not change."
        },
        {
            "id": "clean-comment-form-styles",
            "version": 1,
            "title": "Clean Comment Form Styles",
            "description": "Gives the comment form full-width fields, rounded corners, a clear focus color and a solid submit button that match your accent color.",
            "category": "comments",
            "tags": [
                "comments",
                "design",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "// Style settings.\n$accent: #2271b1;\n$radius: 8px;\n$border: rgba(127, 127, 127, .4);\n\n#respond,\n.wp-block-post-comments-form {\n    input[type=\"text\"],\n    input[type=\"email\"],\n    input[type=\"url\"],\n    textarea {\n        width: 100%;\n        max-width: 100%;\n        padding: .7em .9em;\n        border: 1px solid $border;\n        border-radius: $radius;\n        background: transparent;\n        color: inherit;\n        box-sizing: border-box;\n\n        &:focus {\n            outline: none;\n            border-color: $accent;\n            box-shadow: 0 0 0 3px rgba($accent, .2);\n        }\n    }\n\n    .form-submit input[type=\"submit\"],\n    .wp-block-button__link {\n        padding: .75em 1.5em;\n        border: 0;\n        border-radius: $radius;\n        background: $accent;\n        color: #fff;\n        cursor: pointer;\n\n        &:hover {\n            background: darken($accent, 8%);\n        }\n    }\n}",
            "requires": [],
            "notes": ""
        },
        {
            "id": "jump-to-comments-mid-article",
            "version": 1,
            "title": "Jump-to-Comments Link in the Middle of Posts",
            "description": "Adds a small \"Join the discussion\" link with the comment count halfway through posts, so engaged readers find the comments faster.",
            "category": "comments",
            "tags": [
                "comments",
                "engagement",
                "navigation"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_half",
            "insert_number": 1,
            "code": "<?php if ( is_single() && ( comments_open() || get_comments_number() ) ) : ?>\n<p class=\"wpide-jump-to-comments\" style=\"margin:1.5em 0;padding:.6em 1em;border-radius:6px;background:rgba(127,127,127,.08)\">\n    <a href=\"#respond\">\n        <?php\n        $wpide_count = (int) get_comments_number();\n        echo esc_html( $wpide_count ? sprintf( _n( 'Join the discussion: %s comment so far', 'Join the discussion: %s comments so far', $wpide_count ), number_format_i18n( $wpide_count ) ) : 'Have a thought? Be the first to comment' );\n        ?>\n        &darr;\n    </a>\n</p>\n<?php endif; ?>",
            "requires": [],
            "notes": "Links to the comment form (#respond), which exists in classic and block themes."
        },
        {
            "id": "comment-invitation-near-end",
            "version": 1,
            "title": "Comment Invitation Near the End of Posts",
            "description": "Asks readers a question near the end of each post (after 80% of the content) and links straight to the comment form.",
            "category": "comments",
            "tags": [
                "comments",
                "engagement",
                "call-to-action"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_80",
            "insert_number": 1,
            "code": "<?php if ( is_single() && comments_open() ) : ?>\n<aside class=\"wpide-comment-invite\" style=\"margin:2em 0;padding:1em 1.25em;border-left:4px solid currentColor\">\n    <p style=\"margin:0\"><strong>What do you think?</strong> Share your experience or ask a question <a href=\"#respond\">in the comments</a>. We read every one.</p>\n</aside>\n<?php endif; ?>",
            "requires": [],
            "notes": ""
        },
        {
            "id": "woo-review-prompt-after-summary",
            "version": 1,
            "title": "Review Prompt Below the Product Summary",
            "description": "Adds a \"Bought this? Share your review\" link with the current review count under the WooCommerce product summary, opening the Reviews tab.",
            "category": "comments",
            "tags": [
                "comments",
                "woocommerce",
                "reviews"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "wc_after_single_product_summary",
            "insert_number": 1,
            "code": "<?php\nglobal $product;\nif ( $product instanceof WC_Product && $product->get_reviews_allowed() && 'yes' === get_option( 'woocommerce_enable_reviews' ) ) :\n    $wpide_reviews = (int) $product->get_review_count();\n    ?>\n<p class=\"wpide-review-prompt\" style=\"clear:both;margin:1em 0\">\n    <a href=\"#reviews\" class=\"woocommerce-review-link\">\n        <?php echo esc_html( $wpide_reviews ? sprintf( 'Bought this? Add your review to the %d others', $wpide_reviews ) : 'Bought this? Be the first to write a review' ); ?>\n    </a>\n</p>\n<?php endif; ?>",
            "requires": [
                "woocommerce"
            ],
            "notes": "The woocommerce-review-link class makes WooCommerce open the Reviews tab on click."
        },
        {
            "id": "comments-for-logged-in-only",
            "version": 1,
            "title": "Show Comments Only to Logged-In Users",
            "description": "Hides existing comments and the comment form from visitors who are not logged in, including in feeds and the REST API, and invites them to log in.",
            "category": "comments",
            "tags": [
                "comments",
                "members",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_guest = function () {\n    return ! is_user_logged_in() && ! is_admin();\n};\n\nadd_filter( 'comments_array', function ( $comments ) use ( $wpide_guest ) {\n    return $wpide_guest() ? array() : $comments;\n}, 20 );\n\nadd_filter( 'get_comments_number', function ( $count ) use ( $wpide_guest ) {\n    return $wpide_guest() ? 0 : $count;\n}, 20 );\n\nadd_filter( 'comments_open', function ( $open ) use ( $wpide_guest ) {\n    return $wpide_guest() ? false : $open;\n}, 20 );\n\nadd_filter( 'comment_feed_where', function ( $where ) use ( $wpide_guest ) {\n    return $wpide_guest() ? $where . ' AND 1=0' : $where;\n} );\n\nadd_filter( 'rest_endpoints', function ( $endpoints ) {\n    if ( ! is_user_logged_in() ) {\n        foreach ( array_keys( $endpoints ) as $route ) {\n            if ( 0 === strpos( $route, '/wp/v2/comments' ) ) {\n                unset( $endpoints[ $route ] );\n            }\n        }\n    }\n\n    return $endpoints;\n} );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_guest ) {\n    if ( ! $wpide_guest() || ! is_singular() || ! in_the_loop() || ! is_main_query() || ! post_type_supports( get_post_type(), 'comments' ) ) {\n        return $content;\n    }\n\n    return $content . sprintf( '<p class=\"wpide-comments-login\"><a href=\"%s\">Log in</a> to read and join the discussion.</p>', esc_url( wp_login_url( get_permalink() ) ) );\n}, 20 );",
            "requires": [],
            "notes": "Logged-in users of any role see and post comments as usual."
        },
        {
            "id": "report-comment-link",
            "version": 1,
            "title": "Let Visitors Report Inappropriate Comments",
            "description": "Adds a \"Report\" link to each comment; moderators get an email on the first report and the comment is held for review after three reports.",
            "category": "comments",
            "tags": [
                "comments",
                "moderation",
                "community"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Reports after which a comment is unpublished until a moderator reviews it.\n$wpide_report_threshold = 3;\n\nadd_filter( 'comment_text', function ( $text, $comment ) {\n    if ( is_admin() || wp_is_json_request() || ! ( $comment instanceof WP_Comment ) || '1' !== (string) $comment->comment_approved || is_feed() ) {\n        return $text;\n    }\n\n    return $text . sprintf( '<p class=\"wpide-report\"><a href=\"#\" class=\"wpide-report-comment\" data-id=\"%d\" style=\"font-size:.8em;opacity:.7\">Report</a></p>', (int) $comment->comment_ID );\n}, 30, 2 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! is_singular() || ! get_comments_number() ) {\n        return;\n    }\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var link = event.target.closest( '.wpide-report-comment' );\n        if ( ! link ) {\n            return;\n        }\n        event.preventDefault();\n        if ( ! window.confirm( 'Report this comment to the moderators?' ) ) {\n            return;\n        }\n        var data = new FormData();\n        data.append( 'action', 'wpide_report_comment' );\n        data.append( 'comment_id', link.dataset.id );\n        data.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( 'wpide_report_comment' ) ); ?> );\n        fetch( <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, { method: 'POST', body: data, credentials: 'same-origin' } )\n            .then( function () { link.replaceWith( document.createTextNode( 'Thanks, reported.' ) ); } );\n    } );\n    </script>\n    <?php\n} );\n\n$wpide_handle_report = function () use ( $wpide_report_threshold ) {\n    check_ajax_referer( 'wpide_report_comment', 'nonce' );\n\n    $comment_id = isset( $_POST['comment_id'] ) ? absint( $_POST['comment_id'] ) : 0;\n    $comment    = $comment_id ? get_comment( $comment_id ) : null;\n\n    if ( ! $comment || '1' !== (string) $comment->comment_approved ) {\n        wp_send_json_error( null, 404 );\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? (string) $_SERVER['REMOTE_ADDR'] : '';\n    $key = 'wpide_reported_' . md5( $ip . '|' . $comment_id );\n\n    if ( get_transient( $key ) ) {\n        wp_send_json_success(); // Already counted for this visitor.\n    }\n\n    set_transient( $key, 1, DAY_IN_SECONDS );\n    $reports = (int) get_comment_meta( $comment_id, 'wpide_reports', true ) + 1;\n    update_comment_meta( $comment_id, 'wpide_reports', $reports );\n\n    if ( 1 === $reports ) {\n        wp_mail(\n            get_option( 'admin_email' ),\n            'A comment was reported',\n            sprintf( \"A visitor reported this comment:\\n\\n%s\\n\\nReview it: %s\", wp_trim_words( wp_strip_all_tags( $comment->comment_content ), 60 ), admin_url( 'comment.php?action=editcomment&c=' . $comment_id ) )\n        );\n    }\n\n    if ( $reports >= $wpide_report_threshold ) {\n        wp_set_comment_status( $comment_id, 'hold' );\n    }\n\n    wp_send_json_success();\n};\n\nadd_action( 'wp_ajax_wpide_report_comment', $wpide_handle_report );\nadd_action( 'wp_ajax_nopriv_wpide_report_comment', $wpide_handle_report );",
            "requires": [],
            "notes": "Page caching longer than 12 hours can make the report link fail, because the security token expires."
        },
        {
            "id": "spam-links-in-commenter-name",
            "version": 1,
            "title": "Send Comments With Links in the Name Field to Spam",
            "description": "Marks comments as spam when the name field contains a web address, a classic trick of spam bots.",
            "category": "comments",
            "tags": [
                "comments",
                "spam",
                "filter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_comment_approved', function ( $approved, $commentdata ) {\n    if ( is_wp_error( $approved ) || ! empty( $commentdata['user_id'] ) ) {\n        return $approved;\n    }\n\n    $name = isset( $commentdata['comment_author'] ) ? (string) $commentdata['comment_author'] : '';\n\n    return preg_match( '#https?://|www\\.|\\.(com|net|ru|xyz|top)\\b#i', $name ) ? 'spam' : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "close-comments-after-count",
            "version": 1,
            "title": "Close Comments After a Set Number",
            "description": "Closes the comment form on a post once it has reached the number of approved comments you choose, keeping threads manageable.",
            "category": "comments",
            "tags": [
                "comments",
                "moderation",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Close comments once a post has this many approved comments.\n$wpide_max_comments_per_post = 200;\n\nadd_filter( 'comments_open', function ( $open, $post_id ) use ( $wpide_max_comments_per_post ) {\n    if ( ! $open || ! $post_id ) {\n        return $open;\n    }\n\n    return (int) get_comments_number( $post_id ) < $wpide_max_comments_per_post;\n}, 20, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "email-on-comment-approval",
            "version": 1,
            "title": "Email Commenters When Their Comment Is Approved",
            "description": "Lets people know by email when the comment they left has been approved and is live, with a link to it.",
            "category": "comments",
            "tags": [
                "comments",
                "email",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'transition_comment_status', function ( $new_status, $old_status, $comment ) {\n    if ( 'approved' !== $new_status || 'unapproved' !== $old_status || ! is_email( $comment->comment_author_email ) ) {\n        return;\n    }\n\n    if ( ! in_array( $comment->comment_type, array( '', 'comment' ), true ) || (int) $comment->user_id === get_current_user_id() ) {\n        return;\n    }\n\n    wp_mail(\n        $comment->comment_author_email,\n        sprintf( 'Your comment on \"%s\" is live', wp_specialchars_decode( get_the_title( $comment->comment_post_ID ), ENT_QUOTES ) ),\n        sprintf( \"Hi %s,\\n\\nThanks for your comment. It has been approved and is now visible:\\n%s\", $comment->comment_author, get_comment_link( $comment ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "latest-comment-teaser",
            "version": 1,
            "title": "Latest Comment Teaser Inside Long Posts",
            "description": "Shows a short quote from the most recent approved comment two thirds of the way through a post, with a link to the discussion.",
            "category": "comments",
            "tags": [
                "comments",
                "engagement",
                "social-proof"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_two_thirds",
            "insert_number": 1,
            "code": "<?php\n$wpide_latest = is_single() ? get_comments( array( 'post_id' => get_the_ID(), 'status' => 'approve', 'type' => 'comment', 'number' => 1 ) ) : array();\nif ( $wpide_latest ) :\n    $wpide_latest = $wpide_latest[0];\n    ?>\n<blockquote class=\"wpide-comment-teaser\" style=\"margin:2em 0;padding:1em 1.25em;border-left:4px solid currentColor;opacity:.9\">\n    <p style=\"margin:0 0 .5em\">&ldquo;<?php echo esc_html( wp_trim_words( wp_strip_all_tags( $wpide_latest->comment_content ), 30 ) ); ?>&rdquo;</p>\n    <cite style=\"font-size:.9em\">&mdash; <?php echo esc_html( get_comment_author( $wpide_latest ) ); ?>, <a href=\"<?php echo esc_url( get_comment_link( $wpide_latest ) ); ?>\">read the discussion</a></cite>\n</blockquote>\n<?php endif; ?>",
            "requires": [],
            "notes": "Nothing is shown on posts without approved comments."
        },
        {
            "id": "edd-download-questions-prompt",
            "version": 1,
            "title": "Questions Prompt Below Easy Digital Downloads Products",
            "description": "Invites visitors to ask questions in the comments at the end of each Easy Digital Downloads product page, when comments are open.",
            "category": "comments",
            "tags": [
                "comments",
                "edd",
                "engagement"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "edd_after_download_content",
            "insert_number": 1,
            "code": "<?php if ( comments_open() ) : ?>\n<p class=\"wpide-edd-questions\" style=\"margin:1.5em 0;padding:.75em 1em;border-radius:6px;background:rgba(127,127,127,.08)\">\n    Questions before you buy? <a href=\"#respond\">Ask them in the comments</a> and we will answer publicly so others can benefit too.\n</p>\n<?php endif; ?>",
            "requires": [
                "edd"
            ],
            "notes": "Downloads need comments enabled for the prompt to appear."
        },
        {
            "id": "automatic-table-of-contents",
            "version": 1,
            "title": "Automatic Table of Contents",
            "description": "Adds a linked table of contents above posts that have enough headings, and gives each heading an anchor so readers can jump straight to a section.",
            "category": "content",
            "tags": [
                "toc",
                "headings",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Minimum number of headings before the table of contents appears.\n$wpide_toc_min_headings = 3;\n// Heading levels to include (2 = h2, 3 = h3 ...).\n$wpide_toc_levels = array( 2, 3 );\n// Title shown above the list.\n$wpide_toc_title = 'Contents';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_toc_min_headings, $wpide_toc_levels, $wpide_toc_title ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $levels  = implode( '', array_map( 'absint', $wpide_toc_levels ) );\n    $pattern = '/<h([' . $levels . '])([^>]*)>(.*?)<\\/h\\1>/is';\n\n    if ( ! preg_match_all( $pattern, $content, $found ) || count( $found[0] ) < $wpide_toc_min_headings ) {\n        return $content;\n    }\n\n    $used  = array();\n    $items = array();\n\n    $result = preg_replace_callback( $pattern, function ( $m ) use ( &$used, &$items ) {\n        $text  = trim( wp_strip_all_tags( $m[3] ) );\n        $attrs = $m[2];\n\n        if ( preg_match( '/\\sid=[\"\\']([^\"\\']+)[\"\\']/i', $attrs, $id_match ) ) {\n            $id = $id_match[1];\n        } else {\n            $base = sanitize_title( $text );\n            $base = '' !== $base ? $base : 'section';\n            $id   = $base;\n            $i    = 2;\n\n            while ( isset( $used[ $id ] ) ) {\n                $id = $base . '-' . $i++;\n            }\n\n            $attrs .= ' id=\"' . esc_attr( $id ) . '\"';\n        }\n\n        $used[ $id ] = true;\n        $items[]     = array( 'level' => (int) $m[1], 'id' => $id, 'text' => $text );\n\n        return '<h' . $m[1] . $attrs . '>' . $m[3] . '</h' . $m[1] . '>';\n    }, $content );\n\n    if ( null === $result || ! $items ) {\n        return $content;\n    }\n\n    $top  = min( wp_list_pluck( $items, 'level' ) );\n    $html = '<nav class=\"wpide-toc\" aria-label=\"' . esc_attr( $wpide_toc_title ) . '\" style=\"margin:0 0 2em;padding:1em 1.25em;border:1px solid rgba(0,0,0,.12);border-radius:6px;\">';\n    $html .= '<p style=\"margin:0 0 .5em;\"><strong>' . esc_html( $wpide_toc_title ) . '</strong></p><ul style=\"margin:0;\">';\n\n    foreach ( $items as $item ) {\n        $indent = $item['level'] > $top ? ' style=\"margin-left:' . ( ( $item['level'] - $top ) * 1.25 ) . 'em;\"' : '';\n        $html  .= '<li' . $indent . '><a href=\"#' . esc_attr( $item['id'] ) . '\">' . esc_html( $item['text'] ) . '</a></li>';\n    }\n\n    return $html . '</ul></nav>' . $result;\n}, 20 );",
            "requires": [],
            "notes": "Change the minimum heading count, the heading levels or the title in the variables at the top."
        },
        {
            "id": "heading-anchor-links",
            "version": 1,
            "title": "Copyable Anchor Links on Headings",
            "description": "Gives h2 to h4 headings in posts and pages an ID and a small # link that appears on hover, so readers can share a link to an exact section.",
            "category": "content",
            "tags": [
                "headings",
                "anchors",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular() || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $used = array();\n\n    $result = preg_replace_callback( '/<h([2-4])([^>]*)>(.*?)<\\/h\\1>/is', function ( $m ) use ( &$used ) {\n        $attrs = $m[2];\n\n        if ( preg_match( '/\\sid=[\"\\']([^\"\\']+)[\"\\']/i', $attrs, $id_match ) ) {\n            $id = $id_match[1];\n        } else {\n            $base = sanitize_title( wp_strip_all_tags( $m[3] ) );\n            $base = '' !== $base ? $base : 'section';\n            $id   = $base;\n            $i    = 2;\n\n            while ( isset( $used[ $id ] ) ) {\n                $id = $base . '-' . $i++;\n            }\n\n            $attrs .= ' id=\"' . esc_attr( $id ) . '\"';\n        }\n\n        $used[ $id ] = true;\n\n        return '<h' . $m[1] . $attrs . '>' . $m[3] . ' <a class=\"wpide-heading-anchor\" href=\"#' . esc_attr( $id ) . '\" aria-label=\"Link to this section\">#</a></h' . $m[1] . '>';\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 25 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.wpide-heading-anchor{margin-left:.3em;font-weight:400;text-decoration:none;opacity:0;transition:opacity .15s}h2:hover>.wpide-heading-anchor,h3:hover>.wpide-heading-anchor,h4:hover>.wpide-heading-anchor,.wpide-heading-anchor:focus{opacity:.6}</style>' . \"\\n\";\n} );",
            "requires": [],
            "notes": "Works alongside the table of contents snippet; headings that already have an ID keep it."
        },
        {
            "id": "reading-time-above-posts",
            "version": 1,
            "title": "Estimated Reading Time Above Posts",
            "description": "Shows a short \"5 min read\" line at the top of every blog post, calculated from the number of words in the post.",
            "category": "content",
            "tags": [
                "reading-time",
                "posts",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Average reading speed in words per minute.\n$wpide_words_per_minute = 220;\n// Text after the number of minutes.\n$wpide_reading_label = 'min read';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_words_per_minute, $wpide_reading_label ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $text    = wp_strip_all_tags( strip_shortcodes( get_post_field( 'post_content', get_the_ID() ) ) );\n    $words   = count( preg_split( '/\\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY ) );\n    $minutes = max( 1, (int) ceil( $words / max( 1, $wpide_words_per_minute ) ) );\n\n    return '<p class=\"wpide-reading-time\" style=\"opacity:.75;font-size:.9em;\">' . esc_html( $minutes . ' ' . $wpide_reading_label ) . '</p>' . $content;\n}, 20 );",
            "requires": [],
            "notes": "Adjust the reading speed in words per minute or the label text at the top of the snippet."
        },
        {
            "id": "word-count-shortcode",
            "version": 1,
            "title": "Word Count Shortcode",
            "description": "Adds a [word_count] shortcode that prints how many words the current post contains, handy for long-form guides and writing portfolios.",
            "category": "content",
            "tags": [
                "shortcode",
                "word-count",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'word_count', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'id' => 0 ), $atts, 'word_count' );\n    $post_id = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n\n    if ( ! $post_id || ( ! is_post_publicly_viewable( $post_id ) && ! current_user_can( 'read_post', $post_id ) ) ) {\n        return '';\n    }\n\n    $text  = wp_strip_all_tags( strip_shortcodes( get_post_field( 'post_content', $post_id ) ) );\n    $count = count( preg_split( '/\\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY ) );\n\n    return '<span class=\"wpide-word-count\">' . esc_html( number_format_i18n( $count ) ) . '</span>';\n} );",
            "requires": [],
            "notes": "Use [word_count id=\"123\"] to show the count of another post."
        },
        {
            "id": "simple-post-views-counter",
            "version": 1,
            "title": "Simple Post Views Counter",
            "description": "Counts how many times each blog post is viewed, shows the total in a Views column in the posts list and adds a [post_views] shortcode for the front end.",
            "category": "content",
            "tags": [
                "views",
                "statistics",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_singular( 'post' ) || is_preview() || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';\n\n    if ( '' === $agent || preg_match( '/bot|crawl|spider|slurp|preview|monitor|facebookexternalhit/', $agent ) ) {\n        return;\n    }\n\n    $post_id = get_queried_object_id();\n    update_post_meta( $post_id, 'wpide_post_views', (int) get_post_meta( $post_id, 'wpide_post_views', true ) + 1 );\n} );\n\nadd_shortcode( 'post_views', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'id' => 0 ), $atts, 'post_views' );\n    $post_id = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n\n    return '<span class=\"wpide-post-views\">' . esc_html( number_format_i18n( (int) get_post_meta( $post_id, 'wpide_post_views', true ) ) ) . '</span>';\n} );\n\nadd_filter( 'manage_post_posts_columns', function ( $columns ) {\n    $columns['wpide_views'] = 'Views';\n\n    return $columns;\n} );\n\nadd_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_views' === $column ) {\n        echo esc_html( number_format_i18n( (int) get_post_meta( $post_id, 'wpide_post_views', true ) ) );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Visits from logged-in editors and common bots are not counted. Full-page caching stops the counter from seeing cached visits."
        },
        {
            "id": "share-links-after-posts",
            "version": 1,
            "title": "Share Links Below Posts",
            "description": "Adds plain share links for X, Facebook, LinkedIn, WhatsApp and email under each blog post. No third-party scripts or tracking pixels are loaded.",
            "category": "content",
            "tags": [
                "social",
                "sharing",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Networks to show, in order. Available: x, facebook, linkedin, whatsapp, email.\n$wpide_share_networks = array( 'x', 'facebook', 'linkedin', 'whatsapp', 'email' );\n// Heading above the links.\n$wpide_share_label = 'Share this post';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_share_networks, $wpide_share_label ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $url   = rawurlencode( get_permalink() );\n    $title = rawurlencode( wp_strip_all_tags( get_the_title() ) );\n    $all   = array(\n        'x'        => array( 'X', 'https://x.com/intent/post?url=' . $url . '&text=' . $title ),\n        'facebook' => array( 'Facebook', 'https://www.facebook.com/sharer/sharer.php?u=' . $url ),\n        'linkedin' => array( 'LinkedIn', 'https://www.linkedin.com/sharing/share-offsite/?url=' . $url ),\n        'whatsapp' => array( 'WhatsApp', 'https://wa.me/?text=' . $title . '%20' . $url ),\n        'email'    => array( 'Email', 'mailto:?subject=' . $title . '&body=' . $url ),\n    );\n\n    $links = '';\n\n    foreach ( $wpide_share_networks as $network ) {\n        if ( ! isset( $all[ $network ] ) ) {\n            continue;\n        }\n\n        $external = 'email' !== $network ? ' target=\"_blank\" rel=\"noopener noreferrer\"' : '';\n        $links   .= '<a class=\"wpide-share-' . esc_attr( $network ) . '\" href=\"' . esc_url( $all[ $network ][1], array( 'https', 'mailto' ) ) . '\"' . $external . ' style=\"display:inline-block;margin:0 .5em .5em 0;padding:.35em .8em;border:1px solid currentColor;border-radius:4px;text-decoration:none;\">' . esc_html( $all[ $network ][0] ) . '</a>';\n    }\n\n    return $content . '<div class=\"wpide-share-links\" style=\"margin-top:2em;\"><p><strong>' . esc_html( $wpide_share_label ) . '</strong></p>' . $links . '</div>';\n}, 30 );",
            "requires": [],
            "notes": "Remove networks you do not want from the $wpide_share_networks list."
        },
        {
            "id": "print-button-shortcode",
            "version": 1,
            "title": "Print Button Shortcode",
            "description": "Adds a [print_button] shortcode that places a button visitors can click to print the current page, useful for recipes, guides and forms.",
            "category": "content",
            "tags": [
                "shortcode",
                "print",
                "button"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'print_button', function ( $atts ) {\n    $atts = shortcode_atts( array( 'label' => 'Print this page' ), $atts, 'print_button' );\n\n    return '<button type=\"button\" class=\"wp-element-button wpide-print-button\" onclick=\"window.print();\">' . esc_html( $atts['label'] ) . '</button>';\n} );",
            "requires": [],
            "notes": "Change the label with [print_button label=\"Print this recipe\"]."
        },
        {
            "id": "affiliate-disclosure-by-category",
            "version": 1,
            "title": "Affiliate Disclosure on Selected Categories",
            "description": "Shows an affiliate or sponsorship disclosure at the top of posts in the categories you choose, so you never forget to add it by hand.",
            "category": "content",
            "tags": [
                "affiliate",
                "disclosure",
                "compliance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Category slugs that need the disclosure.\n$wpide_disclosure_categories = array( 'reviews', 'deals' );\n// Disclosure text (basic HTML allowed).\n$wpide_disclosure_text = 'This post contains affiliate links. If you buy through them we may earn a commission, at no extra cost to you.';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_disclosure_categories, $wpide_disclosure_text ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! has_category( $wpide_disclosure_categories ) ) {\n        return $content;\n    }\n\n    return '<p class=\"wpide-disclosure\" style=\"padding:.75em 1em;font-size:.9em;background:rgba(0,0,0,.04);border-left:3px solid currentColor;\">' . wp_kses_post( $wpide_disclosure_text ) . '</p>' . $content;\n}, 20 );",
            "requires": [],
            "notes": "Set the category slugs and the disclosure text at the top of the snippet."
        },
        {
            "id": "old-post-notice",
            "version": 1,
            "title": "Notice on Old Posts",
            "description": "Warns readers at the top of posts older than a set number of years that the information may be out of date.",
            "category": "content",
            "tags": [
                "posts",
                "notice",
                "outdated"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Posts older than this many years get the notice.\n$wpide_old_post_years = 2;\n// Message shown; %s becomes the age of the post.\n$wpide_old_post_message = 'This article was published %s ago, so some details may be out of date.';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_old_post_years, $wpide_old_post_message ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $published = (int) get_post_time( 'U', true );\n\n    if ( ! $published || $published > strtotime( '-' . absint( $wpide_old_post_years ) . ' years' ) ) {\n        return $content;\n    }\n\n    $notice = sprintf( $wpide_old_post_message, human_time_diff( $published, time() ) );\n\n    return '<p class=\"wpide-old-post-notice\" style=\"padding:.75em 1em;background:#fcf9e8;border-left:3px solid #dba617;color:#1d2327;\">' . esc_html( $notice ) . '</p>' . $content;\n}, 20 );",
            "requires": [],
            "notes": "Change the age in years and the message; %s is replaced with the age, such as \"3 years\"."
        },
        {
            "id": "scheduled-content-shortcode",
            "version": 1,
            "title": "Show Content Between Two Dates",
            "description": "Adds a [scheduled] shortcode that shows the text inside it only from a start date, until an end date, or between both. Ideal for limited offers and event notices.",
            "category": "content",
            "tags": [
                "shortcode",
                "schedule",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'scheduled', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'from' => '', 'until' => '' ), $atts, 'scheduled' );\n    $now  = time();\n\n    if ( '' !== $atts['from'] ) {\n        $from = date_create( $atts['from'], wp_timezone() );\n\n        if ( $from && $now < $from->getTimestamp() ) {\n            return '';\n        }\n    }\n\n    if ( '' !== $atts['until'] ) {\n        $until = date_create( $atts['until'], wp_timezone() );\n\n        if ( $until && $now > $until->getTimestamp() ) {\n            return '';\n        }\n    }\n\n    return do_shortcode( (string) $content );\n} );",
            "requires": [],
            "notes": "Example: [scheduled from=\"2026-11-25 00:00\" until=\"2026-11-30 23:59\"]Black Friday sale![/scheduled]. Dates use the site time zone. Page caching may delay the switch."
        },
        {
            "id": "logged-in-logged-out-content-shortcodes",
            "version": 1,
            "title": "Content for Logged-In or Logged-Out Visitors",
            "description": "Adds [members_only] and [guests_only] shortcodes so you can show different text, links or offers to visitors depending on whether they are logged in.",
            "category": "content",
            "tags": [
                "shortcode",
                "members",
                "visibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'members_only', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'message' => '' ), $atts, 'members_only' );\n\n    if ( is_user_logged_in() ) {\n        return do_shortcode( (string) $content );\n    }\n\n    return '' !== $atts['message'] ? '<p class=\"wpide-members-only\">' . esc_html( $atts['message'] ) . ' <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">Log in</a></p>' : '';\n} );\n\nadd_shortcode( 'guests_only', function ( $atts, $content = '' ) {\n    return is_user_logged_in() ? '' : do_shortcode( (string) $content );\n} );",
            "requires": [],
            "notes": "[members_only message=\"Please log in to read this.\"] shows the message to guests instead of nothing."
        },
        {
            "id": "content-by-role-shortcode",
            "version": 1,
            "title": "Show Content to Specific Roles",
            "description": "Adds a [show_to] shortcode that reveals the text inside it only to users with the roles you list, such as editors or customers.",
            "category": "content",
            "tags": [
                "shortcode",
                "roles",
                "visibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'show_to', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'roles' => '' ), $atts, 'show_to' );\n\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $allowed = array_filter( array_map( 'trim', explode( ',', strtolower( $atts['roles'] ) ) ) );\n    $user    = wp_get_current_user();\n\n    if ( ! array_intersect( $allowed, (array) $user->roles ) ) {\n        return '';\n    }\n\n    return do_shortcode( (string) $content );\n} );",
            "requires": [],
            "notes": "Example: [show_to roles=\"editor,administrator\"]Internal note[/show_to]. Content stays hidden from everyone else, including guests."
        },
        {
            "id": "button-shortcode",
            "version": 1,
            "title": "Button Shortcode",
            "description": "Adds a [button] shortcode that turns a link into a styled button using your theme's button styles, for calls to action inside classic content and widgets.",
            "category": "content",
            "tags": [
                "shortcode",
                "button",
                "cta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'button', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'url'     => '',\n        'text'    => 'Learn more',\n        'new_tab' => 'no',\n        'class'   => '',\n    ), $atts, 'button' );\n\n    if ( '' === $atts['url'] ) {\n        return '';\n    }\n\n    $target = 'yes' === $atts['new_tab'] ? ' target=\"_blank\" rel=\"noopener noreferrer\"' : '';\n    $class  = trim( 'wp-element-button button wpide-button ' . implode( ' ', array_map( 'sanitize_html_class', explode( ' ', $atts['class'] ) ) ) );\n\n    return '<a class=\"' . esc_attr( $class ) . '\" href=\"' . esc_url( $atts['url'] ) . '\"' . $target . '>' . esc_html( $atts['text'] ) . '</a>';\n} );",
            "requires": [],
            "notes": "Example: [button url=\"/contact/\" text=\"Get in touch\" new_tab=\"no\"]."
        },
        {
            "id": "notice-box-shortcode",
            "version": 1,
            "title": "Notice Box Shortcode",
            "description": "Adds a [notice] shortcode for colored info, success, warning and error boxes that make tips and important warnings stand out in your content.",
            "category": "content",
            "tags": [
                "shortcode",
                "notice",
                "callout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'notice', function ( $atts, $content = '' ) {\n    $atts   = shortcode_atts( array( 'type' => 'info', 'title' => '' ), $atts, 'notice' );\n    $colors = array(\n        'info'    => array( '#f0f6fc', '#2271b1' ),\n        'success' => array( '#edfaef', '#00a32a' ),\n        'warning' => array( '#fcf9e8', '#dba617' ),\n        'error'   => array( '#fcf0f1', '#d63638' ),\n    );\n    $type   = isset( $colors[ $atts['type'] ] ) ? $atts['type'] : 'info';\n    $style  = sprintf( 'margin:1.5em 0;padding:1em 1.25em;background:%s;border-left:4px solid %s;color:#1d2327;border-radius:2px;', $colors[ $type ][0], $colors[ $type ][1] );\n    $title  = '' !== $atts['title'] ? '<p style=\"margin:0 0 .4em;\"><strong>' . esc_html( $atts['title'] ) . '</strong></p>' : '';\n\n    return '<div class=\"wpide-notice wpide-notice-' . esc_attr( $type ) . '\" role=\"note\" style=\"' . esc_attr( $style ) . '\">' . $title . wpautop( do_shortcode( wp_kses_post( (string) $content ) ) ) . '</div>';\n} );",
            "requires": [],
            "notes": "Example: [notice type=\"warning\" title=\"Heads up\"]Back up first.[/notice]. Types are info, success, warning and error."
        },
        {
            "id": "site-info-shortcodes",
            "version": 1,
            "title": "Site Name, Tagline and URL Shortcodes",
            "description": "Adds [site_name], [site_tagline] and [site_url] shortcodes so text that mentions your site stays correct if you rename or move it.",
            "category": "content",
            "tags": [
                "shortcode",
                "site-name",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'site_name', function () {\n    return esc_html( get_bloginfo( 'name' ) );\n} );\n\nadd_shortcode( 'site_tagline', function () {\n    return esc_html( get_bloginfo( 'description' ) );\n} );\n\nadd_shortcode( 'site_url', function ( $atts ) {\n    $atts = shortcode_atts( array( 'link' => 'no' ), $atts, 'site_url' );\n    $url  = home_url( '/' );\n\n    return 'yes' === $atts['link'] ? '<a href=\"' . esc_url( $url ) . '\">' . esc_html( untrailingslashit( preg_replace( '#^https?://#', '', $url ) ) ) . '</a>' : esc_html( $url );\n} );",
            "requires": [],
            "notes": "[site_url link=\"yes\"] prints a clickable link instead of plain text."
        },
        {
            "id": "child-pages-shortcode",
            "version": 1,
            "title": "Child Pages List Shortcode",
            "description": "Adds a [child_pages] shortcode that lists the subpages of the current page, so section landing pages always link to everything below them.",
            "category": "content",
            "tags": [
                "shortcode",
                "pages",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'child_pages', function ( $atts ) {\n    $atts   = shortcode_atts( array( 'id' => 0, 'depth' => 1, 'sort' => 'menu_order' ), $atts, 'child_pages' );\n    $parent = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n\n    if ( ! $parent ) {\n        return '';\n    }\n\n    $list = wp_list_pages( array(\n        'child_of'    => $parent,\n        'depth'       => absint( $atts['depth'] ),\n        'title_li'    => '',\n        'echo'        => false,\n        'sort_column' => in_array( $atts['sort'], array( 'menu_order', 'post_title', 'post_date' ), true ) ? $atts['sort'] . ', post_title' : 'menu_order, post_title',\n    ) );\n\n    return $list ? '<ul class=\"wpide-child-pages\">' . $list . '</ul>' : '';\n} );",
            "requires": [],
            "notes": "Use [child_pages id=\"12\" depth=\"2\"] to list the children of another page or to include grandchildren."
        },
        {
            "id": "recent-posts-shortcode",
            "version": 1,
            "title": "Recent Posts Shortcode",
            "description": "Adds a [recent_posts] shortcode that lists your latest posts anywhere, optionally from one category and with dates.",
            "category": "content",
            "tags": [
                "shortcode",
                "recent-posts",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'recent_posts', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'number'    => 5,\n        'category'  => '',\n        'show_date' => 'no',\n    ), $atts, 'recent_posts' );\n\n    $args = array(\n        'numberposts'         => min( 50, max( 1, absint( $atts['number'] ) ) ),\n        'post_status'         => 'publish',\n        'ignore_sticky_posts' => true,\n        'no_found_rows'       => true,\n        'post__not_in'        => array( get_the_ID() ),\n    );\n\n    if ( '' !== $atts['category'] ) {\n        $args['category_name'] = sanitize_title( $atts['category'] );\n    }\n\n    $posts = get_posts( $args );\n\n    if ( ! $posts ) {\n        return '';\n    }\n\n    $html = '<ul class=\"wpide-recent-posts\">';\n\n    foreach ( $posts as $item ) {\n        $date  = 'yes' === $atts['show_date'] ? ' <time datetime=\"' . esc_attr( get_the_date( 'c', $item ) ) . '\">' . esc_html( get_the_date( '', $item ) ) . '</time>' : '';\n        $html .= '<li><a href=\"' . esc_url( get_permalink( $item ) ) . '\">' . esc_html( get_the_title( $item ) ) . '</a>' . $date . '</li>';\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [],
            "notes": "Example: [recent_posts number=\"5\" category=\"news\" show_date=\"yes\"]."
        },
        {
            "id": "previous-next-post-links",
            "version": 1,
            "title": "Previous and Next Post Links Below Posts",
            "description": "Adds links to the previous and next post at the end of each blog post, for themes that do not show post navigation.",
            "category": "content",
            "tags": [
                "navigation",
                "posts",
                "internal-links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Only link to posts that share a category with the current post.\n$wpide_same_category = false;\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_same_category ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $previous = get_previous_post_link( '<div class=\"wpide-nav-previous\" style=\"flex:1;\">&larr; %link</div>', '%title', $wpide_same_category );\n    $next     = get_next_post_link( '<div class=\"wpide-nav-next\" style=\"flex:1;text-align:right;\">%link &rarr;</div>', '%title', $wpide_same_category );\n\n    if ( ! $previous && ! $next ) {\n        return $content;\n    }\n\n    return $content . '<nav class=\"wpide-post-nav\" aria-label=\"Posts\" style=\"display:flex;gap:1.5em;margin-top:2.5em;\">' . $previous . $next . '</nav>';\n}, 40 );",
            "requires": [],
            "notes": "Set $wpide_same_category to true to only link to posts in the same category."
        },
        {
            "id": "featured-image-above-content",
            "version": 1,
            "title": "Show the Featured Image Above Post Content",
            "description": "Prints the featured image at the top of single posts, for themes that only use featured images on archive pages.",
            "category": "content",
            "tags": [
                "featured-image",
                "posts",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Image size to display.\n$wpide_featured_size = 'large';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_featured_size ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! has_post_thumbnail() ) {\n        return $content;\n    }\n\n    $image   = get_the_post_thumbnail( null, $wpide_featured_size, array( 'loading' => 'eager', 'fetchpriority' => 'high' ) );\n    $caption = get_the_post_thumbnail_caption();\n    $caption = $caption ? '<figcaption>' . wp_kses_post( $caption ) . '</figcaption>' : '';\n\n    return '<figure class=\"wpide-featured-image\" style=\"margin:0 0 2em;\">' . $image . $caption . '</figure>' . $content;\n}, 5 );",
            "requires": [],
            "notes": "Change the image size from large to full or any registered size. Do not use it if your theme already shows the image on single posts."
        },
        {
            "id": "find-and-replace-text",
            "version": 1,
            "title": "Replace Words in Content on Display",
            "description": "Swaps words or phrases in post titles, content and excerpts when pages are shown, without editing the posts. Useful after a rebrand or a product rename.",
            "category": "content",
            "tags": [
                "replace",
                "rebrand",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Text to find => replacement.\n$wpide_replacements = array(\n    'Old Brand Name' => 'New Brand Name',\n    'old-product'    => 'new-product',\n);\n\n$wpide_replace_text = function ( $text ) use ( $wpide_replacements ) {\n    if ( ! is_string( $text ) || '' === $text || is_admin() ) {\n        return $text;\n    }\n\n    foreach ( $wpide_replacements as $find => $replace ) {\n        $result = preg_replace( '/' . preg_quote( $find, '/' ) . '(?![^<]*>)/u', str_replace( array( '\\\\', '$' ), array( '\\\\\\\\', '\\\\$' ), $replace ), $text );\n        $text   = null === $result ? $text : $result;\n    }\n\n    return $text;\n};\n\nadd_filter( 'the_title', $wpide_replace_text );\nadd_filter( 'the_content', $wpide_replace_text, 30 );\nadd_filter( 'the_excerpt', $wpide_replace_text );",
            "requires": [],
            "notes": "Replacements are case-sensitive and skip HTML tags and attributes. The text saved in the database is not changed."
        },
        {
            "id": "automatic-keyword-links",
            "version": 1,
            "title": "Automatic Internal Links for Keywords",
            "description": "Turns the first mention of chosen keywords in each post into a link to the page you pick, building internal links without editing every post.",
            "category": "content",
            "tags": [
                "internal-links",
                "keywords",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Keyword => URL (full URL or a path on this site). Examples:\n// 'contact us'   => '/contact/',\n// 'our services' => '/services/',\n$wpide_keyword_links = array();\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_keyword_links ) {\n    if ( ! $wpide_keyword_links || ! is_singular() || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $current = untrailingslashit( (string) get_permalink() );\n    $parts   = preg_split( '/(<[^>]+>)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE );\n    $depth   = 0;\n    $done    = array();\n    $tokens  = array();\n\n    foreach ( $parts as $i => $part ) {\n        if ( '' === $part ) {\n            continue;\n        }\n\n        if ( '<' === $part[0] ) {\n            if ( preg_match( '/^<(a|h[1-6]|code|pre|script|style|button|textarea)[\\s>]/i', $part ) ) {\n                $depth++;\n            } elseif ( preg_match( '/^<\\/(a|h[1-6]|code|pre|script|style|button|textarea)>/i', $part ) ) {\n                $depth = max( 0, $depth - 1 );\n            }\n            continue;\n        }\n\n        if ( $depth ) {\n            continue;\n        }\n\n        foreach ( $wpide_keyword_links as $keyword => $url ) {\n            if ( isset( $done[ $keyword ] ) ) {\n                continue;\n            }\n\n            $href = preg_match( '#^https?://#', $url ) ? $url : home_url( $url );\n\n            if ( untrailingslashit( $href ) === $current ) {\n                $done[ $keyword ] = true;\n                continue;\n            }\n\n            $new = preg_replace_callback( '/\\b(' . preg_quote( $keyword, '/' ) . ')\\b/iu', function ( $m ) use ( $href, &$tokens ) {\n                $tokens[] = '<a class=\"wpide-auto-link\" href=\"' . esc_url( $href ) . '\">' . $m[1] . '</a>';\n\n                return \"\\x01\" . ( count( $tokens ) - 1 ) . \"\\x02\";\n            }, $part, 1, $count );\n\n            if ( $count && null !== $new ) {\n                $parts[ $i ]      = $new;\n                $part             = $new;\n                $done[ $keyword ] = true;\n            }\n        }\n    }\n\n    $content = implode( '', $parts );\n\n    foreach ( $tokens as $index => $link ) {\n        $content = str_replace( \"\\x01\" . $index . \"\\x02\", $link, $content );\n    }\n\n    return $content;\n}, 20 );",
            "requires": [],
            "notes": "Add your keywords and target pages to the list first; it is empty by default. Links are not added inside headings, existing links, code or buttons, and a post never links to itself."
        },
        {
            "id": "lead-paragraph-style",
            "version": 1,
            "title": "Larger Lead Paragraph on Posts",
            "description": "Makes the first paragraph of every blog post slightly larger, a classic magazine touch that draws readers into the article.",
            "category": "content",
            "tags": [
                "typography",
                "paragraph",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $result = preg_replace_callback( '/<p(?=[\\s>])([^>]*)>/i', function ( $m ) {\n        if ( preg_match( '/\\sclass=([\"\\'])/i', $m[1] ) ) {\n            return '<p' . preg_replace( '/\\sclass=([\"\\'])/i', ' class=$1wpide-lead ', $m[1], 1 ) . '>';\n        }\n\n        return '<p class=\"wpide-lead\"' . $m[1] . '>';\n    }, $content, 1 );\n\n    return null === $result ? $content : $result;\n}, 20 );\n\nadd_action( 'wp_head', function () {\n    if ( is_singular( 'post' ) ) {\n        echo '<style>.wpide-lead{font-size:1.2em;line-height:1.6;}</style>' . \"\\n\";\n    }\n} );",
            "requires": [],
            "notes": "Change the font size in the style rule at the bottom of the snippet."
        },
        {
            "id": "drop-cap-on-posts",
            "version": 1,
            "title": "Automatic Drop Cap on Posts",
            "description": "Styles the first letter of every blog post as a large decorative drop cap, without having to switch it on block by block.",
            "category": "content",
            "tags": [
                "typography",
                "drop-cap",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".single-post .wp-block-post-content > p:first-of-type::first-letter,\n.single-post .entry-content > p:first-of-type::first-letter {\n    float: left;\n    margin: 0.06em 0.12em 0 0;\n    font-size: 3.6em;\n    line-height: 0.85;\n    font-weight: 700;\n}",
            "requires": [],
            "notes": "Selectors cover block themes and most classic themes; adjust them if your theme uses different content classes."
        },
        {
            "id": "minimum-word-count-to-publish",
            "version": 1,
            "title": "Minimum Word Count to Publish Posts",
            "description": "Stops posts shorter than a minimum number of words from being published, in both the block editor and the classic editor, to keep thin content off the site.",
            "category": "content",
            "tags": [
                "editorial",
                "word-count",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum number of words a post needs before it can be published.\n$wpide_min_words = 300;\n\n$wpide_count_words = function ( $content ) {\n    return count( preg_split( '/\\s+/u', trim( wp_strip_all_tags( strip_shortcodes( (string) $content ) ) ), -1, PREG_SPLIT_NO_EMPTY ) );\n};\n\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) use ( $wpide_min_words, $wpide_count_words ) {\n    $post_id = isset( $prepared->ID ) ? (int) $prepared->ID : 0;\n    $status  = isset( $prepared->post_status ) ? $prepared->post_status : ( $post_id ? get_post_status( $post_id ) : 'draft' );\n\n    if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n        return $prepared;\n    }\n\n    $content = isset( $prepared->post_content ) ? $prepared->post_content : ( $post_id ? get_post_field( 'post_content', $post_id ) : '' );\n\n    if ( $wpide_count_words( $content ) < $wpide_min_words ) {\n        return new WP_Error( 'wpide_too_short', sprintf( 'This post needs at least %d words before it can be published.', $wpide_min_words ), array( 'status' => 400 ) );\n    }\n\n    return $prepared;\n}, 10, 2 );\n\nadd_filter( 'wp_insert_post_data', function ( $data ) use ( $wpide_min_words, $wpide_count_words ) {\n    if ( ! is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || 'post' !== $data['post_type'] || 'publish' !== $data['post_status'] ) {\n        return $data;\n    }\n\n    if ( $wpide_count_words( wp_unslash( $data['post_content'] ) ) < $wpide_min_words ) {\n        $data['post_status'] = 'draft';\n\n        add_filter( 'redirect_post_location', function ( $location ) {\n            return add_query_arg( 'wpide_too_short', 1, remove_query_arg( 'message', $location ) );\n        } );\n    }\n\n    return $data;\n} );\n\nadd_action( 'admin_notices', function () use ( $wpide_min_words ) {\n    if ( isset( $_GET['wpide_too_short'] ) ) {\n        echo '<div class=\"notice notice-error\"><p>' . esc_html( sprintf( 'The post was saved as a draft because it has fewer than %d words.', $wpide_min_words ) ) . '</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Drafts can still be saved at any length. Change the minimum at the top of the snippet."
        },
        {
            "id": "default-content-for-new-posts",
            "version": 1,
            "title": "Starter Text for New Posts",
            "description": "Fills the editor of every new post with your own starter text or outline, so writers begin from the same structure each time.",
            "category": "content",
            "tags": [
                "editor",
                "template",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type that gets the starter text.\n$wpide_starter_post_type = 'post';\n// Starter content (HTML or block markup).\n$wpide_starter_content = '<!-- wp:paragraph --><p>Introduction: what will the reader learn?</p><!-- /wp:paragraph -->\n\n<!-- wp:heading --><h2 class=\"wp-block-heading\">Main point</h2><!-- /wp:heading -->\n\n<!-- wp:paragraph --><p>Explain the main idea here.</p><!-- /wp:paragraph -->\n\n<!-- wp:heading --><h2 class=\"wp-block-heading\">Summary</h2><!-- /wp:heading -->';\n\nadd_filter( 'default_content', function ( $content, $post ) use ( $wpide_starter_post_type, $wpide_starter_content ) {\n    if ( '' !== $content || ! $post || $wpide_starter_post_type !== $post->post_type ) {\n        return $content;\n    }\n\n    return $wpide_starter_content;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the post type or the starter HTML. Existing posts are not affected."
        },
        {
            "id": "footnotes-shortcode",
            "version": 1,
            "title": "Footnotes Shortcode",
            "description": "Adds a [ref] shortcode for footnotes: each note becomes a numbered superscript link, and all notes are listed at the end of the post with links back.",
            "category": "content",
            "tags": [
                "shortcode",
                "footnotes",
                "references"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_footnotes = array();\n\nadd_shortcode( 'ref', function ( $atts, $content = '' ) use ( &$wpide_footnotes ) {\n    $post_id = (int) get_the_ID();\n    $content = trim( (string) $content );\n\n    if ( '' === $content ) {\n        return '';\n    }\n\n    $wpide_footnotes[ $post_id ][] = $content;\n    $number = count( $wpide_footnotes[ $post_id ] );\n    $id     = $post_id . '-' . $number;\n\n    return '<sup class=\"wpide-fn-ref\" id=\"fnref-' . esc_attr( $id ) . '\"><a href=\"#fn-' . esc_attr( $id ) . '\">' . $number . '</a></sup>';\n} );\n\nadd_filter( 'the_content', function ( $content ) use ( &$wpide_footnotes ) {\n    $post_id = (int) get_the_ID();\n\n    if ( empty( $wpide_footnotes[ $post_id ] ) ) {\n        return $content;\n    }\n\n    $html = '<ol class=\"wpide-footnotes\" style=\"font-size:.9em;\">';\n\n    foreach ( $wpide_footnotes[ $post_id ] as $index => $note ) {\n        $id    = $post_id . '-' . ( $index + 1 );\n        $html .= '<li id=\"fn-' . esc_attr( $id ) . '\">' . wp_kses_post( $note ) . ' <a href=\"#fnref-' . esc_attr( $id ) . '\" aria-label=\"Back to the text\">&#8617;</a></li>';\n    }\n\n    unset( $wpide_footnotes[ $post_id ] );\n\n    return $content . '<hr class=\"wpide-footnotes-separator\" />' . $html . '</ol>';\n}, 12 );",
            "requires": [],
            "notes": "Example: The study found this.[ref]Smith, 2024, page 12.[/ref] Notes can contain links and basic formatting."
        },
        {
            "id": "toggle-shortcode",
            "version": 1,
            "title": "Collapsible Toggle Shortcode",
            "description": "Adds a [toggle] shortcode that hides text behind a clickable title, built on the native details element so it works without JavaScript.",
            "category": "content",
            "tags": [
                "shortcode",
                "accordion",
                "toggle"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'toggle', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'title' => 'More', 'open' => 'no' ), $atts, 'toggle' );\n    $open = 'yes' === $atts['open'] ? ' open' : '';\n\n    return '<details class=\"wpide-toggle\" style=\"margin:1em 0;padding:.75em 1em;border:1px solid rgba(0,0,0,.15);border-radius:4px;\"' . $open . '><summary style=\"cursor:pointer;font-weight:600;\">' . esc_html( $atts['title'] ) . '</summary><div style=\"margin-top:.75em;\">' . wpautop( do_shortcode( wp_kses_post( (string) $content ) ) ) . '</div></details>';\n} );",
            "requires": [],
            "notes": "Example: [toggle title=\"What is included?\" open=\"no\"]Answer here.[/toggle]."
        },
        {
            "id": "email-protection-shortcode",
            "version": 1,
            "title": "Spam-Protected Email Shortcode",
            "description": "Adds an [email] shortcode that prints a clickable email address encoded so simple spam bots cannot harvest it.",
            "category": "content",
            "tags": [
                "shortcode",
                "email",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'email', function ( $atts, $content = '' ) {\n    $atts    = shortcode_atts( array( 'address' => '', 'label' => '' ), $atts, 'email' );\n    $address = sanitize_email( '' !== $atts['address'] ? $atts['address'] : wp_strip_all_tags( (string) $content ) );\n\n    if ( ! is_email( $address ) ) {\n        return '';\n    }\n\n    $label = '' !== $atts['label'] ? esc_html( $atts['label'] ) : antispambot( $address );\n\n    return '<a href=\"mailto:' . antispambot( $address, 1 ) . '\">' . $label . '</a>';\n} );",
            "requires": [],
            "notes": "Example: [email]hello@example.com[/email] or [email address=\"hello@example.com\" label=\"Email us\"]."
        },
        {
            "id": "relative-post-dates",
            "version": 1,
            "title": "Show Recent Post Dates as \"2 Days Ago\"",
            "description": "Displays the publish date of recent posts in a friendly relative format, such as \"3 hours ago\", and switches back to the normal date after a set number of days.",
            "category": "content",
            "tags": [
                "dates",
                "posts",
                "display"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Use relative dates for posts newer than this many days.\n$wpide_relative_days = 7;\n\nadd_filter( 'get_the_date', function ( $date, $format, $post ) use ( $wpide_relative_days ) {\n    if ( is_admin() || is_feed() || ( '' !== $format && get_option( 'date_format' ) !== $format ) ) {\n        return $date;\n    }\n\n    $timestamp = (int) get_post_time( 'U', true, $post );\n\n    if ( ! $timestamp || time() - $timestamp > $wpide_relative_days * DAY_IN_SECONDS || $timestamp > time() ) {\n        return $date;\n    }\n\n    return sprintf( '%s ago', human_time_diff( $timestamp, time() ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Machine-readable dates in datetime attributes are left untouched. Change the day limit at the top."
        },
        {
            "id": "custom-more-tag-link",
            "version": 1,
            "title": "Change the \"Read More\" Tag Link",
            "description": "Changes the text of the link created by the More block and stops it from jumping halfway down the post when clicked.",
            "category": "content",
            "tags": [
                "read-more",
                "excerpts",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Link text.\n$wpide_more_text = 'Keep reading &rarr;';\n// Keep the jump to the #more-123 anchor.\n$wpide_more_jump = false;\n\nadd_filter( 'the_content_more_link', function ( $link ) use ( $wpide_more_text, $wpide_more_jump ) {\n    $url = get_permalink();\n\n    if ( $wpide_more_jump ) {\n        $url .= '#more-' . get_the_ID();\n    }\n\n    return '<a class=\"more-link\" href=\"' . esc_url( $url ) . '\">' . wp_kses_post( $wpide_more_text ) . '</a>';\n} );",
            "requires": [],
            "notes": "Set $wpide_more_text to your own wording. Set $wpide_more_jump to true to keep the jump to the #more anchor."
        },
        {
            "id": "password-form-message",
            "version": 1,
            "title": "Custom Message on Password-Protected Posts",
            "description": "Replaces the standard \"This content is password protected\" sentence with your own message, for example explaining how to get the password.",
            "category": "content",
            "tags": [
                "password",
                "protected",
                "message"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Message shown above the password field.\n$wpide_password_message = 'This page is for clients only. Enter the password from your welcome email to continue.';\n\nadd_filter( 'the_password_form', function ( $output ) use ( $wpide_password_message ) {\n    $result = preg_replace( '/<p>.*?<\\/p>/s', '<p>' . str_replace( array( '\\\\', '$' ), array( '\\\\\\\\', '\\\\$' ), wp_kses_post( $wpide_password_message ) ) . '</p>', $output, 1 );\n\n    return null === $result ? $output : $result;\n} );",
            "requires": [],
            "notes": "Basic HTML such as links is allowed in the message."
        },
        {
            "id": "remove-protected-private-title-prefix",
            "version": 1,
            "title": "Remove \"Protected:\" and \"Private:\" From Titles",
            "description": "Drops the \"Protected:\" and \"Private:\" words WordPress adds in front of the titles of password-protected and private posts.",
            "category": "content",
            "tags": [
                "titles",
                "private",
                "protected"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'protected_title_format', function () {\n    return '%s';\n} );\n\nadd_filter( 'private_title_format', function () {\n    return '%s';\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-title-on-selected-pages",
            "version": 1,
            "title": "Hide the Title on Selected Pages",
            "description": "Removes the page title on the pages you list, such as a landing page or the homepage, while keeping it in menus, the browser tab and search results.",
            "category": "content",
            "tags": [
                "titles",
                "pages",
                "landing-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of the pages whose title should be hidden.\n$wpide_hidden_title_ids = array( 0 );\n\nadd_filter( 'the_title', function ( $title, $id = 0 ) use ( $wpide_hidden_title_ids ) {\n    if ( is_admin() || ! $id || ! in_the_loop() || ! is_singular() || (int) $id !== get_queried_object_id() ) {\n        return $title;\n    }\n\n    return in_array( (int) $id, array_map( 'intval', $wpide_hidden_title_ids ), true ) ? '' : $title;\n}, 10, 2 );",
            "requires": [],
            "notes": "Add the page IDs to the list. Some themes print the title outside the main loop, in which case hide it with CSS instead."
        },
        {
            "id": "clickable-urls-in-content",
            "version": 1,
            "title": "Turn Plain URLs Into Links",
            "description": "Converts web addresses and email addresses typed as plain text in posts into clickable links.",
            "category": "content",
            "tags": [
                "links",
                "urls",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', 'make_clickable', 12 );",
            "requires": [],
            "notes": "Text inside existing links, code and preformatted blocks is left alone."
        },
        {
            "id": "new-badge-on-recent-posts",
            "version": 1,
            "title": "\"New\" Badge on Recent Post Titles",
            "description": "Adds a small \"New\" label next to the titles of posts published in the last few days on your blog and archive pages.",
            "category": "content",
            "tags": [
                "badge",
                "posts",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Posts newer than this many days get the badge.\n$wpide_new_badge_days = 7;\n// Badge text.\n$wpide_new_badge_text = 'New';\n\nadd_filter( 'the_title', function ( $title, $id = 0 ) use ( $wpide_new_badge_days, $wpide_new_badge_text ) {\n    if ( is_admin() || is_feed() || is_singular() || ! in_the_loop() || ! $id || 'post' !== get_post_type( $id ) ) {\n        return $title;\n    }\n\n    $published = (int) get_post_time( 'U', true, $id );\n\n    if ( $published && time() - $published < $wpide_new_badge_days * DAY_IN_SECONDS ) {\n        $title .= ' <span class=\"wpide-new-badge\" style=\"display:inline-block;margin-left:.4em;padding:.1em .45em;font-size:.6em;vertical-align:middle;line-height:1.4;border-radius:3px;background:#d63638;color:#fff;\">' . esc_html( $wpide_new_badge_text ) . '</span>';\n    }\n\n    return $title;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the number of days or the label text. Themes that use the title inside an HTML attribute will show the word as plain text there."
        },
        {
            "id": "unpublish-posts-on-date",
            "version": 1,
            "title": "Unpublish Posts on an Expiry Date",
            "description": "Adds an \"Unpublish on\" date box to the post editor and moves expired posts back to draft automatically, for time-limited offers and announcements.",
            "category": "content",
            "tags": [
                "expiry",
                "schedule",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function () {\n    add_meta_box( 'wpide-expiry', 'Unpublish on', function ( $post ) {\n        wp_nonce_field( 'wpide_expiry_save', 'wpide_expiry_nonce' );\n        $value = get_post_meta( $post->ID, 'wpide_expires', true );\n        echo '<label class=\"screen-reader-text\" for=\"wpide-expires\">Unpublish on</label>';\n        echo '<input type=\"datetime-local\" id=\"wpide-expires\" name=\"wpide_expires\" value=\"' . esc_attr( $value ) . '\" style=\"width:100%;\" />';\n        echo '<p class=\"description\">Leave empty to keep the post published.</p>';\n    }, 'post', 'side' );\n} );\n\nadd_action( 'save_post_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_expiry_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wpide_expiry_nonce'] ) ), 'wpide_expiry_save' ) ) {\n        return;\n    }\n\n    if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $value = isset( $_POST['wpide_expires'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_expires'] ) ) : '';\n\n    if ( preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $value ) ) {\n        update_post_meta( $post_id, 'wpide_expires', $value );\n    } else {\n        delete_post_meta( $post_id, 'wpide_expires' );\n    }\n} );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_unpublish_expired_posts' ) ) {\n        wp_schedule_event( time() + 300, 'hourly', 'wpide_unpublish_expired_posts' );\n    }\n} );\n\nadd_action( 'wpide_unpublish_expired_posts', function () {\n    $expired = get_posts( array(\n        'post_type'   => 'post',\n        'post_status' => 'publish',\n        'fields'      => 'ids',\n        'numberposts' => 50,\n        'meta_query'  => array(\n            array( 'key' => 'wpide_expires', 'value' => '', 'compare' => '!=' ),\n            array( 'key' => 'wpide_expires', 'value' => current_time( 'Y-m-d\\TH:i' ), 'compare' => '<=' ),\n        ),\n    ) );\n\n    foreach ( $expired as $post_id ) {\n        wp_update_post( array( 'ID' => $post_id, 'post_status' => 'draft' ) );\n    }\n} );",
            "requires": [],
            "notes": "A check runs every hour with WP-Cron, so posts expire within an hour of the chosen time. Dates use the site time zone."
        },
        {
            "id": "mid-article-call-to-action",
            "version": 1,
            "title": "Call to Action in the Middle of Posts",
            "description": "Places a call-to-action box halfway through each post, where readers who are engaged are most likely to notice it.",
            "category": "content",
            "tags": [
                "cta",
                "content",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "content_half",
            "insert_number": 1,
            "code": "<div class=\"wpide-mid-cta\" style=\"margin:2em 0;padding:1.5em;border-radius:8px;background:#f0f6fc;color:#1d2327;text-align:center;\">\n    <p style=\"margin:0 0 .5em;font-size:1.15em;\"><strong>Want the full checklist?</strong></p>\n    <p style=\"margin:0 0 1em;\">Download the free PDF version of this guide and keep it handy.</p>\n    <p style=\"margin:0;\"><a class=\"wp-element-button button\" href=\"/free-checklist/\">Get the checklist</a></p>\n</div>",
            "requires": [],
            "notes": "Replace the heading, text and link with your own offer."
        },
        {
            "id": "repeating-ad-every-350-words",
            "version": 1,
            "title": "Repeating Ad Slot Every 350 Words",
            "description": "Inserts your ad code after every 350 words of a post, at paragraph boundaries, so long articles earn more without manual placement.",
            "category": "content",
            "tags": [
                "ads",
                "monetization",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "every_words",
            "insert_number": 350,
            "code": "<div class=\"wpide-ad-slot\" style=\"margin:2em 0;text-align:center;\">\n    <!-- Paste your ad code here. -->\n    <p style=\"margin:0;padding:1em;border:1px dashed rgba(0,0,0,.25);font-size:.85em;opacity:.7;\">Advertisement</p>\n</div>",
            "requires": [],
            "notes": "Change the word count in the snippet settings and paste your ad network code in place of the placeholder."
        },
        {
            "id": "read-next-link-in-content",
            "version": 1,
            "title": "\"Read Next\" Link Two Thirds Into Posts",
            "description": "Adds a \"Read next\" link to another post from the same category two thirds of the way through each article, keeping readers on your site.",
            "category": "content",
            "tags": [
                "internal-links",
                "engagement",
                "related"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "content_two_thirds",
            "insert_number": 1,
            "code": "<?php\n$wpide_read_next = get_posts( array(\n    'category__in'        => wp_get_post_categories( get_the_ID() ),\n    'post__not_in'        => array( get_the_ID() ),\n    'numberposts'         => 1,\n    'ignore_sticky_posts' => true,\n    'no_found_rows'       => true,\n) );\n\nif ( $wpide_read_next ) : ?>\n<p class=\"wpide-read-next\" style=\"margin:2em 0;padding:1em 1.25em;border-left:4px solid currentColor;background:rgba(0,0,0,.03);\">\n    <strong>Read next:</strong>\n    <a href=\"<?php echo esc_url( get_permalink( $wpide_read_next[0] ) ); ?>\"><?php echo esc_html( get_the_title( $wpide_read_next[0] ) ); ?></a>\n</p>\n<?php endif; ?>",
            "requires": [],
            "notes": "The suggestion is the newest other post in the same category; nothing is shown if there is none."
        },
        {
            "id": "author-cta-block-after-posts",
            "version": 1,
            "title": "Call-to-Action Group Block After Posts",
            "description": "Adds a ready-made group of blocks (heading, text and button) to the end of every post, which you can restyle with the block editor's own classes.",
            "category": "content",
            "tags": [
                "blocks",
                "cta",
                "posts"
            ],
            "code_type": "blocks",
            "insert_method": "auto",
            "location": "after_content",
            "insert_number": 1,
            "code": "<!-- wp:group {\"style\":{\"spacing\":{\"padding\":{\"top\":\"2em\",\"bottom\":\"2em\",\"left\":\"2em\",\"right\":\"2em\"}},\"border\":{\"radius\":\"8px\"},\"color\":{\"background\":\"#f0f6fc\",\"text\":\"#1d2327\"}},\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group has-text-color has-background\" style=\"border-radius:8px;color:#1d2327;background-color:#f0f6fc;padding-top:2em;padding-right:2em;padding-bottom:2em;padding-left:2em\"><!-- wp:heading {\"level\":3} -->\n<h3 class=\"wp-block-heading\">Need help with your project?</h3>\n<!-- /wp:heading -->\n\n<!-- wp:paragraph -->\n<p>Tell us what you are working on and we will get back to you within one business day.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:buttons -->\n<div class=\"wp-block-buttons\"><!-- wp:button -->\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"/contact/\">Contact us</a></div>\n<!-- /wp:button --></div>\n<!-- /wp:buttons --></div>\n<!-- /wp:group -->",
            "requires": [],
            "notes": "Edit the block markup in the snippet editor to change the wording, colors or link."
        },
        {
            "id": "scrollable-tables-on-mobile",
            "version": 1,
            "title": "Make Content Tables Scroll on Small Screens",
            "description": "Wraps tables in posts and pages in a scrollable container so wide tables no longer break the layout on phones.",
            "category": "content",
            "tags": [
                "tables",
                "mobile",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '<table' ) || is_feed() ) {\n        return $content;\n    }\n\n    $result = preg_replace_callback( '/(<figure[^>]*wp-block-table[^>]*>\\s*)?(<table\\b.*?<\\/table>)/is', function ( $m ) {\n        if ( ! empty( $m[1] ) ) {\n            return $m[0];\n        }\n\n        return '<div class=\"wpide-table-scroll\" style=\"overflow-x:auto;-webkit-overflow-scrolling:touch;max-width:100%;\">' . $m[2] . '</div>';\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 20 );",
            "requires": [],
            "notes": "Tables that are already inside a Table block wrapper are left as they are."
        },
        {
            "id": "remove-empty-paragraphs",
            "version": 1,
            "title": "Remove Empty Paragraphs From Content",
            "description": "Cleans up the empty paragraphs and stray line breaks left behind by pasted text, old page builders and shortcodes, so spacing stays even.",
            "category": "content",
            "tags": [
                "cleanup",
                "paragraphs",
                "formatting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    $result = preg_replace( '/<p(?:\\s[^>]*)?>(?:\\s|&nbsp;|&#160;|<br\\s*\\/?>)*<\\/p>/i', '', $content );\n\n    return null === $result ? $content : $result;\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-orphaned-shortcodes",
            "version": 1,
            "title": "Hide Leftover Shortcodes From Removed Plugins",
            "description": "Hides shortcodes such as [old_slider id=\"3\"] that show up as raw text after the plugin that handled them was removed.",
            "category": "content",
            "tags": [
                "shortcodes",
                "cleanup",
                "plugins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === strpos( $content, '[' ) ) {\n        return $content;\n    }\n\n    $result = preg_replace_callback( '/\\[(\\/?)([a-zA-Z][\\w\\-]*)(\\s[^\\]]*)?\\]/', function ( $m ) {\n        if ( shortcode_exists( $m[2] ) ) {\n            return $m[0];\n        }\n\n        $looks_like_shortcode = '/' === $m[1] || ! empty( $m[3] ) || preg_match( '/[_\\-]/', $m[2] );\n\n        return $looks_like_shortcode ? '' : $m[0];\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 12 );",
            "requires": [],
            "notes": "To avoid removing normal bracketed text, only tags with attributes, a closing tag, or an underscore or hyphen in their name are hidden."
        },
        {
            "id": "post-info-shortcodes",
            "version": 1,
            "title": "Post Title, Date and Author Shortcodes",
            "description": "Adds [post_title], [post_date], [post_modified], [post_author] and [post_url] shortcodes for templates, patterns and widgets that need details of the current post.",
            "category": "content",
            "tags": [
                "shortcode",
                "posts",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'post_title', function () {\n    return esc_html( get_the_title() );\n} );\n\nadd_shortcode( 'post_date', function ( $atts ) {\n    $atts = shortcode_atts( array( 'format' => '' ), $atts, 'post_date' );\n\n    return esc_html( get_the_date( $atts['format'] ) );\n} );\n\nadd_shortcode( 'post_modified', function ( $atts ) {\n    $atts = shortcode_atts( array( 'format' => '' ), $atts, 'post_modified' );\n\n    return esc_html( get_the_modified_date( $atts['format'] ) );\n} );\n\nadd_shortcode( 'post_author', function () {\n    $post = get_post();\n\n    return $post ? esc_html( get_the_author_meta( 'display_name', $post->post_author ) ) : '';\n} );\n\nadd_shortcode( 'post_url', function () {\n    return esc_url( get_permalink() );\n} );",
            "requires": [],
            "notes": "Date shortcodes accept a format, for example [post_date format=\"j F Y\"]."
        },
        {
            "id": "custom-field-shortcode",
            "version": 1,
            "title": "Custom Field Shortcode",
            "description": "Adds a [field] shortcode that prints the value of a custom field of the current post, so you can show details like a price or a subtitle inside the content.",
            "category": "content",
            "tags": [
                "shortcode",
                "custom-fields",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'field', function ( $atts ) {\n    $atts = shortcode_atts( array( 'name' => '', 'id' => 0, 'default' => '' ), $atts, 'field' );\n    $key  = sanitize_key( $atts['name'] );\n\n    if ( '' === $key || is_protected_meta( $key, 'post' ) ) {\n        return '';\n    }\n\n    $post_id = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n\n    if ( ! $post_id || ( ! is_post_publicly_viewable( $post_id ) && ! current_user_can( 'read_post', $post_id ) ) ) {\n        return '';\n    }\n\n    $value = get_post_meta( $post_id, $key, true );\n\n    if ( is_array( $value ) || is_object( $value ) || '' === (string) $value ) {\n        return esc_html( $atts['default'] );\n    }\n\n    return esc_html( $value );\n} );",
            "requires": [],
            "notes": "Example: [field name=\"subtitle\" default=\"-\"]. Hidden fields whose names start with an underscore are never shown."
        },
        {
            "id": "countdown-timer-shortcode",
            "version": 1,
            "title": "Countdown Timer Shortcode",
            "description": "Adds a [countdown] shortcode that shows a live days, hours, minutes and seconds countdown to a date, with a message once the time is up.",
            "category": "content",
            "tags": [
                "shortcode",
                "countdown",
                "events"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'countdown', function ( $atts ) {\n    $atts = shortcode_atts( array( 'to' => '', 'expired' => 'Time is up!' ), $atts, 'countdown' );\n    $date = date_create( $atts['to'], wp_timezone() );\n\n    if ( ! $date ) {\n        return '';\n    }\n\n    $id = wp_unique_id( 'wpide-countdown-' );\n\n    $html  = '<span id=\"' . esc_attr( $id ) . '\" class=\"wpide-countdown\" data-end=\"' . esc_attr( $date->getTimestamp() * 1000 ) . '\" data-expired=\"' . esc_attr( $atts['expired'] ) . '\"></span>';\n    $html .= '<script>(function(){var el=document.getElementById(' . wp_json_encode( $id ) . ');if(!el){return;}var end=parseInt(el.getAttribute(\"data-end\"),10);function pad(n){return n<10?\"0\"+n:n;}function tick(){var left=end-Date.now();if(left<=0){el.textContent=el.getAttribute(\"data-expired\");return;}var s=Math.floor(left/1000),d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);el.textContent=d+\"d \"+pad(h)+\"h \"+pad(m)+\"m \"+pad(s%60)+\"s\";setTimeout(tick,1000);}tick();})();</script>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Example: [countdown to=\"2026-12-31 23:59\" expired=\"The sale has ended.\"]. The date uses the site time zone."
        },
        {
            "id": "copy-button-for-code-blocks",
            "version": 1,
            "title": "Copy Button on Code Blocks",
            "description": "Adds a \"Copy\" button to every code block in your posts so readers can copy commands and code samples with one click.",
            "category": "content",
            "tags": [
                "code",
                "copy",
                "clipboard"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    if (!navigator.clipboard) {\n        return;\n    }\n\n    document.querySelectorAll('.entry-content pre, .wp-block-post-content pre, .wp-block-code').forEach(function (block) {\n        if (block.tagName !== 'PRE') {\n            return;\n        }\n\n        var button = document.createElement('button');\n        button.type = 'button';\n        button.className = 'wpide-copy-code';\n        button.textContent = 'Copy';\n        button.style.cssText = 'position:absolute;top:.5em;right:.5em;padding:.2em .6em;font-size:12px;cursor:pointer;';\n\n        if (getComputedStyle(block).position === 'static') {\n            block.style.position = 'relative';\n        }\n\n        button.addEventListener('click', function () {\n            var code = block.querySelector('code') || block;\n\n            navigator.clipboard.writeText(code.innerText.replace(/\\n?Copied!$|\\n?Copy$/, '')).then(function () {\n                button.textContent = 'Copied!';\n                setTimeout(function () {\n                    button.textContent = 'Copy';\n                }, 2000);\n            });\n        });\n\n        block.appendChild(button);\n    });\n})();",
            "requires": [],
            "notes": "The button appears on pre elements inside the content; style it with the .wpide-copy-code class."
        },
        {
            "id": "open-pdf-links-in-new-tab",
            "version": 1,
            "title": "Open PDF Links in a New Tab",
            "description": "Makes links to PDF files in your content open in a new browser tab, so visitors do not lose their place on your site.",
            "category": "content",
            "tags": [
                "pdf",
                "links",
                "new-tab"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '.pdf' ) ) {\n        return $content;\n    }\n\n    $result = preg_replace_callback( '/<a\\s[^>]*href=[\"\\'][^\"\\']+\\.pdf(?:[?#][^\"\\']*)?[\"\\'][^>]*>/i', function ( $m ) {\n        if ( false !== stripos( $m[0], ' target=' ) ) {\n            return $m[0];\n        }\n\n        return substr( $m[0], 0, -1 ) . ' target=\"_blank\" rel=\"noopener\">';\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 20 );",
            "requires": [],
            "notes": "Links that already set a target are left unchanged."
        },
        {
            "id": "excerpts-on-archive-pages",
            "version": 1,
            "title": "Show Excerpts Instead of Full Posts on Archives",
            "description": "Shows a short excerpt and a \"Read more\" link for each post on the blog page, archives and search results, for themes that print the full content of every post.",
            "category": "content",
            "tags": [
                "excerpts",
                "archives",
                "blog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( is_singular() || is_feed() || ! in_the_loop() || ! is_main_query() || doing_filter( 'get_the_excerpt' ) ) {\n        return $content;\n    }\n\n    static $running = false;\n\n    if ( $running ) {\n        return $content;\n    }\n\n    $running = true;\n    $excerpt = get_the_excerpt();\n    $running = false;\n\n    return '<p>' . esc_html( $excerpt ) . '</p><p><a class=\"more-link\" href=\"' . esc_url( get_permalink() ) . '\">Read more<span class=\"screen-reader-text\"> about ' . esc_html( get_the_title() ) . '</span></a></p>';\n}, 1 );",
            "requires": [],
            "notes": "The excerpt length and the link text follow your excerpt settings and other excerpt snippets."
        },
        {
            "id": "styled-blockquotes-scss",
            "version": 1,
            "title": "Elegant Blockquote Styles (SCSS)",
            "description": "Gives quotes in your posts a refined look with a large quotation mark, an accent border and a styled citation line.",
            "category": "content",
            "tags": [
                "scss",
                "quotes",
                "typography"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #2271b1;\n$quote-size: 1.15em;\n\n.entry-content,\n.wp-block-post-content {\n    blockquote,\n    .wp-block-quote {\n        position: relative;\n        margin: 2em 0;\n        padding: 0.5em 1.5em 0.5em 2.25em;\n        border-left: 4px solid $accent;\n        font-size: $quote-size;\n        font-style: italic;\n\n        &::before {\n            content: \"\\201C\";\n            position: absolute;\n            top: -0.15em;\n            left: 0.25em;\n            font-size: 3em;\n            line-height: 1;\n            color: rgba($accent, 0.35);\n        }\n\n        p:last-of-type {\n            margin-bottom: 0;\n        }\n\n        cite {\n            display: block;\n            margin-top: 0.75em;\n            font-size: 0.8em;\n            font-style: normal;\n            opacity: 0.75;\n\n            &::before {\n                content: \"\\2014\\00a0\";\n            }\n        }\n    }\n}",
            "requires": [],
            "notes": "Change the $accent variable to your brand color."
        },
        {
            "id": "content-table-styles-scss",
            "version": 1,
            "title": "Clean Table Styles for Posts (SCSS)",
            "description": "Gives tables in posts and pages a clean, readable look with a bold header row, light striping, comfortable padding and horizontal scrolling on phones.",
            "category": "content",
            "tags": [
                "scss",
                "tables",
                "typography"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$header-bg: #f0f0f1;\n$stripe: rgba(0, 0, 0, 0.03);\n$border: rgba(0, 0, 0, 0.12);\n\n.entry-content,\n.wp-block-post-content {\n    table {\n        width: 100%;\n        border-collapse: collapse;\n        font-size: 0.95em;\n    }\n\n    th,\n    td {\n        padding: 0.6em 0.9em;\n        border: 1px solid $border;\n        text-align: left;\n        vertical-align: top;\n    }\n\n    thead th,\n    tr:first-child th {\n        background: $header-bg;\n        font-weight: 600;\n    }\n\n    tbody tr:nth-child(even) {\n        background: $stripe;\n    }\n\n    @media (max-width: 600px) {\n        table {\n            display: block;\n            overflow-x: auto;\n            white-space: nowrap;\n        }\n    }\n}",
            "requires": [],
            "notes": "Change $header-bg and $stripe to match your theme."
        },
        {
            "id": "readable-post-typography-scss",
            "version": 1,
            "title": "Comfortable Reading Typography for Posts (SCSS)",
            "description": "Tunes the line length, line height and spacing of headings, paragraphs and lists inside blog posts for long-form reading.",
            "category": "content",
            "tags": [
                "scss",
                "typography",
                "readability"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$measure: 68ch;\n$line-height: 1.7;\n$flow: 1.25em;\n\n.single-post {\n    .entry-content,\n    .wp-block-post-content {\n        line-height: $line-height;\n\n        > p,\n        > ul,\n        > ol,\n        > blockquote {\n            max-width: $measure;\n        }\n\n        > * + * {\n            margin-top: $flow;\n        }\n\n        h2,\n        h3,\n        h4 {\n            margin-top: $flow * 2;\n            margin-bottom: $flow * 0.5;\n            line-height: 1.25;\n        }\n\n        li + li {\n            margin-top: 0.4em;\n        }\n    }\n}",
            "requires": [],
            "notes": "The measure limits the width of text blocks; wide and full-width blocks are not affected."
        },
        {
            "id": "styled-code-blocks-scss",
            "version": 1,
            "title": "Styled Code Blocks (SCSS)",
            "description": "Gives code blocks and inline code in your posts a dark, readable style with rounded corners, a monospace font stack and horizontal scrolling for long lines.",
            "category": "content",
            "tags": [
                "scss",
                "code",
                "developer"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$code-bg: #1e1e2e;\n$code-fg: #e4e4ef;\n$inline-bg: rgba(0, 0, 0, 0.06);\n$mono: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n\n.entry-content,\n.wp-block-post-content {\n    pre,\n    .wp-block-code {\n        overflow-x: auto;\n        padding: 1em 1.25em;\n        border-radius: 8px;\n        background: $code-bg;\n        color: $code-fg;\n        font-family: $mono;\n        font-size: 0.875em;\n        line-height: 1.6;\n\n        code {\n            padding: 0;\n            background: none;\n            color: inherit;\n        }\n    }\n\n    :not(pre) > code {\n        padding: 0.15em 0.4em;\n        border-radius: 4px;\n        background: $inline-bg;\n        font-family: $mono;\n        font-size: 0.9em;\n    }\n}",
            "requires": [],
            "notes": "Pairs well with the Copy Button on Code Blocks snippet."
        },
        {
            "id": "key-takeaways-block-before-content",
            "version": 1,
            "title": "Key Takeaways Box Before Posts (Blocks)",
            "description": "Adds a \"Key takeaways\" box built from blocks at the top of every post, a skimmable summary readers and search engines appreciate.",
            "category": "content",
            "tags": [
                "blocks",
                "summary",
                "posts"
            ],
            "code_type": "blocks",
            "insert_method": "auto",
            "location": "before_content",
            "insert_number": 1,
            "code": "<!-- wp:group {\"style\":{\"spacing\":{\"padding\":{\"top\":\"1.25em\",\"bottom\":\"1.25em\",\"left\":\"1.5em\",\"right\":\"1.5em\"}},\"border\":{\"left\":{\"color\":\"#2271b1\",\"width\":\"4px\"}},\"color\":{\"background\":\"#f6f7f7\",\"text\":\"#1d2327\"}},\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group has-text-color has-background\" style=\"border-left-color:#2271b1;border-left-width:4px;color:#1d2327;background-color:#f6f7f7;padding-top:1.25em;padding-right:1.5em;padding-bottom:1.25em;padding-left:1.5em\"><!-- wp:heading {\"level\":3} -->\n<h3 class=\"wp-block-heading\">Key takeaways</h3>\n<!-- /wp:heading -->\n\n<!-- wp:list -->\n<ul class=\"wp-block-list\"><!-- wp:list-item -->\n<li>The most important point of the article.</li>\n<!-- /wp:list-item -->\n\n<!-- wp:list-item -->\n<li>A second insight readers should remember.</li>\n<!-- /wp:list-item -->\n\n<!-- wp:list-item -->\n<li>What to do next.</li>\n<!-- /wp:list-item --></ul>\n<!-- /wp:list --></div>\n<!-- /wp:group -->",
            "requires": [],
            "notes": "Edit the list in the snippet editor; for per-post summaries, duplicate the snippet and limit it with conditions."
        },
        {
            "id": "newsletter-prompt-after-300-words",
            "version": 1,
            "title": "Newsletter Prompt After 300 Words",
            "description": "Shows a short newsletter invitation once readers are about 300 words into a post, after they have shown real interest but before most people stop scrolling.",
            "category": "content",
            "tags": [
                "newsletter",
                "cta",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "after_words",
            "insert_number": 300,
            "code": "<aside class=\"wpide-words-newsletter\" style=\"margin:2em 0;padding:1.25em 1.5em;border:1px solid rgba(0,0,0,.12);border-radius:8px;\">\n    <p style=\"margin:0 0 .75em;\"><strong>Enjoying this?</strong> Get the next article in your inbox, about twice a month.</p>\n    <a class=\"wp-element-button button\" href=\"/newsletter/\">Join the newsletter</a>\n</aside>",
            "requires": [],
            "notes": "Change the word count in the snippet settings and the link to your sign-up page."
        },
        {
            "id": "sponsor-message-at-80-percent",
            "version": 1,
            "title": "Sponsor Message Near the End of Posts",
            "description": "Places a sponsor or partner message 80% of the way through each post, where it reaches readers who are almost finished without interrupting the start.",
            "category": "content",
            "tags": [
                "sponsors",
                "monetization",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "content_80",
            "insert_number": 1,
            "code": "<aside class=\"wpide-sponsor\" style=\"margin:2em 0;padding:1em 1.25em;border-radius:6px;background:rgba(0,0,0,.035);font-size:.95em;\">\n    <p style=\"margin:0 0 .25em;font-size:.75em;letter-spacing:.06em;text-transform:uppercase;opacity:.7;\">Sponsored</p>\n    <p style=\"margin:0;\">This article is supported by <a href=\"https://example.com/\" rel=\"sponsored noopener\" target=\"_blank\">Example Partner</a>, who help small teams ship faster.</p>\n</aside>",
            "requires": [],
            "notes": "Replace the text, logo and link with your sponsor's details and keep the \"Sponsored\" label for transparency."
        },
        {
            "id": "ad-slot-after-first-quarter",
            "version": 1,
            "title": "Ad Slot After the First Quarter of Posts",
            "description": "Inserts your ad code after the first quarter of each post's content, a placement that tends to perform well without pushing the article down.",
            "category": "content",
            "tags": [
                "ads",
                "monetization",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "content_quarter",
            "insert_number": 1,
            "code": "<div class=\"wpide-ad-quarter\" style=\"margin:2em 0;text-align:center;\">\n    <!-- Paste your ad code here. -->\n    <p style=\"margin:0;padding:1em;border:1px dashed rgba(0,0,0,.25);font-size:.85em;opacity:.7;\">Advertisement</p>\n</div>",
            "requires": [],
            "notes": "Paste your ad network code in place of the placeholder."
        },
        {
            "id": "posts-per-page-for-post-type-archives",
            "version": 1,
            "title": "Posts per Page for Custom Post Type Archives",
            "description": "Sets how many entries each custom post type archive shows, separately from the blog setting, for example 12 portfolio items or every testimonial on one page.",
            "category": "archive",
            "tags": [
                "post-types",
                "pagination",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => number per page (-1 = all).\n$wpide_archive_per_page = array(\n    'portfolio'   => 12,\n    'testimonial' => -1,\n);\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_archive_per_page ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive() ) {\n        return;\n    }\n\n    foreach ( $wpide_archive_per_page as $post_type => $count ) {\n        if ( $query->is_post_type_archive( $post_type ) ) {\n            $query->set( 'posts_per_page', (int) $count );\n            return;\n        }\n    }\n} );",
            "requires": [],
            "notes": "Use -1 to show all entries on one page. WooCommerce products have their own setting and should not be listed here."
        },
        {
            "id": "order-posts-by-last-modified",
            "version": 1,
            "title": "Order Blog and Archives by Last Updated",
            "description": "Lists posts on the blog page and archives by the date they were last updated instead of the date they were published, so refreshed articles move back to the top.",
            "category": "archive",
            "tags": [
                "order",
                "archives",
                "modified-date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! ( $query->is_home() || $query->is_archive() ) || $query->get( 'orderby' ) ) {\n        return;\n    }\n\n    $query->set( 'orderby', 'modified' );\n    $query->set( 'order', 'DESC' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "random-order-for-post-type-archive",
            "version": 1,
            "title": "Random Order on a Post Type Archive",
            "description": "Shows the entries of selected post types, such as testimonials or team members, in a random order on each visit.",
            "category": "archive",
            "tags": [
                "random",
                "post-types",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types whose archive is shuffled.\n$wpide_random_post_types = array( 'testimonial' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_random_post_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( $wpide_random_post_types ) ) {\n        return;\n    }\n\n    $query->set( 'orderby', 'rand' );\n} );",
            "requires": [],
            "notes": "Random order works best when all entries fit on one page; with pagination, items can repeat across pages. Page caching keeps the same order until the cache clears."
        },
        {
            "id": "upcoming-events-archive",
            "version": 1,
            "title": "Upcoming Events Archive Sorted by Event Date",
            "description": "Orders an events post type archive by the event date stored in a custom field, soonest first, and hides events that have already happened.",
            "category": "archive",
            "tags": [
                "events",
                "custom-fields",
                "order"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Events post type and the custom field that holds the event date (YYYY-MM-DD).\n$wpide_event_post_type = 'event';\n$wpide_event_date_key  = 'event_date';\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_event_post_type, $wpide_event_date_key ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( $wpide_event_post_type ) ) {\n        return;\n    }\n\n    $query->set( 'meta_query', array(\n        'wpide_event_date' => array(\n            'key'     => $wpide_event_date_key,\n            'value'   => current_time( 'Y-m-d' ),\n            'compare' => '>=',\n            'type'    => 'DATE',\n        ),\n    ) );\n    $query->set( 'orderby', 'wpide_event_date' );\n    $query->set( 'order', 'ASC' );\n} );",
            "requires": [],
            "notes": "Set the post type and the custom field name. The field must hold dates as YYYY-MM-DD."
        },
        {
            "id": "custom-archive-title-wording",
            "version": 1,
            "title": "Custom Wording for Archive Titles",
            "description": "Rewrites archive headings with your own wording, such as \"Articles by Jane\" on author pages or \"Posts from March 2026\" on monthly archives.",
            "category": "archive",
            "tags": [
                "archives",
                "titles",
                "wording"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Title templates. %s is replaced with the term, author or date.\n$wpide_archive_titles = array(\n    'category' => '%s',\n    'tag'      => 'Posts tagged \"%s\"',\n    'author'   => 'Articles by %s',\n    'year'     => 'Posts from %s',\n    'month'    => 'Posts from %s',\n    'day'      => 'Posts from %s',\n);\n\nadd_filter( 'get_the_archive_title', function ( $title ) use ( $wpide_archive_titles ) {\n    if ( is_category() ) {\n        return sprintf( $wpide_archive_titles['category'], single_cat_title( '', false ) );\n    }\n\n    if ( is_tag() ) {\n        return sprintf( $wpide_archive_titles['tag'], single_tag_title( '', false ) );\n    }\n\n    if ( is_author() ) {\n        return sprintf( $wpide_archive_titles['author'], get_the_author_meta( 'display_name', get_queried_object_id() ) );\n    }\n\n    if ( is_day() ) {\n        return sprintf( $wpide_archive_titles['day'], get_the_date() );\n    }\n\n    if ( is_month() ) {\n        return sprintf( $wpide_archive_titles['month'], get_the_date( 'F Y' ) );\n    }\n\n    if ( is_year() ) {\n        return sprintf( $wpide_archive_titles['year'], get_the_date( 'Y' ) );\n    }\n\n    return $title;\n}, 20 );",
            "requires": [],
            "notes": "Edit the templates at the top; %s is replaced with the name or date."
        },
        {
            "id": "exclude-posts-from-blog-page",
            "version": 1,
            "title": "Hide Specific Posts From the Blog Page",
            "description": "Keeps the posts you list off the main blog page and its pagination while they stay reachable through their own URL, categories and search.",
            "category": "archive",
            "tags": [
                "blog",
                "exclude",
                "homepage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IDs of posts to keep off the blog page.\n$wpide_hidden_from_blog = array( 0 );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_hidden_from_blog ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() ) {\n        return;\n    }\n\n    $query->set( 'post__not_in', array_merge( (array) $query->get( 'post__not_in' ), array_map( 'absint', $wpide_hidden_from_blog ) ) );\n} );",
            "requires": [],
            "notes": "Add the post IDs to the list."
        },
        {
            "id": "blog-page-only-selected-categories",
            "version": 1,
            "title": "Show Only Selected Categories on the Blog Page",
            "description": "Limits the main blog page to posts from the categories you choose, for example only News, while other categories keep their own archives.",
            "category": "archive",
            "tags": [
                "blog",
                "categories",
                "homepage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slugs to show on the blog page, for example array( 'news' ).\n$wpide_blog_categories = array();\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_blog_categories ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() || ! $wpide_blog_categories ) {\n        return;\n    }\n\n    $query->set( 'category_name', implode( ',', array_map( 'sanitize_title', $wpide_blog_categories ) ) );\n} );",
            "requires": [],
            "notes": "The list is empty by default, which changes nothing. Add category slugs; posts from other categories still appear in their archives, search and feeds."
        },
        {
            "id": "redirect-single-post-archives",
            "version": 1,
            "title": "Skip Category and Tag Pages With Only One Post",
            "description": "Sends visitors straight to the post when a category or tag archive contains just one post, saving them a pointless extra click.",
            "category": "archive",
            "tags": [
                "archives",
                "redirect",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( ! ( is_category() || is_tag() ) || is_paged() || is_feed() ) {\n        return;\n    }\n\n    if ( 1 === (int) $wp_query->found_posts && ! empty( $wp_query->posts[0] ) ) {\n        wp_safe_redirect( get_permalink( $wp_query->posts[0] ), 302 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Uses a temporary redirect, so the archive works normally again as soon as a second post is added."
        },
        {
            "id": "empty-term-archives-404",
            "version": 1,
            "title": "Return 404 for Empty Category and Tag Pages",
            "description": "Shows your \"not found\" page with a 404 status for categories, tags and other terms that have no published posts, instead of an empty archive.",
            "category": "archive",
            "tags": [
                "archives",
                "404",
                "thin-content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( ( is_category() || is_tag() || is_tax() ) && ! is_paged() && ! $wp_query->have_posts() ) {\n        $wp_query->set_404();\n        status_header( 404 );\n        nocache_headers();\n    }\n}, 1 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "exclude-subcategory-posts",
            "version": 1,
            "title": "Hide Subcategory Posts From Parent Categories",
            "description": "Makes a category archive list only the posts assigned directly to that category, not the posts from its child categories.",
            "category": "archive",
            "tags": [
                "categories",
                "archives",
                "subcategories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'parse_tax_query', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_category() || empty( $query->tax_query->queries ) ) {\n        return;\n    }\n\n    foreach ( $query->tax_query->queries as $index => $clause ) {\n        if ( is_array( $clause ) && isset( $clause['taxonomy'] ) && 'category' === $clause['taxonomy'] ) {\n            $query->tax_query->queries[ $index ]['include_children'] = false;\n        }\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "subcategory-links-on-category-archives",
            "version": 1,
            "title": "Subcategory Links at the Top of Category Archives",
            "description": "Shows a row of links to the child categories at the top of a category archive, helping visitors drill down into large sections.",
            "category": "archive",
            "tags": [
                "categories",
                "navigation",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'loop_start', function ( $query ) {\n    static $done = false;\n\n    if ( $done || ! $query->is_main_query() || ! is_category() || is_paged() ) {\n        return;\n    }\n\n    $done     = true;\n    $children = get_categories( array(\n        'parent'     => get_queried_object_id(),\n        'hide_empty' => true,\n    ) );\n\n    if ( ! $children ) {\n        return;\n    }\n\n    echo '<nav class=\"wpide-subcategories\" aria-label=\"Subcategories\"><ul style=\"list-style:none;margin:0 0 2em;padding:0;display:flex;flex-wrap:wrap;gap:.5em;\">';\n\n    foreach ( $children as $child ) {\n        echo '<li><a href=\"' . esc_url( get_category_link( $child ) ) . '\" style=\"display:inline-block;padding:.3em .8em;border:1px solid currentColor;border-radius:999px;text-decoration:none;\">' . esc_html( $child->name ) . ' <span style=\"opacity:.6;\">' . esc_html( number_format_i18n( $child->count ) ) . '</span></a></li>';\n    }\n\n    echo '</ul></nav>';\n} );",
            "requires": [],
            "notes": "Only categories with posts are listed. The position depends on where your theme starts the post loop."
        },
        {
            "id": "post-count-in-archive-title",
            "version": 1,
            "title": "Post Count in Archive Titles",
            "description": "Adds the number of posts to category, tag, author and date archive headings, for example \"Recipes (42)\".",
            "category": "archive",
            "tags": [
                "archives",
                "titles",
                "count"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_the_archive_title', function ( $title ) {\n    global $wp_query;\n\n    if ( ! is_archive() || is_post_type_archive() || ! $wp_query instanceof WP_Query ) {\n        return $title;\n    }\n\n    return $title . ' <span class=\"wpide-archive-count\">(' . esc_html( number_format_i18n( (int) $wp_query->found_posts ) ) . ')</span>';\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "term-description-first-page-only",
            "version": 1,
            "title": "Show Category Descriptions on the First Page Only",
            "description": "Hides category, tag and taxonomy descriptions on page 2 and beyond of an archive, so long intro texts are not repeated on every page.",
            "category": "archive",
            "tags": [
                "archives",
                "descriptions",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_the_archive_description', function ( $description ) {\n    return is_paged() ? '' : $description;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "archives-list-shortcode",
            "version": 1,
            "title": "Archives List Shortcode",
            "description": "Adds an [archives] shortcode that lists your posts by month or year, as a list or a dropdown, anywhere in content or widgets.",
            "category": "archive",
            "tags": [
                "shortcode",
                "archives",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'archives', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'type'       => 'monthly',\n        'limit'      => '',\n        'show_count' => 'no',\n        'dropdown'   => 'no',\n        'post_type'  => 'post',\n    ), $atts, 'archives' );\n\n    $args = array(\n        'type'            => in_array( $atts['type'], array( 'yearly', 'monthly', 'weekly', 'daily', 'postbypost', 'alpha' ), true ) ? $atts['type'] : 'monthly',\n        'limit'           => $atts['limit'] ? absint( $atts['limit'] ) : '',\n        'show_post_count' => 'yes' === $atts['show_count'],\n        'post_type'       => post_type_exists( $atts['post_type'] ) ? $atts['post_type'] : 'post',\n        'echo'            => false,\n    );\n\n    if ( 'yes' === $atts['dropdown'] ) {\n        $args['format'] = 'option';\n\n        return '<select class=\"wpide-archives-dropdown\" aria-label=\"Archives\" onchange=\"if(this.value){window.location.href=this.value;}\"><option value=\"\">Select a period</option>' . wp_get_archives( $args ) . '</select>';\n    }\n\n    return '<ul class=\"wpide-archives\">' . wp_get_archives( $args ) . '</ul>';\n} );",
            "requires": [],
            "notes": "Example: [archives type=\"yearly\" show_count=\"yes\"] or [archives type=\"monthly\" limit=\"12\" dropdown=\"yes\"]."
        },
        {
            "id": "category-list-shortcode",
            "version": 1,
            "title": "Category List Shortcode",
            "description": "Adds a [category_list] shortcode that prints a linked list of your categories, optionally with post counts and nested subcategories.",
            "category": "archive",
            "tags": [
                "shortcode",
                "categories",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'category_list', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'show_count'   => 'no',\n        'hierarchical' => 'yes',\n        'exclude'      => '',\n        'taxonomy'     => 'category',\n    ), $atts, 'category_list' );\n\n    $list = wp_list_categories( array(\n        'title_li'     => '',\n        'echo'         => false,\n        'show_count'   => 'yes' === $atts['show_count'],\n        'hierarchical' => 'yes' === $atts['hierarchical'],\n        'exclude'      => wp_parse_id_list( $atts['exclude'] ),\n        'taxonomy'     => taxonomy_exists( $atts['taxonomy'] ) ? $atts['taxonomy'] : 'category',\n    ) );\n\n    return '<ul class=\"wpide-category-list\">' . $list . '</ul>';\n} );",
            "requires": [],
            "notes": "Example: [category_list show_count=\"yes\" exclude=\"1,7\" taxonomy=\"category\"]."
        },
        {
            "id": "tag-cloud-shortcode",
            "version": 1,
            "title": "Tag Cloud Shortcode",
            "description": "Adds a [tag_cloud] shortcode to show a tag cloud inside a page or post, with control over how many tags appear and how large they get.",
            "category": "archive",
            "tags": [
                "shortcode",
                "tags",
                "tag-cloud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'tag_cloud', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'number'   => 45,\n        'smallest' => 0.8,\n        'largest'  => 1.6,\n        'taxonomy' => 'post_tag',\n        'orderby'  => 'name',\n    ), $atts, 'tag_cloud' );\n\n    $cloud = wp_tag_cloud( array(\n        'number'   => absint( $atts['number'] ),\n        'smallest' => (float) $atts['smallest'],\n        'largest'  => (float) $atts['largest'],\n        'unit'     => 'em',\n        'taxonomy' => taxonomy_exists( $atts['taxonomy'] ) ? $atts['taxonomy'] : 'post_tag',\n        'orderby'  => 'count' === $atts['orderby'] ? 'count' : 'name',\n        'order'    => 'count' === $atts['orderby'] ? 'DESC' : 'ASC',\n        'echo'     => false,\n    ) );\n\n    return $cloud ? '<div class=\"wpide-tag-cloud\">' . $cloud . '</div>' : '';\n} );",
            "requires": [],
            "notes": "Example: [tag_cloud number=\"30\" smallest=\"0.8\" largest=\"1.6\" taxonomy=\"post_tag\"]. Sizes are in em."
        },
        {
            "id": "custom-post-types-on-author-archives",
            "version": 1,
            "title": "Include Custom Post Types on Author Archives",
            "description": "Lists an author's portfolio items, recipes or other custom post type entries on their author page, not just their blog posts.",
            "category": "archive",
            "tags": [
                "authors",
                "post-types",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types shown on author archives.\n$wpide_author_archive_types = array( 'post', 'portfolio' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_author_archive_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_author() ) {\n        return;\n    }\n\n    $query->set( 'post_type', array_values( array_filter( $wpide_author_archive_types, 'post_type_exists' ) ) );\n} );",
            "requires": [],
            "notes": "Set the post types to include. They must support authors."
        },
        {
            "id": "custom-post-types-on-term-archives",
            "version": 1,
            "title": "Include Custom Post Types on Category and Tag Archives",
            "description": "Makes category and tag archives list custom post type entries that use those categories and tags, not only regular posts.",
            "category": "archive",
            "tags": [
                "categories",
                "tags",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types shown on category and tag archives.\n$wpide_term_archive_types = array( 'post', 'portfolio' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_term_archive_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! ( $query->is_category() || $query->is_tag() ) || $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $query->set( 'post_type', array_values( array_filter( $wpide_term_archive_types, 'post_type_exists' ) ) );\n} );",
            "requires": [],
            "notes": "The custom post type must be registered with the category or post_tag taxonomy."
        },
        {
            "id": "disable-tag-archives",
            "version": 1,
            "title": "Disable Tag Archives",
            "description": "Redirects tag archive pages to the homepage and removes tags from the XML sitemap, for sites that use tags only internally.",
            "category": "archive",
            "tags": [
                "tags",
                "archives",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_tag() ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n} );\n\nadd_filter( 'wp_sitemaps_taxonomies', function ( $taxonomies ) {\n    unset( $taxonomies['post_tag'] );\n\n    return $taxonomies;\n} );",
            "requires": [],
            "notes": "Your theme may still print tag links on posts; they will redirect to the homepage."
        },
        {
            "id": "disable-post-format-archives",
            "version": 1,
            "title": "Disable Post Format Archives",
            "description": "Redirects the automatic archives for post formats (such as /type/gallery/) to the homepage and removes them from the sitemap.",
            "category": "archive",
            "tags": [
                "post-formats",
                "archives",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_tax( 'post_format' ) ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n} );\n\nadd_filter( 'wp_sitemaps_taxonomies', function ( $taxonomies ) {\n    unset( $taxonomies['post_format'] );\n\n    return $taxonomies;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "different-post-count-on-first-blog-page",
            "version": 1,
            "title": "Different Number of Posts on the First Blog Page",
            "description": "Shows a different number of posts on the first page of the blog than on the following pages, for example 3 featured posts up front and 10 per page after that, with pagination still correct.",
            "category": "archive",
            "tags": [
                "blog",
                "pagination",
                "homepage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of posts on the first page of the blog.\n$wpide_first_page_posts = 3;\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_first_page_posts ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() ) {\n        return;\n    }\n\n    $per_page = (int) get_option( 'posts_per_page' );\n    $paged    = max( 1, (int) $query->get( 'paged' ) );\n\n    if ( 1 === $paged ) {\n        $query->set( 'posts_per_page', $wpide_first_page_posts );\n    } else {\n        $query->set( 'posts_per_page', $per_page );\n        $query->set( 'offset', $wpide_first_page_posts + ( $paged - 2 ) * $per_page );\n    }\n} );\n\nadd_filter( 'found_posts', function ( $found, $query ) use ( $wpide_first_page_posts ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_home() ) {\n        return $found;\n    }\n\n    $per_page = max( 1, (int) get_option( 'posts_per_page' ) );\n    $pages    = 1 + (int) ceil( max( 0, $found - $wpide_first_page_posts ) / $per_page );\n\n    if ( max( 1, (int) $query->get( 'paged' ) ) === 1 ) {\n        return $pages * $wpide_first_page_posts;\n    }\n\n    return $found - $wpide_first_page_posts + $per_page;\n}, 10, 2 );",
            "requires": [],
            "notes": "Later pages use the \"Blog pages show at most\" setting. Sticky posts are added on top of the first-page count by WordPress."
        },
        {
            "id": "ignore-sticky-posts-on-blog",
            "version": 1,
            "title": "Stop Sticky Posts Jumping to the Top",
            "description": "Keeps sticky posts in normal date order on the blog page, so you can still mark posts as sticky for other uses without changing the main feed.",
            "category": "archive",
            "tags": [
                "sticky",
                "blog",
                "order"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_home() ) {\n        $query->set( 'ignore_sticky_posts', true );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "sticky-posts-first-in-categories",
            "version": 1,
            "title": "Sticky Posts First in Category Archives",
            "description": "Puts sticky posts at the top of the category archives they belong to, the same way WordPress already does on the blog page.",
            "category": "archive",
            "tags": [
                "sticky",
                "categories",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'the_posts', function ( $posts, $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_category() || $query->is_paged() ) {\n        return $posts;\n    }\n\n    $sticky = array_map( 'absint', (array) get_option( 'sticky_posts', array() ) );\n\n    if ( ! $sticky ) {\n        return $posts;\n    }\n\n    $pinned = get_posts( array(\n        'post__in'            => $sticky,\n        'category__in'        => array( $query->get_queried_object_id() ),\n        'numberposts'         => count( $sticky ),\n        'ignore_sticky_posts' => true,\n        'no_found_rows'       => true,\n    ) );\n\n    if ( ! $pinned ) {\n        return $posts;\n    }\n\n    $pinned_ids = wp_list_pluck( $pinned, 'ID' );\n    $others     = array_filter( $posts, function ( $post ) use ( $pinned_ids ) {\n        return ! in_array( $post->ID, $pinned_ids, true );\n    } );\n\n    return array_merge( $pinned, array_values( $others ) );\n}, 10, 2 );",
            "requires": [],
            "notes": "Only the first page of each category archive is affected."
        },
        {
            "id": "page-number-in-archive-titles",
            "version": 1,
            "title": "Page Number in Archive Headings",
            "description": "Adds \"Page 2 of 7\" to archive headings on paginated pages, so visitors and search engines can tell the pages apart.",
            "category": "archive",
            "tags": [
                "pagination",
                "archives",
                "titles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_the_archive_title', function ( $title ) {\n    global $wp_query;\n\n    $paged = (int) get_query_var( 'paged' );\n\n    if ( $paged > 1 ) {\n        $title .= sprintf( ' <span class=\"wpide-archive-page\">&ndash; Page %1$d of %2$d</span>', $paged, max( $paged, (int) $wp_query->max_num_pages ) );\n    }\n\n    return $title;\n}, 30 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "posts-per-page-author-date-archives",
            "version": 1,
            "title": "Posts per Page on Author and Date Archives",
            "description": "Sets how many posts appear on author and date (year, month, day) archives, independent of the main blog setting.",
            "category": "archive",
            "tags": [
                "pagination",
                "authors",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Posts per page on author archives and date archives.\n$wpide_author_per_page = 20;\n$wpide_date_per_page   = 30;\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_author_per_page, $wpide_date_per_page ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    if ( $query->is_author() ) {\n        $query->set( 'posts_per_page', (int) $wpide_author_per_page );\n    } elseif ( $query->is_date() ) {\n        $query->set( 'posts_per_page', (int) $wpide_date_per_page );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "query-loop-card-style-scss",
            "version": 1,
            "title": "Card Style for Query Loop Posts (SCSS)",
            "description": "Turns each post in Query Loop blocks into a card with padding, rounded corners, a soft shadow and a lift on hover, for blog and archive templates in block themes.",
            "category": "archive",
            "tags": [
                "scss",
                "query-loop",
                "cards"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$card-padding: 1.25rem;\n$card-radius: 10px;\n$card-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);\n$card-shadow-hover: 0 8px 22px rgba(0, 0, 0, 0.12);\n\n.wp-block-post-template {\n    gap: 1.5rem;\n\n    > .wp-block-post {\n        padding: $card-padding;\n        border-radius: $card-radius;\n        background: var(--wp--preset--color--base, #fff);\n        box-shadow: $card-shadow;\n        transition: transform 0.2s ease, box-shadow 0.2s ease;\n\n        &:hover,\n        &:focus-within {\n            transform: translateY(-3px);\n            box-shadow: $card-shadow-hover;\n        }\n\n        .wp-block-post-featured-image {\n            margin: (-$card-padding) (-$card-padding) 1rem;\n\n            img {\n                border-radius: $card-radius $card-radius 0 0;\n            }\n        }\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wp-block-post-template > .wp-block-post {\n        transition: none;\n\n        &:hover,\n        &:focus-within {\n            transform: none;\n        }\n    }\n}",
            "requires": [],
            "notes": "Change the variables at the top to match your design. Works with list and grid layouts."
        },
        {
            "id": "newsletter-box-in-post-lists",
            "version": 1,
            "title": "Newsletter Box After the Third Post in Lists",
            "description": "Inserts a newsletter sign-up box after the third post on the blog page, archives and search results, where browsing readers will see it.",
            "category": "archive",
            "tags": [
                "newsletter",
                "archives",
                "cta"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "archive_after_post",
            "insert_number": 3,
            "code": "<div class=\"wpide-list-newsletter\" style=\"margin:2em 0;padding:1.5em;border-radius:8px;background:#f0f6fc;color:#1d2327;text-align:center;\">\n    <p style=\"margin:0 0 .4em;font-size:1.1em;\"><strong>Get new posts by email</strong></p>\n    <p style=\"margin:0 0 1em;\">One short email when something new is published. Unsubscribe any time.</p>\n    <a class=\"wp-element-button button\" href=\"/newsletter/\">Subscribe</a>\n</div>",
            "requires": [],
            "notes": "Change the post number in the snippet settings and the link to your sign-up page or form."
        },
        {
            "id": "pagination-buttons-scss",
            "version": 1,
            "title": "Button-Style Pagination (SCSS)",
            "description": "Turns the page numbers under the blog, archives and Query Loop blocks into evenly sized rounded buttons with a clear current-page state.",
            "category": "archive",
            "tags": [
                "scss",
                "pagination",
                "archives"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #2271b1;\n$size: 2.4em;\n\n.nav-links,\n.wp-block-query-pagination-numbers {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.4em;\n}\n\n.nav-links .page-numbers,\n.wp-block-query-pagination .page-numbers,\n.wp-block-query-pagination-previous,\n.wp-block-query-pagination-next {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    min-width: $size;\n    height: $size;\n    padding: 0 0.8em;\n    border: 1px solid rgba(0, 0, 0, 0.15);\n    border-radius: 6px;\n    text-decoration: none;\n\n    &:hover,\n    &:focus {\n        border-color: $accent;\n        color: $accent;\n    }\n\n    &.current {\n        border-color: $accent;\n        background: $accent;\n        color: #fff;\n    }\n\n    &.dots {\n        border-color: transparent;\n    }\n}",
            "requires": [],
            "notes": "Covers classic theme pagination (.nav-links) and the Query Pagination block."
        },
        {
            "id": "archive-featured-image-hover-scss",
            "version": 1,
            "title": "Featured Image Hover Zoom on Archives (SCSS)",
            "description": "Adds a gentle zoom to featured images on the blog page, archives and Query Loop blocks when visitors hover over a post, making lists feel more interactive.",
            "category": "archive",
            "tags": [
                "scss",
                "featured-image",
                "hover"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$zoom: 1.05;\n$speed: 0.4s;\n\n.wp-block-post-featured-image,\n.blog .post-thumbnail,\n.archive .post-thumbnail {\n    overflow: hidden;\n\n    img {\n        transition: transform $speed ease;\n    }\n}\n\n.wp-block-post:hover .wp-block-post-featured-image img,\n.blog article:hover .post-thumbnail img,\n.archive article:hover .post-thumbnail img {\n    transform: scale($zoom);\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wp-block-post-featured-image img,\n    .post-thumbnail img {\n        transition: none;\n        transform: none !important;\n    }\n}",
            "requires": [],
            "notes": "Motion is switched off for visitors who prefer reduced motion."
        },
        {
            "id": "allow-font-and-data-uploads-for-admins",
            "version": 1,
            "title": "Allow Font, eBook and Data File Uploads for Administrators",
            "description": "Lets administrators upload web fonts (WOFF, WOFF2, TTF, OTF), EPUB eBooks, JSON and GPX files to the Media Library, which WordPress blocks by default.",
            "category": "media",
            "tags": [
                "uploads",
                "mime-types",
                "fonts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extension => MIME type allowed for administrators.\n$wpide_extra_mimes = array(\n    'woff'  => 'font/woff',\n    'woff2' => 'font/woff2',\n    'ttf'   => 'font/ttf',\n    'otf'   => 'font/otf',\n    'epub'  => 'application/epub+zip',\n    'json'  => 'application/json',\n    'gpx'   => 'application/gpx+xml',\n);\n\nadd_filter( 'upload_mimes', function ( $mimes, $user = null ) use ( $wpide_extra_mimes ) {\n    $user_id = $user instanceof WP_User ? $user->ID : ( $user ? (int) $user : get_current_user_id() );\n\n    if ( user_can( $user_id, 'manage_options' ) ) {\n        $mimes = array_merge( $mimes, $wpide_extra_mimes );\n    }\n\n    return $mimes;\n}, 10, 2 );\n\n// File type detection often reports these formats differently; trust the extension for administrators.\nadd_filter( 'wp_check_filetype_and_ext', function ( $data, $file, $filename ) use ( $wpide_extra_mimes ) {\n    if ( ! empty( $data['ext'] ) || ! current_user_can( 'manage_options' ) ) {\n        return $data;\n    }\n\n    $ext = strtolower( pathinfo( (string) $filename, PATHINFO_EXTENSION ) );\n\n    if ( isset( $wpide_extra_mimes[ $ext ] ) ) {\n        return array( 'ext' => $ext, 'type' => $wpide_extra_mimes[ $ext ], 'proper_filename' => false );\n    }\n\n    return $data;\n}, 10, 3 );",
            "requires": [],
            "notes": "Other roles keep the default list. Add or remove extensions in $wpide_extra_mimes."
        },
        {
            "id": "clean-image-titles-on-upload",
            "version": 1,
            "title": "Tidy Image Titles on Upload",
            "description": "Turns file-name titles like \"IMG_2041-sunset_beach-final\" into readable ones like \"Sunset beach final\" when images are uploaded, so the Media Library is easier to search.",
            "category": "media",
            "tags": [
                "uploads",
                "titles",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_attachment', function ( $attachment_id ) {\n    if ( ! wp_attachment_is_image( $attachment_id ) ) {\n        return;\n    }\n\n    $title = (string) get_post_field( 'post_title', $attachment_id );\n\n    if ( ! preg_match( '/[_\\-]/', $title ) ) {\n        return;\n    }\n\n    $clean = preg_replace( '/\\b(img|dsc|dscn|dcim|pxl|image|photo|screenshot)[\\s_\\-]*\\d+\\b/i', '', $title );\n    $clean = trim( preg_replace( '/[\\s_\\-]+/', ' ', $clean ) );\n\n    if ( '' === $clean || $clean === $title ) {\n        return;\n    }\n\n    wp_update_post( array(\n        'ID'         => $attachment_id,\n        'post_title' => ucfirst( $clean ),\n    ) );\n} );",
            "requires": [],
            "notes": "Only titles that contain dashes or underscores are changed; titles read from the photo's own metadata stay as they are."
        },
        {
            "id": "image-upload-dimension-limits",
            "version": 1,
            "title": "Minimum and Maximum Image Dimensions for Uploads",
            "description": "Rejects image uploads that are smaller or larger than the pixel sizes you set, so blurry thumbnails and oversized camera originals never reach the Media Library.",
            "category": "media",
            "tags": [
                "uploads",
                "images",
                "dimensions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Limits in pixels (0 = no limit).\n$wpide_min_width  = 300;\n$wpide_min_height = 0;\n$wpide_max_width  = 8000;\n$wpide_max_height = 8000;\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) use ( $wpide_min_width, $wpide_min_height, $wpide_max_width, $wpide_max_height ) {\n    if ( ! empty( $file['error'] ) || empty( $file['type'] ) || 0 !== strpos( $file['type'], 'image/' ) || 'image/svg+xml' === $file['type'] ) {\n        return $file;\n    }\n\n    $size = wp_getimagesize( $file['tmp_name'] );\n\n    if ( ! $size ) {\n        return $file;\n    }\n\n    list( $width, $height ) = $size;\n\n    if ( ( $wpide_min_width && $width < $wpide_min_width ) || ( $wpide_min_height && $height < $wpide_min_height ) ) {\n        $file['error'] = sprintf( 'This image is %1$d x %2$d pixels. Images must be at least %3$d x %4$d pixels.', $width, $height, $wpide_min_width, $wpide_min_height );\n    } elseif ( ( $wpide_max_width && $width > $wpide_max_width ) || ( $wpide_max_height && $height > $wpide_max_height ) ) {\n        $file['error'] = sprintf( 'This image is %1$d x %2$d pixels. Images can be at most %3$d x %4$d pixels.', $width, $height, $wpide_max_width, $wpide_max_height );\n    }\n\n    return $file;\n} );",
            "requires": [],
            "notes": "Set any limit to 0 to switch it off. SVG files are not checked."
        },
        {
            "id": "change-big-image-threshold",
            "version": 1,
            "title": "Change the Big Image Size Limit",
            "description": "Sets the size above which WordPress scales down very large uploaded images (2560 pixels by default), for example to 1920 pixels to save disk space and bandwidth.",
            "category": "media",
            "tags": [
                "images",
                "uploads",
                "resize"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Longest side in pixels before an uploaded image is scaled down.\n$wpide_big_image_threshold = 1920;\n\nadd_filter( 'big_image_size_threshold', function () use ( $wpide_big_image_threshold ) {\n    return max( 600, (int) $wpide_big_image_threshold );\n} );",
            "requires": [],
            "notes": "The original file is kept on the server; WordPress uses the scaled copy everywhere. To turn scaling off completely use the Disable Big Image Scaling snippet instead."
        },
        {
            "id": "convert-uploads-to-webp",
            "version": 1,
            "title": "Save JPEG and PNG Uploads as WebP",
            "description": "Makes WordPress generate WebP versions instead of JPEG and PNG for every image size it creates on upload, which usually cuts image weight by a quarter or more.",
            "category": "media",
            "tags": [
                "webp",
                "images",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Output format for resized images: image/webp or image/avif.\n$wpide_output_format = 'image/webp';\n\nadd_filter( 'image_editor_output_format', function ( $formats ) use ( $wpide_output_format ) {\n    static $checking = false;\n\n    if ( $checking ) {\n        return $formats;\n    }\n\n    $checking  = true;\n    $supported = wp_image_editor_supports( array( 'mime_type' => $wpide_output_format ) );\n    $checking  = false;\n\n    if ( $supported ) {\n        $formats['image/jpeg'] = $wpide_output_format;\n        $formats['image/png']  = $wpide_output_format;\n    }\n\n    return $formats;\n} );",
            "requires": [],
            "notes": "Needs a server whose GD or Imagick supports WebP; otherwise nothing changes. The original upload stays in its first format. Set the format to image/avif for AVIF."
        },
        {
            "id": "uploaded-by-column-in-media",
            "version": 1,
            "title": "\"Uploaded By\" Column in the Media Library",
            "description": "Adds a column to the Media Library list view showing who uploaded each file, with a link to see everything that person uploaded.",
            "category": "media",
            "tags": [
                "media-library",
                "columns",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_upload_columns', function ( $columns ) {\n    $columns['wpide_uploader'] = 'Uploaded by';\n\n    return $columns;\n} );\n\nadd_action( 'manage_media_custom_column', function ( $column, $attachment_id ) {\n    if ( 'wpide_uploader' !== $column ) {\n        return;\n    }\n\n    $author = get_userdata( (int) get_post_field( 'post_author', $attachment_id ) );\n\n    if ( ! $author ) {\n        echo '&mdash;';\n        return;\n    }\n\n    echo '<a href=\"' . esc_url( add_query_arg( array( 'mode' => 'list', 'author' => $author->ID ), admin_url( 'upload.php' ) ) ) . '\">' . esc_html( $author->display_name ) . '</a>';\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "default-featured-image",
            "version": 1,
            "title": "Default Featured Image",
            "description": "Uses an image of your choice as the featured image for posts that do not have one, so archive grids, related posts and social shares never show a gap.",
            "category": "media",
            "tags": [
                "featured-image",
                "fallback",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Attachment ID of the fallback image.\n$wpide_default_thumbnail_id = 0;\n// Post types that use the fallback.\n$wpide_default_thumbnail_types = array( 'post' );\n\nadd_filter( 'post_thumbnail_id', function ( $thumbnail_id, $post ) use ( $wpide_default_thumbnail_id, $wpide_default_thumbnail_types ) {\n    if ( $thumbnail_id || ! $wpide_default_thumbnail_id || is_admin() || ! $post ) {\n        return $thumbnail_id;\n    }\n\n    return in_array( get_post_type( $post ), $wpide_default_thumbnail_types, true ) ? (int) $wpide_default_thumbnail_id : $thumbnail_id;\n}, 10, 2 );",
            "requires": [],
            "notes": "Upload the fallback image, copy its attachment ID from the Media Library and put it in $wpide_default_thumbnail_id."
        },
        {
            "id": "responsive-embeds-classic-themes",
            "version": 1,
            "title": "Responsive Video Embeds for Classic Themes",
            "description": "Makes YouTube, Vimeo and other embedded videos scale to the width of the content on phones and tablets, for older themes that leave them at a fixed size.",
            "category": "media",
            "tags": [
                "embeds",
                "video",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'embed_oembed_html', function ( $html ) {\n    if ( current_theme_supports( 'responsive-embeds' ) || wp_is_block_theme() || false === strpos( $html, '<iframe' ) || false !== strpos( $html, 'wpide-responsive-embed' ) ) {\n        return $html;\n    }\n\n    $width  = preg_match( '/\\swidth=[\"\\']?(\\d+)/i', $html, $w ) ? (int) $w[1] : 16;\n    $height = preg_match( '/\\sheight=[\"\\']?(\\d+)/i', $html, $h ) ? (int) $h[1] : 9;\n\n    if ( ! $width || ! $height ) {\n        $width  = 16;\n        $height = 9;\n    }\n\n    return '<div class=\"wpide-responsive-embed\" style=\"position:relative;width:100%;aspect-ratio:' . $width . '/' . $height . ';\">' . $html . '</div>';\n}, 20 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.wpide-responsive-embed iframe{position:absolute;inset:0;width:100%;height:100%;}</style>' . \"\\n\";\n} );",
            "requires": [],
            "notes": "Block themes and themes that already declare responsive embeds are skipped automatically."
        },
        {
            "id": "youtube-privacy-enhanced-mode",
            "version": 1,
            "title": "Privacy-Enhanced Mode for YouTube Embeds",
            "description": "Switches every embedded YouTube video to youtube-nocookie.com, YouTube's privacy-enhanced mode, which does not set tracking cookies until the visitor plays the video.",
            "category": "media",
            "tags": [
                "youtube",
                "privacy",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'embed_oembed_html', function ( $html ) {\n    $result = preg_replace( '#//(?:www\\.)?youtube\\.com/embed/#i', '//www.youtube-nocookie.com/embed/', $html );\n\n    return null === $result ? $html : $result;\n}, 20 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "youtube-related-from-same-channel",
            "version": 1,
            "title": "Show Only Your Own Related Videos on YouTube Embeds",
            "description": "Adds rel=0 to YouTube embeds so the suggestions shown when a video ends come from the same channel instead of from other creators.",
            "category": "media",
            "tags": [
                "youtube",
                "embeds",
                "video"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'embed_oembed_html', function ( $html ) {\n    $result = preg_replace_callback( '#src=\"([^\"]*youtube(?:-nocookie)?\\.com/embed/[^\"]*)\"#i', function ( $m ) {\n        return 'src=\"' . esc_url( add_query_arg( 'rel', '0', html_entity_decode( $m[1] ) ) ) . '\"';\n    }, $html );\n\n    return null === $result ? $html : $result;\n}, 15 );",
            "requires": [],
            "notes": "YouTube no longer allows hiding suggestions completely; rel=0 limits them to the video's own channel."
        },
        {
            "id": "default-embed-size",
            "version": 1,
            "title": "Change the Default Embed Size",
            "description": "Sets the default maximum width and height WordPress asks for when embedding videos and other media from a link.",
            "category": "media",
            "tags": [
                "embeds",
                "video",
                "size"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Maximum width and height in pixels.\n$wpide_embed_width  = 800;\n$wpide_embed_height = 450;\n\nadd_filter( 'embed_defaults', function () use ( $wpide_embed_width, $wpide_embed_height ) {\n    return array(\n        'width'  => $wpide_embed_width,\n        'height' => $wpide_embed_height,\n    );\n} );",
            "requires": [],
            "notes": "Embeds that are already cached keep their old size until the post is updated."
        },
        {
            "id": "custom-sizes-in-image-size-menu",
            "version": 1,
            "title": "Show Custom Image Sizes in the Editor Size Menu",
            "description": "Adds every image size registered by your theme and plugins to the size dropdown in the editor and media modal, instead of only Thumbnail, Medium, Large and Full.",
            "category": "media",
            "tags": [
                "image-sizes",
                "editor",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'image_size_names_choose', function ( $sizes ) {\n    foreach ( wp_get_registered_image_subsizes() as $name => $data ) {\n        if ( isset( $sizes[ $name ] ) || in_array( $name, array( 'medium_large', '1536x1536', '2048x2048' ), true ) ) {\n            continue;\n        }\n\n        $sizes[ $name ] = sprintf( '%s (%d x %d)', ucwords( str_replace( array( '-', '_' ), ' ', $name ) ), $data['width'], $data['height'] );\n    }\n\n    return $sizes;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "upload-folders-by-file-type",
            "version": 1,
            "title": "Sort Uploads Into Folders by File Type",
            "description": "Stores new uploads in images, videos, audio and documents folders inside your uploads directory, which keeps the file system tidy on large sites.",
            "category": "media",
            "tags": [
                "uploads",
                "folders",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_upload_type_folder = '';\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) use ( &$wpide_upload_type_folder ) {\n    $check = wp_check_filetype( isset( $file['name'] ) ? $file['name'] : '' );\n    $mime  = (string) $check['type'];\n\n    if ( 0 === strpos( $mime, 'image/' ) ) {\n        $wpide_upload_type_folder = 'images';\n    } elseif ( 0 === strpos( $mime, 'video/' ) ) {\n        $wpide_upload_type_folder = 'videos';\n    } elseif ( 0 === strpos( $mime, 'audio/' ) ) {\n        $wpide_upload_type_folder = 'audio';\n    } else {\n        $wpide_upload_type_folder = 'documents';\n    }\n\n    return $file;\n} );\n\nadd_filter( 'upload_dir', function ( $dirs ) use ( &$wpide_upload_type_folder ) {\n    if ( '' === $wpide_upload_type_folder || ! empty( $dirs['error'] ) ) {\n        return $dirs;\n    }\n\n    $subdir         = '/' . $wpide_upload_type_folder . $dirs['subdir'];\n    $dirs['path']   = $dirs['basedir'] . $subdir;\n    $dirs['url']    = $dirs['baseurl'] . $subdir;\n    $dirs['subdir'] = $subdir;\n\n    return $dirs;\n} );\n\nadd_filter( 'wp_handle_upload', function ( $upload ) use ( &$wpide_upload_type_folder ) {\n    $wpide_upload_type_folder = '';\n\n    return $upload;\n} );",
            "requires": [],
            "notes": "Only new uploads are affected; existing files stay where they are. The year and month folders are kept inside each type folder when that setting is on."
        },
        {
            "id": "media-library-infinite-scroll",
            "version": 1,
            "title": "Infinite Scrolling in the Media Library",
            "description": "Brings back infinite scrolling in the Media Library grid, so more files load as you scroll instead of clicking \"Load more\".",
            "category": "media",
            "tags": [
                "media-library",
                "admin",
                "scrolling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'media_library_infinite_scrolling', '__return_true' );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "photo-credit-field",
            "version": 1,
            "title": "Photo Credit Field for Images",
            "description": "Adds a \"Photo credit\" field to images in the Media Library and prints the credit under the image whenever it is used in an Image block.",
            "category": "media",
            "tags": [
                "images",
                "credits",
                "attribution"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'attachment_fields_to_edit', function ( $fields, $post ) {\n    if ( wp_attachment_is_image( $post ) ) {\n        $fields['wpide_photo_credit'] = array(\n            'label' => 'Photo credit',\n            'input' => 'text',\n            'value' => get_post_meta( $post->ID, '_wpide_photo_credit', true ),\n            'helps' => 'Shown below the image, for example \"Jane Doe / Unsplash\".',\n        );\n    }\n\n    return $fields;\n}, 10, 2 );\n\nadd_filter( 'attachment_fields_to_save', function ( $post, $attachment ) {\n    if ( isset( $attachment['wpide_photo_credit'] ) && current_user_can( 'edit_post', $post['ID'] ) ) {\n        $credit = sanitize_text_field( $attachment['wpide_photo_credit'] );\n\n        if ( '' === $credit ) {\n            delete_post_meta( $post['ID'], '_wpide_photo_credit' );\n        } else {\n            update_post_meta( $post['ID'], '_wpide_photo_credit', $credit );\n        }\n    }\n\n    return $post;\n}, 10, 2 );\n\nadd_filter( 'render_block_core/image', function ( $content, $block ) {\n    $id     = isset( $block['attrs']['id'] ) ? (int) $block['attrs']['id'] : 0;\n    $credit = $id ? get_post_meta( $id, '_wpide_photo_credit', true ) : '';\n\n    if ( '' === $credit ) {\n        return $content;\n    }\n\n    $html     = '<small class=\"wpide-photo-credit\" style=\"display:block;font-size:.75em;opacity:.7;\">Photo: ' . esc_html( $credit ) . '</small>';\n    $position = strrpos( $content, '</figure>' );\n\n    return false === $position ? $content . $html : substr_replace( $content, $html, $position, 0 );\n}, 10, 2 );",
            "requires": [],
            "notes": "The credit is added below Image blocks that point to the attachment; classic editor images are not changed."
        },
        {
            "id": "disable-right-click-on-images",
            "version": 1,
            "title": "Disable Right-Click and Dragging on Images",
            "description": "Stops visitors from right-clicking or dragging images to save them, a simple deterrent for photographers and artists.",
            "category": "media",
            "tags": [
                "images",
                "protection",
                "copyright"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    function isImage(target) {\n        return target && target.tagName === 'IMG';\n    }\n\n    document.addEventListener('contextmenu', function (event) {\n        if (isImage(event.target)) {\n            event.preventDefault();\n        }\n    });\n\n    document.addEventListener('dragstart', function (event) {\n        if (isImage(event.target)) {\n            event.preventDefault();\n        }\n    });\n})();",
            "requires": [],
            "notes": "This only discourages casual copying; images can still be saved through browser tools or screenshots."
        },
        {
            "id": "gallery-shortcode-defaults",
            "version": 1,
            "title": "Link Gallery Images to the Full File by Default",
            "description": "Makes [gallery] shortcodes link each thumbnail to the full-size image and use medium-size thumbnails, unless the shortcode sets its own options.",
            "category": "media",
            "tags": [
                "gallery",
                "images",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Defaults for galleries that do not set these options.\n$wpide_gallery_defaults = array(\n    'link'    => 'file',\n    'size'    => 'medium',\n    'columns' => 3,\n);\n\nadd_filter( 'shortcode_atts_gallery', function ( $out, $pairs, $atts ) use ( $wpide_gallery_defaults ) {\n    foreach ( $wpide_gallery_defaults as $key => $value ) {\n        if ( ! isset( $atts[ $key ] ) ) {\n            $out[ $key ] = $value;\n        }\n    }\n\n    return $out;\n}, 10, 3 );",
            "requires": [],
            "notes": "Applies to classic [gallery] shortcodes; Gallery blocks keep the settings chosen in the editor."
        },
        {
            "id": "image-lightbox-by-default",
            "version": 1,
            "title": "Turn On the Image Lightbox by Default",
            "description": "Switches on the built-in \"Enlarge on click\" lightbox for every Image block, so readers can view photos full screen without a lightbox plugin.",
            "category": "media",
            "tags": [
                "lightbox",
                "images",
                "blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'blocks' => array(\n                'core/image' => array(\n                    'lightbox' => array(\n                        'enabled'      => true,\n                        'allowEditing' => true,\n                    ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Images linked to a page or file keep their link instead of the lightbox. Editors can still turn it off per image."
        },
        {
            "id": "restrict-upload-types-for-non-admins",
            "version": 1,
            "title": "Allow Only Images and PDFs for Non-Administrators",
            "description": "Limits the file types authors, editors and other non-admin users can upload to common images and PDF documents.",
            "category": "media",
            "tags": [
                "uploads",
                "security",
                "mime-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Allowed upload types for non-administrators (WordPress mime keys).\n$wpide_allowed_upload_types = array( 'jpg|jpeg|jpe', 'png', 'gif', 'webp', 'avif', 'pdf' );\n\nadd_filter( 'upload_mimes', function ( $mimes, $user = null ) use ( $wpide_allowed_upload_types ) {\n    $user_id = $user instanceof WP_User ? $user->ID : ( $user ? (int) $user : get_current_user_id() );\n\n    if ( user_can( $user_id, 'manage_options' ) ) {\n        return $mimes;\n    }\n\n    return array_intersect_key( $mimes, array_flip( $wpide_allowed_upload_types ) );\n}, 20, 2 );",
            "requires": [],
            "notes": "Administrators keep the full list. Add extensions in the same format WordPress uses, such as 'mp4|m4v'."
        },
        {
            "id": "no-preload-audio-video",
            "version": 1,
            "title": "Do Not Preload Audio and Video Files",
            "description": "Sets audio and video players to preload nothing until the visitor presses play, saving bandwidth on pages with several media files.",
            "category": "media",
            "tags": [
                "audio",
                "video",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_no_preload = function ( $out ) {\n    $out['preload'] = 'none';\n\n    return $out;\n};\n\nadd_filter( 'shortcode_atts_audio', $wpide_no_preload );\nadd_filter( 'shortcode_atts_video', $wpide_no_preload );\n\n$wpide_no_preload_block = function ( $content ) {\n    $content = preg_replace( '/\\spreload=\"[^\"]*\"/i', '', $content );\n\n    return preg_replace( '/<(audio|video)\\b/i', '<$1 preload=\"none\"', $content );\n};\n\nadd_filter( 'render_block_core/audio', $wpide_no_preload_block );\nadd_filter( 'render_block_core/video', $wpide_no_preload_block );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "alt-text-column-in-media",
            "version": 1,
            "title": "Alt Text Column in the Media Library",
            "description": "Adds an Alt text column to the Media Library list view and flags images that have none, making accessibility clean-ups much quicker.",
            "category": "media",
            "tags": [
                "alt-text",
                "accessibility",
                "media-library"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_upload_columns', function ( $columns ) {\n    $columns['wpide_alt'] = 'Alt text';\n\n    return $columns;\n} );\n\nadd_action( 'manage_media_custom_column', function ( $column, $attachment_id ) {\n    if ( 'wpide_alt' !== $column || ! wp_attachment_is_image( $attachment_id ) ) {\n        return;\n    }\n\n    $alt = trim( (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) );\n\n    echo '' === $alt ? '<span style=\"color:#d63638;font-weight:600;\">Missing</span>' : esc_html( $alt );\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-pdf-thumbnails",
            "version": 1,
            "title": "Disable PDF Preview Images",
            "description": "Stops WordPress from generating preview images for every uploaded PDF, saving disk space and server time when you upload many documents.",
            "category": "media",
            "tags": [
                "pdf",
                "uploads",
                "thumbnails"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fallback_intermediate_image_sizes', '__return_empty_array' );",
            "requires": [],
            "notes": "PDFs uploaded while this is active show a generic document icon in the Media Library."
        },
        {
            "id": "name-uploads-after-post",
            "version": 1,
            "title": "Name Uploaded Files After Their Post",
            "description": "Renames files uploaded from inside the post editor to match the post's slug, so \"DSC_0042.jpg\" becomes \"summer-menu.jpg\", which is better for image search.",
            "category": "media",
            "tags": [
                "uploads",
                "file-names",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_handle_upload_prefilter', function ( $file ) {\n    $post_id = isset( $_REQUEST['post_id'] ) ? absint( $_REQUEST['post_id'] ) : 0;\n\n    if ( ! $post_id || ! empty( $file['error'] ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return $file;\n    }\n\n    $post = get_post( $post_id );\n    $slug = $post ? ( $post->post_name ? $post->post_name : sanitize_title( $post->post_title ) ) : '';\n\n    if ( '' === $slug || 0 === strpos( $slug, 'auto-draft' ) ) {\n        return $file;\n    }\n\n    $extension    = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) );\n    $file['name'] = $slug . ( $extension ? '.' . $extension : '' );\n\n    return $file;\n} );",
            "requires": [],
            "notes": "Only files uploaded while editing a post or page are renamed. WordPress adds -1, -2 and so on when a name is taken."
        },
        {
            "id": "block-duplicate-uploads",
            "version": 1,
            "title": "Block Duplicate Uploads",
            "description": "Stops the same file from being uploaded twice by comparing a fingerprint of each new upload with files already in the Media Library, and tells the user where the existing copy is.",
            "category": "media",
            "tags": [
                "uploads",
                "duplicates",
                "media-library"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_handle_upload_prefilter', function ( $file ) {\n    if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) || ! is_readable( $file['tmp_name'] ) ) {\n        return $file;\n    }\n\n    $existing = get_posts( array(\n        'post_type'   => 'attachment',\n        'post_status' => 'inherit',\n        'fields'      => 'ids',\n        'numberposts' => 1,\n        'meta_key'    => '_wpide_file_hash',\n        'meta_value'  => md5_file( $file['tmp_name'] ),\n    ) );\n\n    if ( $existing ) {\n        $file['error'] = sprintf( 'This file is already in the Media Library as \"%1$s\" (ID %2$d).', get_post_field( 'post_title', $existing[0] ), $existing[0] );\n    }\n\n    return $file;\n} );\n\nadd_action( 'add_attachment', function ( $attachment_id ) {\n    $path = get_attached_file( $attachment_id );\n\n    if ( $path && is_readable( $path ) ) {\n        update_post_meta( $attachment_id, '_wpide_file_hash', md5_file( $path ) );\n    }\n} );",
            "requires": [],
            "notes": "Only files uploaded after the snippet is activated have a fingerprint, so older duplicates are not detected."
        },
        {
            "id": "bulk-fill-missing-alt-text",
            "version": 1,
            "title": "Fill Missing Alt Text in Bulk (Run Once)",
            "description": "A one-off task that gives every image without alt text a description based on its title. Run it from the snippet editor whenever you want to catch up.",
            "category": "media",
            "tags": [
                "alt-text",
                "accessibility",
                "bulk"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "// Maximum number of images to update per run.\n$wpide_alt_batch = 500;\n\nif ( ! current_user_can( 'upload_files' ) && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n    return;\n}\n\n$wpide_images = get_posts( array(\n    'post_type'      => 'attachment',\n    'post_mime_type' => 'image',\n    'post_status'    => 'inherit',\n    'fields'         => 'ids',\n    'numberposts'    => $wpide_alt_batch,\n    'meta_query'     => array(\n        'relation' => 'OR',\n        array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),\n        array( 'key' => '_wp_attachment_image_alt', 'value' => '' ),\n    ),\n) );\n\n$wpide_updated = 0;\n\nforeach ( $wpide_images as $wpide_image_id ) {\n    $wpide_alt = preg_replace( '/\\b(img|dsc|dscn|pxl|image|photo|screenshot)[\\s_\\-]*\\d+\\b/i', '', (string) get_post_field( 'post_title', $wpide_image_id ) );\n    $wpide_alt = trim( preg_replace( '/[\\s_\\-]+/', ' ', $wpide_alt ) );\n\n    if ( '' === $wpide_alt ) {\n        continue;\n    }\n\n    update_post_meta( $wpide_image_id, '_wp_attachment_image_alt', sanitize_text_field( ucfirst( $wpide_alt ) ) );\n    $wpide_updated++;\n}\n\necho esc_html( sprintf( 'Alt text added to %1$d of %2$d images without it.', $wpide_updated, count( $wpide_images ) ) );",
            "requires": [],
            "notes": "Processes up to 500 images per run; run it again for more. Review important images afterwards, as titles are not always good descriptions."
        },
        {
            "id": "gallery-hover-zoom-scss",
            "version": 1,
            "title": "Gallery Hover Zoom (SCSS)",
            "description": "Adds rounded corners and a gentle zoom on hover to images in Gallery blocks and classic galleries, with motion turned off for visitors who prefer reduced motion.",
            "category": "media",
            "tags": [
                "scss",
                "gallery",
                "hover"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$radius: 6px;\n$zoom: 1.06;\n$speed: 0.35s;\n\n.wp-block-gallery .wp-block-image,\n.gallery .gallery-item {\n    overflow: hidden;\n    border-radius: $radius;\n\n    img {\n        transition: transform $speed ease, filter $speed ease;\n    }\n\n    &:hover img,\n    &:focus-within img {\n        transform: scale($zoom);\n        filter: brightness(1.05);\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wp-block-gallery .wp-block-image img,\n    .gallery .gallery-item img {\n        transition: none;\n        transform: none !important;\n    }\n}",
            "requires": [],
            "notes": "Adjust $radius and $zoom to taste."
        },
        {
            "id": "image-caption-overlay-scss",
            "version": 1,
            "title": "Image Captions as Overlays (SCSS)",
            "description": "Places Image block captions on top of the bottom of the photo with a soft dark gradient, a magazine-style look that saves vertical space.",
            "category": "media",
            "tags": [
                "scss",
                "captions",
                "images"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$overlay-start: rgba(0, 0, 0, 0);\n$overlay-end: rgba(0, 0, 0, 0.7);\n\n.entry-content,\n.wp-block-post-content {\n    figure.wp-block-image:has(figcaption) {\n        position: relative;\n\n        img {\n            display: block;\n        }\n\n        figcaption {\n            position: absolute;\n            right: 0;\n            bottom: 0;\n            left: 0;\n            margin: 0;\n            padding: 2.5em 1em 0.75em;\n            background: linear-gradient(to bottom, $overlay-start, $overlay-end);\n            color: #fff;\n            font-size: 0.85em;\n            text-align: left;\n\n            a {\n                color: inherit;\n            }\n        }\n    }\n}",
            "requires": [],
            "notes": "Applies to Image blocks with captions inside post content."
        },
        {
            "id": "nofollow-external-links",
            "version": 1,
            "title": "Add nofollow to External Links in Content",
            "description": "Adds rel=\"nofollow\" to links in posts and pages that point to other websites, while keeping any rel values already set. Links to your own domain are untouched.",
            "category": "seo",
            "tags": [
                "nofollow",
                "links",
                "external"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Domains whose links stay followed (your own domain is always followed).\n$wpide_follow_domains = array( 'wordpress.org' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_follow_domains ) {\n    if ( false === stripos( $content, '<a ' ) ) {\n        return $content;\n    }\n\n    $own = wp_parse_url( home_url(), PHP_URL_HOST );\n\n    $result = preg_replace_callback( '/<a\\s[^>]*>/i', function ( $m ) use ( $own, $wpide_follow_domains ) {\n        $tag = $m[0];\n\n        if ( ! preg_match( '/href=[\"\\'](https?:)?\\/\\/([^\\/\"\\':?#]+)/i', $tag, $href ) ) {\n            return $tag;\n        }\n\n        $host = strtolower( $href[2] );\n\n        foreach ( array_merge( array( $own ), $wpide_follow_domains ) as $domain ) {\n            $domain = strtolower( (string) $domain );\n\n            if ( $host === $domain || substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) {\n                return $tag;\n            }\n        }\n\n        if ( preg_match( '/\\srel=([\"\\'])([^\"\\']*)\\1/i', $tag, $rel ) ) {\n            if ( preg_match( '/\\bnofollow\\b/i', $rel[2] ) ) {\n                return $tag;\n            }\n\n            return str_replace( $rel[0], ' rel=\"' . esc_attr( trim( $rel[2] . ' nofollow' ) ) . '\"', $tag );\n        }\n\n        return substr( $tag, 0, -1 ) . ' rel=\"nofollow\">';\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 20 );",
            "requires": [],
            "notes": "Add domains you trust to $wpide_follow_domains to keep their links followed."
        },
        {
            "id": "sponsored-affiliate-links",
            "version": 1,
            "title": "Mark Affiliate Links as Sponsored",
            "description": "Adds rel=\"sponsored nofollow\" to links that go to your affiliate programs or cloaked /go/ links, as search engines ask for paid links.",
            "category": "seo",
            "tags": [
                "affiliate",
                "sponsored",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Affiliate domains (subdomains included).\n$wpide_affiliate_domains = array( 'amzn.to', 'amazon.com', 'shareasale.com' );\n// Paths on your own site used for cloaked affiliate links.\n$wpide_affiliate_paths = array( '/go/', '/recommends/' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_affiliate_domains, $wpide_affiliate_paths ) {\n    if ( false === stripos( $content, '<a ' ) ) {\n        return $content;\n    }\n\n    $own = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n\n    $result = preg_replace_callback( '/<a\\s[^>]*href=([\"\\'])([^\"\\']+)\\1[^>]*>/i', function ( $m ) use ( $own, $wpide_affiliate_domains, $wpide_affiliate_paths ) {\n        $url  = $m[2];\n        $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );\n        $path = (string) wp_parse_url( $url, PHP_URL_PATH );\n        $hit  = false;\n\n        foreach ( $wpide_affiliate_domains as $domain ) {\n            if ( $host && ( $host === $domain || substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) ) {\n                $hit = true;\n            }\n        }\n\n        if ( ! $hit && ( ! $host || $host === $own ) ) {\n            foreach ( $wpide_affiliate_paths as $prefix ) {\n                if ( 0 === strpos( $path, $prefix ) ) {\n                    $hit = true;\n                }\n            }\n        }\n\n        if ( ! $hit ) {\n            return $m[0];\n        }\n\n        $tag = preg_replace( '/\\srel=([\"\\'])[^\"\\']*\\1/i', '', $m[0] );\n\n        return substr( $tag, 0, -1 ) . ' rel=\"sponsored nofollow noopener\">';\n    }, $content );\n\n    return null === $result ? $content : $result;\n}, 21 );",
            "requires": [],
            "notes": "List affiliate domains and any local link prefixes, such as /go/ or /recommends/, at the top."
        },
        {
            "id": "breadcrumbs-shortcode",
            "version": 1,
            "title": "Breadcrumbs Shortcode With Schema Markup",
            "description": "Adds a [breadcrumbs] shortcode that prints a Home > Category > Post trail with BreadcrumbList markup, so search results can show your site structure.",
            "category": "seo",
            "tags": [
                "breadcrumbs",
                "schema",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label of the first link.\n$wpide_crumb_home = 'Home';\n// Separator between items.\n$wpide_crumb_separator = '/';\n\nadd_shortcode( 'breadcrumbs', function () use ( $wpide_crumb_home, $wpide_crumb_separator ) {\n    $items = array( array( $wpide_crumb_home, home_url( '/' ) ) );\n    $link  = function ( $term ) {\n        $url = get_term_link( $term );\n\n        return is_wp_error( $url ) ? '' : $url;\n    };\n\n    if ( is_front_page() ) {\n        return '';\n    }\n\n    if ( is_singular() ) {\n        $post = get_queried_object();\n\n        if ( 'post' === $post->post_type ) {\n            $categories = get_the_category( $post->ID );\n\n            if ( $categories ) {\n                foreach ( array_reverse( get_ancestors( $categories[0]->term_id, 'category', 'taxonomy' ) ) as $ancestor ) {\n                    $term    = get_term( $ancestor, 'category' );\n                    $items[] = array( $term->name, $link( $term ) );\n                }\n\n                $items[] = array( $categories[0]->name, $link( $categories[0] ) );\n            }\n        } elseif ( is_post_type_hierarchical( $post->post_type ) ) {\n            foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor ) {\n                $items[] = array( get_the_title( $ancestor ), get_permalink( $ancestor ) );\n            }\n        } elseif ( get_post_type_archive_link( $post->post_type ) ) {\n            $items[] = array( get_post_type_object( $post->post_type )->labels->name, get_post_type_archive_link( $post->post_type ) );\n        }\n\n        $items[] = array( get_the_title( $post ), '' );\n    } elseif ( is_category() || is_tag() || is_tax() ) {\n        $term = get_queried_object();\n\n        foreach ( array_reverse( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) ) as $ancestor ) {\n            $parent  = get_term( $ancestor, $term->taxonomy );\n            $items[] = array( $parent->name, $link( $parent ) );\n        }\n\n        $items[] = array( $term->name, '' );\n    } elseif ( is_search() ) {\n        $items[] = array( sprintf( 'Search results for \"%s\"', get_search_query( false ) ), '' );\n    } elseif ( is_404() ) {\n        $items[] = array( 'Page not found', '' );\n    } elseif ( is_home() ) {\n        $items[] = array( single_post_title( '', false ), '' );\n    } else {\n        $items[] = array( wp_strip_all_tags( get_the_archive_title() ), '' );\n    }\n\n    $html = '<nav class=\"wpide-breadcrumbs\" aria-label=\"Breadcrumb\"><ol itemscope itemtype=\"https://schema.org/BreadcrumbList\" style=\"list-style:none;margin:0 0 1em;padding:0;display:flex;flex-wrap:wrap;gap:.4em;\">';\n    $last = count( $items ) - 1;\n\n    foreach ( $items as $position => $item ) {\n        $name  = '<span itemprop=\"name\">' . esc_html( $item[0] ) . '</span>';\n        $inner = ( $item[1] && $position !== $last ) ? '<a itemprop=\"item\" href=\"' . esc_url( $item[1] ) . '\">' . $name . '</a>' : $name;\n        $sep   = $position !== $last ? ' <span aria-hidden=\"true\">' . esc_html( $wpide_crumb_separator ) . '</span>' : '';\n        $html .= '<li itemprop=\"itemListElement\" itemscope itemtype=\"https://schema.org/ListItem\"' . ( $position === $last ? ' aria-current=\"page\"' : '' ) . '>' . $inner . '<meta itemprop=\"position\" content=\"' . ( $position + 1 ) . '\" />' . $sep . '</li>';\n    }\n\n    return $html . '</ol></nav>';\n} );",
            "requires": [],
            "notes": "Place [breadcrumbs] in a Shortcode block in your templates or at the top of content. Change the separator and home label at the top."
        },
        {
            "id": "breadcrumb-schema-json-ld",
            "version": 1,
            "title": "Breadcrumb Structured Data for Posts and Pages",
            "description": "Adds BreadcrumbList JSON-LD to posts (Home > Category > Post) and pages (Home > Parent > Page) so Google can show a breadcrumb trail instead of a raw URL.",
            "category": "seo",
            "tags": [
                "schema",
                "breadcrumbs",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular() || is_front_page() || defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ) || defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) {\n        return;\n    }\n\n    $post  = get_queried_object();\n    $trail = array( array( get_bloginfo( 'name' ), home_url( '/' ) ) );\n\n    if ( 'post' === $post->post_type ) {\n        $categories = get_the_category( $post->ID );\n\n        if ( $categories ) {\n            $ids = array_reverse( get_ancestors( $categories[0]->term_id, 'category', 'taxonomy' ) );\n            $ids[] = $categories[0]->term_id;\n\n            foreach ( $ids as $term_id ) {\n                $url = get_term_link( (int) $term_id, 'category' );\n\n                if ( ! is_wp_error( $url ) ) {\n                    $trail[] = array( get_term( $term_id, 'category' )->name, $url );\n                }\n            }\n        }\n    } elseif ( is_post_type_hierarchical( $post->post_type ) ) {\n        foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor ) {\n            $trail[] = array( get_the_title( $ancestor ), get_permalink( $ancestor ) );\n        }\n    }\n\n    $trail[] = array( get_the_title( $post ), get_permalink( $post ) );\n    $list    = array();\n\n    foreach ( $trail as $index => $crumb ) {\n        $list[] = array(\n            '@type'    => 'ListItem',\n            'position' => $index + 1,\n            'name'     => wp_strip_all_tags( $crumb[0] ),\n            'item'     => $crumb[1],\n        );\n    }\n\n    $data = array(\n        '@context'        => 'https://schema.org',\n        '@type'           => 'BreadcrumbList',\n        'itemListElement' => $list,\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Skipped automatically when Yoast SEO, Rank Math, AIOSEO, SEOPress or The SEO Framework is active."
        },
        {
            "id": "faq-shortcode-with-schema",
            "version": 1,
            "title": "FAQ Shortcode With FAQ Schema",
            "description": "Adds a [faq] shortcode for collapsible questions and answers, and outputs matching FAQPage structured data for every question on the page.",
            "category": "seo",
            "tags": [
                "faq",
                "schema",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_faq_items = array();\n\nadd_shortcode( 'faq', function ( $atts, $content = '' ) use ( &$wpide_faq_items ) {\n    $atts     = shortcode_atts( array( 'question' => '' ), $atts, 'faq' );\n    $question = trim( $atts['question'] );\n\n    if ( '' === $question || '' === trim( (string) $content ) ) {\n        return '';\n    }\n\n    $answer = wpautop( do_shortcode( wp_kses_post( (string) $content ) ) );\n\n    if ( is_singular() ) {\n        $wpide_faq_items[ md5( $question ) ] = array( $question, $answer );\n    }\n\n    return '<details class=\"wpide-faq\" style=\"margin:0 0 .75em;padding:.75em 1em;border:1px solid rgba(0,0,0,.15);border-radius:4px;\"><summary style=\"cursor:pointer;font-weight:600;\">' . esc_html( $question ) . '</summary><div style=\"margin-top:.5em;\">' . $answer . '</div></details>';\n} );\n\nadd_action( 'wp_footer', function () use ( &$wpide_faq_items ) {\n    if ( ! $wpide_faq_items ) {\n        return;\n    }\n\n    $allowed = array( 'p' => array(), 'br' => array(), 'a' => array( 'href' => array() ), 'strong' => array(), 'em' => array(), 'ul' => array(), 'ol' => array(), 'li' => array() );\n    $data    = array(\n        '@context'   => 'https://schema.org',\n        '@type'      => 'FAQPage',\n        'mainEntity' => array(),\n    );\n\n    foreach ( $wpide_faq_items as $item ) {\n        $data['mainEntity'][] = array(\n            '@type'          => 'Question',\n            'name'           => wp_strip_all_tags( $item[0] ),\n            'acceptedAnswer' => array(\n                '@type' => 'Answer',\n                'text'  => trim( wp_kses( $item[1], $allowed ) ),\n            ),\n        );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Example: [faq question=\"Do you ship abroad?\"]Yes, to most countries.[/faq]. Google now shows FAQ rich results mainly for authoritative sites, but the markup still describes your content."
        },
        {
            "id": "canonical-urls-for-archives",
            "version": 1,
            "title": "Canonical URLs on Archive Pages",
            "description": "Adds canonical link tags to the blog page, category, tag, author and post type archives, which WordPress only does for single posts and pages by default.",
            "category": "seo",
            "tags": [
                "canonical",
                "archives",
                "duplicate-content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( is_singular() || is_404() || is_search() || defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ) || defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) {\n        return;\n    }\n\n    $url = '';\n\n    if ( is_front_page() ) {\n        $url = home_url( '/' );\n    } elseif ( is_home() ) {\n        $url = get_permalink( (int) get_option( 'page_for_posts' ) );\n    } elseif ( is_category() || is_tag() || is_tax() ) {\n        $url = get_term_link( get_queried_object() );\n    } elseif ( is_author() ) {\n        $url = get_author_posts_url( get_queried_object_id() );\n    } elseif ( is_post_type_archive() ) {\n        $type = get_query_var( 'post_type' );\n        $url  = get_post_type_archive_link( is_array( $type ) ? reset( $type ) : $type );\n    }\n\n    if ( ! $url || is_wp_error( $url ) ) {\n        return;\n    }\n\n    $paged = (int) get_query_var( 'paged' );\n\n    if ( $paged > 1 ) {\n        global $wp_rewrite;\n        $url = $wp_rewrite->using_permalinks() ? user_trailingslashit( trailingslashit( $url ) . $wp_rewrite->pagination_base . '/' . $paged, 'paged' ) : add_query_arg( 'paged', $paged, $url );\n    }\n\n    echo '<link rel=\"canonical\" href=\"' . esc_url( $url ) . '\" />' . \"\\n\";\n}, 1 );",
            "requires": [],
            "notes": "Each paginated page points to itself. Skipped when a major SEO plugin is active."
        },
        {
            "id": "exclude-posts-from-sitemap",
            "version": 1,
            "title": "Exclude Specific Posts and Pages From the Sitemap",
            "description": "Leaves the posts and pages you list out of the built-in XML sitemap, such as thank-you pages, landing pages for ads or legal pages.",
            "category": "seo",
            "tags": [
                "sitemap",
                "xml",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IDs of posts, pages or custom post type entries to leave out.\n$wpide_sitemap_excluded_ids = array( 0 );\n\nadd_filter( 'wp_sitemaps_posts_query_args', function ( $args ) use ( $wpide_sitemap_excluded_ids ) {\n    $args['post__not_in'] = array_merge( isset( $args['post__not_in'] ) ? (array) $args['post__not_in'] : array(), array_map( 'absint', $wpide_sitemap_excluded_ids ) );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Add the post or page IDs to the list. This does not noindex the pages; combine it with a noindex rule if they should not appear in search at all."
        },
        {
            "id": "sitemap-last-modified-dates",
            "version": 1,
            "title": "Last Modified Dates in the XML Sitemap",
            "description": "Adds the date each post was last updated to the built-in sitemap, which helps search engines find changed content faster.",
            "category": "seo",
            "tags": [
                "sitemap",
                "lastmod",
                "crawling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_sitemaps_posts_entry', function ( $entry, $post ) {\n    $entry['lastmod'] = get_post_modified_time( 'c', true, $post );\n\n    return $entry;\n}, 10, 2 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "sitemap-urls-per-page",
            "version": 1,
            "title": "Change the Number of URLs per Sitemap Page",
            "description": "Changes how many links each page of the built-in XML sitemap holds (2,000 by default), for example to split a huge sitemap into smaller files.",
            "category": "seo",
            "tags": [
                "sitemap",
                "xml",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of URLs per sitemap page.\n$wpide_sitemap_max_urls = 500;\n\nadd_filter( 'wp_sitemaps_max_urls', function () use ( $wpide_sitemap_max_urls ) {\n    return max( 1, min( 50000, (int) $wpide_sitemap_max_urls ) );\n} );",
            "requires": [],
            "notes": "Values between 100 and 50,000 are allowed by the sitemap protocol."
        },
        {
            "id": "change-title-separator",
            "version": 1,
            "title": "Change the Title Separator",
            "description": "Changes the character between the page name and the site name in browser tabs and search results, for example from a dash to a vertical bar.",
            "category": "seo",
            "tags": [
                "title",
                "separator",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Character placed between the page title and the site name.\n$wpide_title_separator = '|';\n\nadd_filter( 'document_title_separator', function () use ( $wpide_title_separator ) {\n    return $wpide_title_separator;\n} );",
            "requires": [],
            "notes": "Some SEO plugins set their own separator and ignore this one."
        },
        {
            "id": "custom-homepage-title",
            "version": 1,
            "title": "Custom Title for the Homepage",
            "description": "Sets the browser and search result title of the homepage to the exact text you choose instead of \"Site Name - Tagline\".",
            "category": "seo",
            "tags": [
                "title",
                "homepage",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Full title used on the homepage.\n$wpide_homepage_title = 'Acme Studio | Web Design and Branding in Lisbon';\n\nadd_filter( 'pre_get_document_title', function ( $title ) use ( $wpide_homepage_title ) {\n    if ( is_front_page() && '' !== trim( $wpide_homepage_title ) ) {\n        return $wpide_homepage_title;\n    }\n\n    return $title;\n} );",
            "requires": [],
            "notes": "Skip this if an SEO plugin already manages your homepage title."
        },
        {
            "id": "remove-site-name-from-post-titles",
            "version": 1,
            "title": "Remove the Site Name From Post Titles",
            "description": "Shows only the post or page name in the browser tab and search results on single posts and pages, leaving more room for a descriptive title.",
            "category": "seo",
            "tags": [
                "title",
                "posts",
                "search-results"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types without the site name in the title (empty array = all).\n$wpide_title_post_types = array();\n\nadd_filter( 'document_title_parts', function ( $parts ) use ( $wpide_title_post_types ) {\n    if ( ! is_singular() || is_front_page() ) {\n        return $parts;\n    }\n\n    if ( $wpide_title_post_types && ! is_singular( $wpide_title_post_types ) ) {\n        return $parts;\n    }\n\n    unset( $parts['site'], $parts['tagline'] );\n\n    return $parts;\n} );",
            "requires": [],
            "notes": "Set $wpide_title_post_types to limit it to certain post types."
        },
        {
            "id": "website-schema-with-search",
            "version": 1,
            "title": "WebSite Structured Data With Site Search",
            "description": "Adds WebSite JSON-LD to the homepage, including a SearchAction that tells search engines how your site search works.",
            "category": "seo",
            "tags": [
                "schema",
                "website",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_front_page() || defined( 'WPSEO_VERSION' ) || defined( 'RANK_MATH_VERSION' ) || defined( 'AIOSEO_VERSION' ) || defined( 'SEOPRESS_VERSION' ) || defined( 'THE_SEO_FRAMEWORK_VERSION' ) ) {\n        return;\n    }\n\n    $data = array(\n        '@context'        => 'https://schema.org',\n        '@type'           => 'WebSite',\n        'name'            => get_bloginfo( 'name' ),\n        'url'             => home_url( '/' ),\n        'potentialAction' => array(\n            '@type'       => 'SearchAction',\n            'target'      => array(\n                '@type'       => 'EntryPoint',\n                'urlTemplate' => home_url( '/?s={search_term_string}' ),\n            ),\n            'query-input' => 'required name=search_term_string',\n        ),\n    );\n\n    if ( get_bloginfo( 'description' ) ) {\n        $data['description'] = get_bloginfo( 'description' );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Skipped when a major SEO plugin is active, as they output the same data."
        },
        {
            "id": "local-business-schema",
            "version": 1,
            "title": "Local Business Structured Data",
            "description": "Adds LocalBusiness JSON-LD with your address, phone, opening hours and map location to the homepage, to support local search and map results.",
            "category": "seo",
            "tags": [
                "schema",
                "local-seo",
                "business"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your business details.\n$wpide_business = array(\n    'type'      => 'LocalBusiness',\n    'name'      => 'Acme Coffee Roasters',\n    'phone'     => '+1-555-010-0199',\n    'street'    => '123 Main Street',\n    'city'      => 'Springfield',\n    'region'    => 'IL',\n    'postcode'  => '62701',\n    'country'   => 'US',\n    'latitude'  => '39.7817',\n    'longitude' => '-89.6501',\n    'price'     => '$$',\n    // Opening hours: day codes Mo Tu We Th Fr Sa Su and 24h times.\n    'hours'     => array( 'Mo-Fr 08:00-18:00', 'Sa 09:00-14:00' ),\n);\n\nadd_action( 'wp_head', function () use ( $wpide_business ) {\n    if ( ! is_front_page() ) {\n        return;\n    }\n\n    $data = array(\n        '@context'     => 'https://schema.org',\n        '@type'        => $wpide_business['type'],\n        'name'         => $wpide_business['name'],\n        'url'          => home_url( '/' ),\n        'telephone'    => $wpide_business['phone'],\n        'priceRange'   => $wpide_business['price'],\n        'address'      => array(\n            '@type'           => 'PostalAddress',\n            'streetAddress'   => $wpide_business['street'],\n            'addressLocality' => $wpide_business['city'],\n            'addressRegion'   => $wpide_business['region'],\n            'postalCode'      => $wpide_business['postcode'],\n            'addressCountry'  => $wpide_business['country'],\n        ),\n        'openingHours' => $wpide_business['hours'],\n    );\n\n    if ( $wpide_business['latitude'] && $wpide_business['longitude'] ) {\n        $data['geo'] = array(\n            '@type'     => 'GeoCoordinates',\n            'latitude'  => $wpide_business['latitude'],\n            'longitude' => $wpide_business['longitude'],\n        );\n    }\n\n    if ( has_custom_logo() ) {\n        $logo = wp_get_attachment_image_url( (int) get_theme_mod( 'custom_logo' ), 'full' );\n\n        if ( $logo ) {\n            $data['image'] = $logo;\n        }\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Fill in your details at the top. Use a more specific type such as Restaurant, Dentist or Store when one fits."
        },
        {
            "id": "profile-page-schema-for-authors",
            "version": 1,
            "title": "Author Profile Structured Data",
            "description": "Adds ProfilePage JSON-LD to author archives, describing the author as a person with their name, bio, avatar and website.",
            "category": "seo",
            "tags": [
                "schema",
                "authors",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_author() ) {\n        return;\n    }\n\n    $author = get_queried_object();\n\n    if ( ! $author instanceof WP_User ) {\n        return;\n    }\n\n    $person = array(\n        '@type' => 'Person',\n        'name'  => $author->display_name,\n        'url'   => get_author_posts_url( $author->ID ),\n        'image' => get_avatar_url( $author->ID, array( 'size' => 256 ) ),\n    );\n\n    if ( $author->description ) {\n        $person['description'] = wp_strip_all_tags( $author->description );\n    }\n\n    if ( $author->user_url ) {\n        $person['sameAs'] = array( $author->user_url );\n    }\n\n    $data = array(\n        '@context'   => 'https://schema.org',\n        '@type'      => 'ProfilePage',\n        'mainEntity' => $person,\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Author archives must be enabled for this to have any effect."
        },
        {
            "id": "noindex-selected-pages",
            "version": 1,
            "title": "Noindex Selected Posts and Pages",
            "description": "Asks search engines not to index the posts and pages you list, such as thank-you, login or internal pages, while still following their links.",
            "category": "seo",
            "tags": [
                "noindex",
                "robots",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IDs of the posts and pages to keep out of search results.\n$wpide_noindex_ids = array( 0 );\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_noindex_ids ) {\n    if ( is_singular() && in_array( get_queried_object_id(), array_map( 'absint', $wpide_noindex_ids ), true ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Add the IDs to the list. Pair it with the sitemap exclusion snippet so the pages are not listed there either."
        },
        {
            "id": "block-ai-crawlers-robots-txt",
            "version": 1,
            "title": "Block AI Training Crawlers in robots.txt",
            "description": "Adds rules to your virtual robots.txt that ask AI training bots such as GPTBot, ClaudeBot, CCBot and Google-Extended not to crawl your site.",
            "category": "seo",
            "tags": [
                "robots-txt",
                "ai",
                "crawlers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// AI crawler user agents to disallow.\n$wpide_ai_bots = array( 'GPTBot', 'ChatGPT-User', 'ClaudeBot', 'anthropic-ai', 'CCBot', 'Google-Extended', 'Applebot-Extended', 'PerplexityBot', 'Bytespider', 'meta-externalagent' );\n\nadd_filter( 'robots_txt', function ( $output, $public ) use ( $wpide_ai_bots ) {\n    if ( ! $public ) {\n        return $output;\n    }\n\n    $rules = \"\\n# Block AI training crawlers\\n\";\n\n    foreach ( $wpide_ai_bots as $bot ) {\n        $rules .= 'User-agent: ' . $bot . \"\\nDisallow: /\\n\\n\";\n    }\n\n    return rtrim( $output ) . \"\\n\" . $rules;\n}, 20, 2 );",
            "requires": [],
            "notes": "robots.txt is a request, not an enforcement. A physical robots.txt file in the site root overrides the virtual one. Edit the list to allow bots you want."
        },
        {
            "id": "simple-seo-fields",
            "version": 1,
            "title": "SEO Title, Description and Noindex Fields",
            "description": "Adds a small SEO box to the editor of posts and pages with a custom title, a meta description and a \"hide from search engines\" option, for sites without an SEO plugin.",
            "category": "seo",
            "tags": [
                "meta-description",
                "title",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_seo_types = function () {\n    return array_values( array_diff( get_post_types( array( 'public' => true ) ), array( 'attachment' ) ) );\n};\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_seo_types ) {\n    add_meta_box( 'wpide-seo', 'SEO', function ( $post ) {\n        wp_nonce_field( 'wpide_seo_save', 'wpide_seo_nonce' );\n        $title       = get_post_meta( $post->ID, '_wpide_seo_title', true );\n        $description = get_post_meta( $post->ID, '_wpide_seo_description', true );\n        $noindex     = get_post_meta( $post->ID, '_wpide_seo_noindex', true );\n        echo '<p><label for=\"wpide-seo-title\"><strong>Title</strong></label><br /><input type=\"text\" class=\"widefat\" id=\"wpide-seo-title\" name=\"wpide_seo_title\" value=\"' . esc_attr( $title ) . '\" maxlength=\"120\" /></p>';\n        echo '<p><label for=\"wpide-seo-description\"><strong>Meta description</strong></label><br /><textarea class=\"widefat\" rows=\"3\" id=\"wpide-seo-description\" name=\"wpide_seo_description\" maxlength=\"320\">' . esc_textarea( $description ) . '</textarea><span class=\"description\">About 150 characters works best.</span></p>';\n        echo '<p><label><input type=\"checkbox\" name=\"wpide_seo_noindex\" value=\"1\"' . checked( $noindex, '1', false ) . ' /> Hide from search engines (noindex)</label></p>';\n    }, $wpide_seo_types(), 'normal', 'low' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_seo_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wpide_seo_nonce'] ) ), 'wpide_seo_save' ) ) {\n        return;\n    }\n\n    if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $fields = array(\n        '_wpide_seo_title'       => isset( $_POST['wpide_seo_title'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_seo_title'] ) ) : '',\n        '_wpide_seo_description' => isset( $_POST['wpide_seo_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['wpide_seo_description'] ) ) : '',\n        '_wpide_seo_noindex'     => empty( $_POST['wpide_seo_noindex'] ) ? '' : '1',\n    );\n\n    foreach ( $fields as $key => $value ) {\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, $key );\n        } else {\n            update_post_meta( $post_id, $key, $value );\n        }\n    }\n} );\n\nadd_filter( 'pre_get_document_title', function ( $title ) {\n    if ( is_singular() ) {\n        $custom = get_post_meta( get_queried_object_id(), '_wpide_seo_title', true );\n\n        if ( '' !== $custom ) {\n            return $custom;\n        }\n    }\n\n    return $title;\n}, 20 );\n\nadd_action( 'wp_head', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $description = get_post_meta( get_queried_object_id(), '_wpide_seo_description', true );\n\n    if ( '' !== $description ) {\n        echo '<meta name=\"description\" content=\"' . esc_attr( $description ) . '\" />' . \"\\n\";\n    }\n}, 1 );\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular() && get_post_meta( get_queried_object_id(), '_wpide_seo_noindex', true ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );\n\nadd_filter( 'wp_sitemaps_posts_query_args', function ( $args ) {\n    $args['meta_query'] = array(\n        array(\n            'key'     => '_wpide_seo_noindex',\n            'compare' => 'NOT EXISTS',\n        ),\n    );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Do not combine it with an SEO plugin or with the Automatic Meta Description snippet, or pages will get two descriptions. Noindexed entries are also left out of the XML sitemap."
        },
        {
            "id": "indexnow-ping-on-publish",
            "version": 1,
            "title": "Notify IndexNow When Content Is Published",
            "description": "Tells search engines that support IndexNow (Bing, Yandex, Seznam and others) about new and updated posts as soon as they are published, so they are crawled sooner.",
            "category": "seo",
            "tags": [
                "indexnow",
                "indexing",
                "bing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    $key = get_option( 'wpide_indexnow_key' );\n\n    if ( ! $key ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n\n    if ( '/' . $key . '.txt' === $path ) {\n        status_header( 200 );\n        header( 'Content-Type: text/plain; charset=utf-8' );\n        echo esc_html( $key );\n        exit;\n    }\n}, 0 );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    if ( 'publish' !== $new_status || 'production' !== wp_get_environment_type() || wp_is_post_revision( $post ) || ! is_post_publicly_viewable( $post ) ) {\n        return;\n    }\n\n    if ( get_transient( 'wpide_indexnow_' . $post->ID ) ) {\n        return;\n    }\n\n    set_transient( 'wpide_indexnow_' . $post->ID, 1, MINUTE_IN_SECONDS );\n\n    $key = get_option( 'wpide_indexnow_key' );\n\n    if ( ! $key ) {\n        $key = strtolower( wp_generate_password( 32, false, false ) );\n        update_option( 'wpide_indexnow_key', $key, false );\n    }\n\n    wp_remote_post( 'https://api.indexnow.org/indexnow', array(\n        'blocking' => false,\n        'timeout'  => 5,\n        'headers'  => array( 'Content-Type' => 'application/json; charset=utf-8' ),\n        'body'     => wp_json_encode( array(\n            'host'        => wp_parse_url( home_url(), PHP_URL_HOST ),\n            'key'         => $key,\n            'keyLocation' => home_url( '/' . $key . '.txt' ),\n            'urlList'     => array( get_permalink( $post ) ),\n        ) ),\n    ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Only runs on sites whose environment type is production. A key is generated on first use and served at /your-key.txt as the protocol requires."
        },
        {
            "id": "twitter-handle-meta-tags",
            "version": 1,
            "title": "X (Twitter) Handle Meta Tags",
            "description": "Adds twitter:site with your brand's X handle to every page, and twitter:creator with the author's handle on posts, so shared links credit the right accounts.",
            "category": "seo",
            "tags": [
                "twitter",
                "social",
                "meta-tags"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your site's X handle, with the @.\n$wpide_x_site_handle = '@yourbrand';\n\nadd_action( 'wp_head', function () use ( $wpide_x_site_handle ) {\n    if ( '' !== trim( $wpide_x_site_handle ) ) {\n        echo '<meta name=\"twitter:site\" content=\"' . esc_attr( $wpide_x_site_handle ) . '\" />' . \"\\n\";\n    }\n\n    if ( is_singular( 'post' ) ) {\n        $handle = trim( (string) get_the_author_meta( 'twitter', (int) get_queried_object()->post_author ) );\n\n        if ( '' !== $handle ) {\n            $handle = '@' . ltrim( preg_replace( '#^https?://(www\\.)?(twitter|x)\\.com/#i', '', $handle ), '@/' );\n            echo '<meta name=\"twitter:creator\" content=\"' . esc_attr( $handle ) . '\" />' . \"\\n\";\n        }\n    }\n}, 6 );",
            "requires": [],
            "notes": "The author handle is read from a user meta field named twitter; leave it empty and only the site handle is used."
        },
        {
            "id": "article-date-meta-tags",
            "version": 1,
            "title": "Article Published and Modified Date Meta Tags",
            "description": "Adds article:published_time and article:modified_time meta tags to blog posts, which social networks and search engines use to understand how fresh a post is.",
            "category": "seo",
            "tags": [
                "meta-tags",
                "dates",
                "open-graph"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'post' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    echo '<meta property=\"article:published_time\" content=\"' . esc_attr( get_post_time( 'c', true, $post ) ) . '\" />' . \"\\n\";\n    echo '<meta property=\"article:modified_time\" content=\"' . esc_attr( get_post_modified_time( 'c', true, $post ) ) . '\" />' . \"\\n\";\n\n    foreach ( get_the_category( $post->ID ) as $category ) {\n        echo '<meta property=\"article:section\" content=\"' . esc_attr( $category->name ) . '\" />' . \"\\n\";\n        break;\n    }\n}, 6 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "noindex-posts-in-categories",
            "version": 1,
            "title": "Noindex Posts in Selected Categories",
            "description": "Keeps posts from the categories you choose, such as internal news or short announcements, out of search engine results.",
            "category": "seo",
            "tags": [
                "noindex",
                "categories",
                "robots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slugs to keep out of search results.\n$wpide_noindex_categories = array( 'internal' );\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_noindex_categories ) {\n    if ( ( is_singular( 'post' ) && has_category( $wpide_noindex_categories, get_queried_object_id() ) ) || is_category( $wpide_noindex_categories ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Use category slugs. The category archive pages themselves are noindexed too."
        },
        {
            "id": "noindex-thin-term-archives",
            "version": 1,
            "title": "Noindex Category and Tag Pages With Few Posts",
            "description": "Adds noindex to category, tag and custom taxonomy archives that hold fewer posts than a minimum, so near-empty pages do not dilute your search presence.",
            "category": "seo",
            "tags": [
                "noindex",
                "taxonomy",
                "thin-content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Archives with fewer posts than this get noindex.\n$wpide_min_term_posts = 3;\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_min_term_posts ) {\n    if ( is_category() || is_tag() || is_tax() ) {\n        $term = get_queried_object();\n\n        if ( $term instanceof WP_Term && (int) $term->count < $wpide_min_term_posts ) {\n            $robots['noindex'] = true;\n            $robots['follow']  = true;\n        }\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Change the minimum number of posts at the top. The pages start being indexed again once they reach it."
        },
        {
            "id": "extra-urls-sitemap",
            "version": 1,
            "title": "Add Custom URLs to the XML Sitemap",
            "description": "Adds a sitemap of extra URLs, such as static landing pages or sections served outside WordPress, to the built-in sitemap index.",
            "category": "seo",
            "tags": [
                "sitemap",
                "xml",
                "custom-urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra URLs to include.\n$wpide_extra_sitemap_urls = array(\n    home_url( '/landing/spring-offer/' ),\n    home_url( '/docs/' ),\n);\n\nadd_action( 'init', function () use ( $wpide_extra_sitemap_urls ) {\n    if ( ! class_exists( 'WP_Sitemaps_Provider' ) || ! wp_sitemaps_get_server()->sitemaps_enabled() ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Extra_Urls_Sitemap' ) ) {\n        class WPIDE_Extra_Urls_Sitemap extends WP_Sitemaps_Provider {\n            protected $urls = array();\n\n            public function __construct( array $urls ) {\n                $this->name        = 'extra';\n                $this->object_type = 'extra';\n                $this->urls        = $urls;\n            }\n\n            public function get_url_list( $page_num, $object_subtype = '' ) {\n                return array_map( function ( $url ) {\n                    return array( 'loc' => esc_url_raw( $url ) );\n                }, $this->urls );\n            }\n\n            public function get_max_num_pages( $object_subtype = '' ) {\n                return $this->urls ? 1 : 0;\n            }\n        }\n    }\n\n    wp_register_sitemap_provider( 'extra', new WPIDE_Extra_Urls_Sitemap( $wpide_extra_sitemap_urls ) );\n}, 20 );",
            "requires": [],
            "notes": "List full URLs on your own domain. The new sitemap appears at /wp-sitemap-extra-1.xml and in /wp-sitemap.xml."
        },
        {
            "id": "html-sitemap-shortcode",
            "version": 1,
            "title": "HTML Sitemap Shortcode",
            "description": "Adds an [html_sitemap] shortcode that lists all your pages and your posts grouped by category, giving visitors and crawlers one page that links to everything.",
            "category": "seo",
            "tags": [
                "sitemap",
                "shortcode",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'html_sitemap', function ( $atts ) {\n    $atts = shortcode_atts( array( 'show_posts' => 'yes', 'posts_per_category' => 50 ), $atts, 'html_sitemap' );\n    $html = '<div class=\"wpide-html-sitemap\"><h2>Pages</h2><ul>' . wp_list_pages( array( 'title_li' => '', 'echo' => false ) ) . '</ul>';\n\n    if ( 'yes' === $atts['show_posts'] ) {\n        foreach ( get_categories( array( 'hide_empty' => true ) ) as $category ) {\n            $posts = get_posts( array(\n                'numberposts'         => absint( $atts['posts_per_category'] ),\n                'ignore_sticky_posts' => true,\n                'no_found_rows'       => true,\n                'category__in'        => array( $category->term_id ),\n            ) );\n\n            if ( ! $posts ) {\n                continue;\n            }\n\n            $html .= '<h2><a href=\"' . esc_url( get_category_link( $category ) ) . '\">' . esc_html( $category->name ) . '</a></h2><ul>';\n\n            foreach ( $posts as $item ) {\n                $html .= '<li><a href=\"' . esc_url( get_permalink( $item ) ) . '\">' . esc_html( get_the_title( $item ) ) . '</a></li>';\n            }\n\n            $html .= '</ul>';\n        }\n    }\n\n    return $html . '</div>';\n} );",
            "requires": [],
            "notes": "Use [html_sitemap posts_per_category=\"20\"] to limit long categories, or [html_sitemap show_posts=\"no\"] to list pages only."
        },
        {
            "id": "allow-full-search-snippets",
            "version": 1,
            "title": "Allow Full Text and Video Previews in Search Results",
            "description": "Adds max-snippet:-1 and max-video-preview:-1 to the robots meta tag, letting search engines show longer text snippets and video previews for your pages.",
            "category": "seo",
            "tags": [
                "robots",
                "snippets",
                "search-results"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( empty( $robots['noindex'] ) ) {\n        $robots['max-snippet']       = '-1';\n        $robots['max-video-preview'] = '-1';\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "WordPress already allows large image previews; this adds the matching text and video settings."
        },
        {
            "id": "breadcrumbs-style-scss",
            "version": 1,
            "title": "Breadcrumb Trail Styling (SCSS)",
            "description": "Styles breadcrumb trails from the Breadcrumbs shortcode and popular SEO plugins with smaller muted text, chevron separators and a clear current page.",
            "category": "seo",
            "tags": [
                "scss",
                "breadcrumbs",
                "navigation"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$muted: #646970;\n$separator: \"\\203A\";\n\n.wpide-breadcrumbs,\n.rank-math-breadcrumb,\n.yoast-breadcrumbs {\n    margin-bottom: 1.25em;\n    color: $muted;\n    font-size: 0.85em;\n\n    ol {\n        gap: 0.35em !important;\n    }\n\n    a {\n        color: inherit;\n        text-decoration: none;\n\n        &:hover,\n        &:focus {\n            text-decoration: underline;\n        }\n    }\n\n    [aria-current=\"page\"] {\n        color: currentColor;\n        font-weight: 600;\n    }\n}\n\n.wpide-breadcrumbs li > span[aria-hidden=\"true\"] {\n    font-size: 0;\n\n    &::before {\n        content: $separator;\n        font-size: 0.85rem;\n        opacity: 0.6;\n    }\n}",
            "requires": [],
            "notes": "Adjust $muted and the separator character to taste."
        },
        {
            "id": "featured-image-as-media-content",
            "version": 1,
            "title": "Featured Images as media:content in RSS",
            "description": "Adds each post's featured image to the RSS feed as a media:content element, the format email newsletter tools and feed readers use to show a thumbnail.",
            "category": "rss",
            "tags": [
                "rss",
                "images",
                "newsletter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Image size used in the feed.\n$wpide_feed_image_size = 'large';\n\nadd_action( 'rss2_ns', function () {\n    echo 'xmlns:media=\"http://search.yahoo.com/mrss/\"' . \"\\n\";\n} );\n\nadd_action( 'rss2_item', function () use ( $wpide_feed_image_size ) {\n    $post = get_post();\n\n    if ( ! $post || ! has_post_thumbnail( $post ) ) {\n        return;\n    }\n\n    $image = wp_get_attachment_image_src( get_post_thumbnail_id( $post ), $wpide_feed_image_size );\n\n    if ( ! $image ) {\n        return;\n    }\n\n    $mime = get_post_mime_type( get_post_thumbnail_id( $post ) );\n\n    printf(\n        \"<media:content url=\\\"%s\\\" medium=\\\"image\\\" width=\\\"%d\\\" height=\\\"%d\\\"%s />\\n\",\n        esc_url( $image[0] ),\n        (int) $image[1],\n        (int) $image[2],\n        $mime ? ' type=\"' . esc_attr( $mime ) . '\"' : ''\n    );\n} );",
            "requires": [],
            "notes": "Mailchimp, Feedly and most RSS-to-email services read this element. The image size is set at the top."
        },
        {
            "id": "exclude-tagged-posts-from-feeds",
            "version": 1,
            "title": "Keep Tagged Posts Out of RSS Feeds",
            "description": "Leaves any post with a \"no-feed\" tag out of all RSS feeds, so you can publish a post on the site without sending it to subscribers and newsletter tools.",
            "category": "rss",
            "tags": [
                "rss",
                "tags",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Tag slug that keeps a post out of feeds.\n$wpide_no_feed_tag = 'no-feed';\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_no_feed_tag ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_feed() || $query->is_comment_feed() ) {\n        return;\n    }\n\n    $tag = get_term_by( 'slug', $wpide_no_feed_tag, 'post_tag' );\n\n    if ( $tag ) {\n        $query->set( 'tag__not_in', array_merge( (array) $query->get( 'tag__not_in' ), array( (int) $tag->term_id ) ) );\n    }\n} );",
            "requires": [],
            "notes": "Create a tag with the slug no-feed (or change the slug at the top) and add it to posts you want to hide from feeds."
        },
        {
            "id": "json-feed",
            "version": 1,
            "title": "JSON Feed for Your Posts",
            "description": "Publishes your latest posts as a JSON Feed at /feed/json/, a modern alternative to RSS supported by many feed readers and easy to use from apps and scripts.",
            "category": "rss",
            "tags": [
                "rss",
                "json",
                "feeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_feed( 'json', function () {\n        $query = new WP_Query( array(\n            'posts_per_page'      => (int) get_option( 'posts_per_rss', 10 ),\n            'post_status'         => 'publish',\n            'ignore_sticky_posts' => true,\n            'has_password'        => false,\n            'no_found_rows'       => true,\n        ) );\n\n        $items = array();\n\n        while ( $query->have_posts() ) {\n            $query->the_post();\n            $post = get_post();\n\n            $item = array(\n                'id'             => get_permalink( $post ),\n                'url'            => get_permalink( $post ),\n                'title'          => html_entity_decode( get_the_title( $post ), ENT_QUOTES, 'UTF-8' ),\n                'content_html'   => apply_filters( 'the_content', get_the_content() ),\n                'summary'        => html_entity_decode( wp_strip_all_tags( get_the_excerpt() ), ENT_QUOTES, 'UTF-8' ),\n                'date_published' => get_post_time( 'c', true, $post ),\n                'date_modified'  => get_post_modified_time( 'c', true, $post ),\n                'authors'        => array(\n                    array(\n                        'name' => get_the_author_meta( 'display_name', $post->post_author ),\n                        'url'  => get_author_posts_url( $post->post_author ),\n                    ),\n                ),\n            );\n\n            $tags = get_the_tags( $post->ID );\n\n            if ( $tags && ! is_wp_error( $tags ) ) {\n                $item['tags'] = wp_list_pluck( $tags, 'name' );\n            }\n\n            if ( has_post_thumbnail( $post ) ) {\n                $item['image'] = get_the_post_thumbnail_url( $post, 'large' );\n            }\n\n            $items[] = $item;\n        }\n\n        wp_reset_postdata();\n\n        $feed = array(\n            'version'       => 'https://jsonfeed.org/version/1.1',\n            'title'         => get_bloginfo( 'name' ),\n            'home_page_url' => home_url( '/' ),\n            'feed_url'      => get_feed_link( 'json' ),\n            'description'   => get_bloginfo( 'description' ),\n            'language'      => get_bloginfo( 'language' ),\n            'items'         => $items,\n        );\n\n        if ( has_site_icon() ) {\n            $feed['icon'] = get_site_icon_url( 512 );\n        }\n\n        if ( ! headers_sent() ) {\n            header( 'Content-Type: application/feed+json; charset=' . get_option( 'blog_charset' ) );\n        }\n\n        echo wp_json_encode( $feed, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT );\n    } );\n} );\n\nadd_action( 'wp_head', function () {\n    echo '<link rel=\"alternate\" type=\"application/feed+json\" title=\"' . esc_attr( get_bloginfo( 'name' ) . ' JSON Feed' ) . '\" href=\"' . esc_url( get_feed_link( 'json' ) ) . '\" />' . \"\\n\";\n} );",
            "requires": [],
            "notes": "With pretty permalinks, visit Settings > Permalinks once after activating so the /feed/json/ address works; ?feed=json works right away."
        },
        {
            "id": "feed-update-frequency",
            "version": 1,
            "title": "Tell Feed Readers How Often to Check",
            "description": "Sets the update period and time-to-live in your RSS feed, so feed readers check a daily blog once a day instead of every hour.",
            "category": "rss",
            "tags": [
                "rss",
                "ttl",
                "caching"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How often you publish: hourly, daily, weekly, monthly or yearly.\n$wpide_feed_period = 'daily';\n// How many times per period.\n$wpide_feed_frequency = 1;\n// Minutes a reader may cache the feed.\n$wpide_feed_ttl = 1440;\n\nadd_filter( 'rss_update_period', function () use ( $wpide_feed_period ) {\n    return $wpide_feed_period;\n} );\n\nadd_filter( 'rss_update_frequency', function () use ( $wpide_feed_frequency ) {\n    return (string) absint( $wpide_feed_frequency );\n} );\n\nadd_action( 'rss2_head', function () use ( $wpide_feed_ttl ) {\n    echo '<ttl>' . absint( $wpide_feed_ttl ) . \"</ttl>\\n\";\n} );",
            "requires": [],
            "notes": "Readers treat these values as hints. Match them to how often you actually publish."
        },
        {
            "id": "rss-feed-shortcode",
            "version": 1,
            "title": "Display an External RSS Feed With a Shortcode",
            "description": "Adds an [rss_feed] shortcode that shows the latest items from any RSS or Atom feed, such as your podcast, YouTube channel or a partner blog, on a page.",
            "category": "rss",
            "tags": [
                "rss",
                "shortcode",
                "external"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'rss_feed', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'url'          => '',\n        'items'        => 5,\n        'show_date'    => 'yes',\n        'show_summary' => 'no',\n    ), $atts, 'rss_feed' );\n\n    $url = esc_url_raw( $atts['url'] );\n\n    if ( ! $url ) {\n        return '';\n    }\n\n    include_once ABSPATH . WPINC . '/feed.php';\n\n    $feed = fetch_feed( $url );\n\n    if ( is_wp_error( $feed ) ) {\n        return current_user_can( 'edit_posts' ) ? '<p class=\"wpide-rss-error\">Feed could not be loaded: ' . esc_html( $feed->get_error_message() ) . '</p>' : '';\n    }\n\n    $items = $feed->get_items( 0, min( 20, max( 1, absint( $atts['items'] ) ) ) );\n\n    if ( ! $items ) {\n        return '';\n    }\n\n    $html = '<ul class=\"wpide-rss-feed\">';\n\n    foreach ( $items as $item ) {\n        $html .= '<li><a href=\"' . esc_url( $item->get_permalink() ) . '\" rel=\"noopener\">' . esc_html( wp_strip_all_tags( (string) $item->get_title() ) ) . '</a>';\n\n        if ( 'yes' === $atts['show_date'] && $item->get_date( 'U' ) ) {\n            $html .= ' <time datetime=\"' . esc_attr( gmdate( 'c', (int) $item->get_date( 'U' ) ) ) . '\">' . esc_html( wp_date( get_option( 'date_format' ), (int) $item->get_date( 'U' ) ) ) . '</time>';\n        }\n\n        if ( 'yes' === $atts['show_summary'] ) {\n            $html .= '<p>' . esc_html( wp_trim_words( wp_strip_all_tags( (string) $item->get_description() ), 30 ) ) . '</p>';\n        }\n\n        $html .= '</li>';\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [],
            "notes": "Example: [rss_feed url=\"https://example.com/feed/\" items=\"5\" show_date=\"yes\" show_summary=\"no\"]. Feeds are cached for 12 hours by WordPress."
        },
        {
            "id": "feed-read-more-link",
            "version": 1,
            "title": "\"Read the Full Article\" Link in Feeds",
            "description": "Adds a link back to the post at the end of every feed item, so readers who see a summary in their reader or inbox can click through to your site.",
            "category": "rss",
            "tags": [
                "rss",
                "links",
                "traffic"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Link text.\n$wpide_feed_link_text = 'Read the full article on our website';\n\n$wpide_feed_read_more = function ( $content ) use ( $wpide_feed_link_text ) {\n    return $content . '<p><a href=\"' . esc_url( get_permalink() ) . '\">' . esc_html( $wpide_feed_link_text ) . ' &rarr;</a></p>';\n};\n\nadd_filter( 'the_excerpt_rss', $wpide_feed_read_more, 20 );\nadd_filter( 'the_content_feed', $wpide_feed_read_more, 20 );",
            "requires": [],
            "notes": "Change the link text at the top."
        },
        {
            "id": "utm-tags-on-feed-links",
            "version": 1,
            "title": "Campaign Tags on Feed Item Links",
            "description": "Adds utm_source, utm_medium and utm_campaign parameters to the links in your RSS feed, so visits from feed readers and RSS emails show up clearly in your analytics.",
            "category": "rss",
            "tags": [
                "rss",
                "utm",
                "analytics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Campaign parameters added to feed links.\n$wpide_feed_utm = array(\n    'utm_source'   => 'rss',\n    'utm_medium'   => 'feed',\n    'utm_campaign' => 'rss-feed',\n);\n\nadd_filter( 'the_permalink_rss', function ( $link ) use ( $wpide_feed_utm ) {\n    return add_query_arg( array_map( 'rawurlencode', $wpide_feed_utm ), $link );\n} );",
            "requires": [],
            "notes": "Change the parameter values to match your analytics naming."
        },
        {
            "id": "custom-feed-title",
            "version": 1,
            "title": "Custom Title for Your RSS Feeds",
            "description": "Sets the title feed readers and podcast apps show for your feeds, for example \"Acme Blog\" instead of the full site name and archive name.",
            "category": "rss",
            "tags": [
                "rss",
                "title",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Title shown in feed readers.\n$wpide_feed_title = 'Acme Blog';\n\nadd_filter( 'get_wp_title_rss', function ( $title ) use ( $wpide_feed_title ) {\n    return '' !== trim( $wpide_feed_title ) ? $wpide_feed_title : $title;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "feed-logo-image",
            "version": 1,
            "title": "Use Your Logo as the Feed Image",
            "description": "Shows your own logo in feed readers instead of the site icon, which is often too small or too simple for that space.",
            "category": "rss",
            "tags": [
                "rss",
                "logo",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Full URL of the logo for feeds (empty = custom logo from the theme).\n$wpide_feed_logo = '';\n\nadd_action( 'init', function () {\n    remove_action( 'rss2_head', 'rss2_site_icon' );\n} );\n\nadd_action( 'rss2_head', function () use ( $wpide_feed_logo ) {\n    $url = $wpide_feed_logo;\n\n    if ( '' === $url && has_custom_logo() ) {\n        $url = (string) wp_get_attachment_image_url( (int) get_theme_mod( 'custom_logo' ), 'medium' );\n    }\n\n    if ( '' === $url ) {\n        return;\n    }\n\n    echo \"<image>\\n\";\n    echo \"\\t<url>\" . esc_url( $url ) . \"</url>\\n\";\n    echo \"\\t<title>\" . esc_html( get_bloginfo_rss( 'name' ) ) . \"</title>\\n\";\n    echo \"\\t<link>\" . esc_url( home_url( '/' ) ) . \"</link>\\n\";\n    echo \"</image>\\n\";\n} );",
            "requires": [],
            "notes": "Put the full URL of a square image of at least 144 pixels in $wpide_feed_logo, or leave it empty to use the theme's custom logo."
        },
        {
            "id": "exclude-protected-posts-from-feeds",
            "version": 1,
            "title": "Keep Password-Protected Posts Out of Feeds",
            "description": "Removes password-protected posts from all feeds, where they otherwise appear with a \"There is no excerpt because this is a protected post\" message.",
            "category": "rss",
            "tags": [
                "rss",
                "password",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_feed() ) {\n        $query->set( 'has_password', false );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "strip-images-from-feed",
            "version": 1,
            "title": "Remove Images From Feed Content",
            "description": "Strips images and image captions from the content sent in your feeds, keeping feeds light for readers and services that only want the text.",
            "category": "rss",
            "tags": [
                "rss",
                "images",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_strip_feed_images = function ( $content ) {\n    $content = preg_replace( '/<figure[^>]*>\\s*(?:<a[^>]*>)?\\s*<img[^>]*>\\s*(?:<\\/a>)?\\s*(?:<figcaption.*?<\\/figcaption>)?\\s*<\\/figure>/is', '', $content );\n    $content = preg_replace( '/<img[^>]*>/i', '', (string) $content );\n\n    return (string) $content;\n};\n\nadd_filter( 'the_content_feed', $wpide_strip_feed_images, 30 );\nadd_filter( 'the_excerpt_rss', $wpide_strip_feed_images, 30 );",
            "requires": [],
            "notes": "The featured image added by other feed snippets is not affected."
        },
        {
            "id": "noindex-feeds",
            "version": 1,
            "title": "Keep Feeds Out of Search Results",
            "description": "Sends an X-Robots-Tag header that asks search engines not to index your RSS feeds, so feed URLs do not compete with your real pages.",
            "category": "rss",
            "tags": [
                "rss",
                "noindex",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_feed() && ! headers_sent() ) {\n        header( 'X-Robots-Tag: noindex, follow', true );\n    }\n}, 1 );",
            "requires": [],
            "notes": "Feed readers ignore this header, so subscribers are not affected."
        },
        {
            "id": "order-feeds-by-last-modified",
            "version": 1,
            "title": "Order Feeds by Last Updated Date",
            "description": "Lists feed items by the date they were last updated instead of the publish date, so readers see refreshed articles again.",
            "category": "rss",
            "tags": [
                "rss",
                "order",
                "modified-date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_feed() && ! $query->is_comment_feed() ) {\n        $query->set( 'orderby', 'modified' );\n        $query->set( 'order', 'DESC' );\n    }\n} );",
            "requires": [],
            "notes": "Some readers show an updated post as a new item. Use it if you regularly refresh older content."
        },
        {
            "id": "limit-feed-to-recent-days",
            "version": 1,
            "title": "Only Include Recent Posts in Feeds",
            "description": "Limits your feeds to posts published in the last few days, which keeps them short for busy news sites and stops readers re-importing old items.",
            "category": "rss",
            "tags": [
                "rss",
                "dates",
                "limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Only posts from the last this many days appear in feeds.\n$wpide_feed_days = 30;\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_feed_days ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_feed() || $query->is_comment_feed() ) {\n        return;\n    }\n\n    $query->set( 'date_query', array(\n        array(\n            'after'     => absint( $wpide_feed_days ) . ' days ago',\n            'inclusive' => true,\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Change the number of days at the top. A feed can be empty if nothing was published in that time."
        },
        {
            "id": "absolute-urls-in-feeds",
            "version": 1,
            "title": "Fix Relative Links and Images in Feeds",
            "description": "Turns relative links and image paths such as \"/about/\" in feed content into full URLs, so they work in feed readers and RSS emails.",
            "category": "rss",
            "tags": [
                "rss",
                "urls",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_absolute_feed_urls = function ( $content ) {\n    $base = untrailingslashit( home_url() );\n\n    $result = preg_replace_callback( '/\\s(href|src)=([\"\\'])(\\/(?!\\/)[^\"\\']*)\\2/i', function ( $m ) use ( $base ) {\n        return ' ' . $m[1] . '=' . $m[2] . esc_url( $base . $m[3] ) . $m[2];\n    }, $content );\n\n    return null === $result ? $content : $result;\n};\n\nadd_filter( 'the_content_feed', $wpide_absolute_feed_urls, 40 );\nadd_filter( 'the_excerpt_rss', $wpide_absolute_feed_urls, 40 );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "remove-extra-feed-links",
            "version": 1,
            "title": "Remove Category, Tag and Author Feed Links From the Head",
            "description": "Stops WordPress from advertising separate feeds for every category, tag, author, search and archive page, while keeping your main feed link.",
            "category": "rss",
            "tags": [
                "rss",
                "head",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "foreach ( array( 'category', 'tag', 'tax', 'author', 'search', 'post_type_archive' ) as $wpide_feed_type ) {\n    add_filter( 'feed_links_extra_show_' . $wpide_feed_type . '_feed', '__return_false' );\n}",
            "requires": [],
            "notes": "The feeds still work if someone visits them directly. Use the Remove Comment Feeds snippet for comment feed links."
        },
        {
            "id": "feed-only-content-shortcodes",
            "version": 1,
            "title": "Feed-Only and Hide-in-Feed Shortcodes",
            "description": "Adds [feed_only] and [hide_in_feed] shortcodes, so you can show a special message to RSS subscribers or keep interactive parts of a post out of feeds.",
            "category": "rss",
            "tags": [
                "rss",
                "shortcode",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'feed_only', function ( $atts, $content = '' ) {\n    return is_feed() ? do_shortcode( (string) $content ) : '';\n} );\n\nadd_shortcode( 'hide_in_feed', function ( $atts, $content = '' ) {\n    return is_feed() ? '' : do_shortcode( (string) $content );\n} );",
            "requires": [],
            "notes": "Example: [hide_in_feed]Interactive calculator here[/hide_in_feed][feed_only]Visit the post to use the calculator.[/feed_only]"
        },
        {
            "id": "rss-subscribe-box-after-posts",
            "version": 1,
            "title": "RSS Subscribe Box After Posts",
            "description": "Adds a small box at the end of each blog post inviting readers to follow the site by RSS, with a link to your feed.",
            "category": "rss",
            "tags": [
                "rss",
                "subscribe",
                "posts"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "after_content",
            "insert_number": 1,
            "code": "<?php if ( is_singular( 'post' ) ) : ?>\n<p class=\"wpide-rss-subscribe\" style=\"margin:2em 0 0;padding:1em 1.25em;border-radius:6px;background:rgba(0,0,0,.04);\">\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" aria-hidden=\"true\" style=\"vertical-align:-2px;margin-right:6px;\"><circle cx=\"3\" cy=\"13\" r=\"2\" fill=\"#f26522\"/><path d=\"M1 6a9 9 0 0 1 9 9h-2.5A6.5 6.5 0 0 0 1 8.5zM1 1a14 14 0 0 1 14 14h-2.5A11.5 11.5 0 0 0 1 3.5z\" fill=\"#f26522\"/></svg>\n    Never miss a post: <a href=\"<?php echo esc_url( get_feed_link() ); ?>\">subscribe with your RSS reader</a>.\n</p>\n<?php endif; ?>",
            "requires": [],
            "notes": "Edit the wording in the snippet; the feed URL is filled in automatically."
        },
        {
            "id": "external-feed-cache-lifetime",
            "version": 1,
            "title": "Refresh External RSS Feeds More Often",
            "description": "Changes how long WordPress caches feeds fetched from other sites for the RSS widget and feed shortcodes, which is 12 hours by default.",
            "category": "rss",
            "tags": [
                "rss",
                "cache",
                "widgets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Cache time for external feeds, in seconds.\n$wpide_feed_cache_seconds = HOUR_IN_SECONDS;\n\nadd_filter( 'wp_feed_cache_transient_lifetime', function () use ( $wpide_feed_cache_seconds ) {\n    return max( 600, (int) $wpide_feed_cache_seconds );\n} );",
            "requires": [],
            "notes": "Very short times add load to your server and to the source site; one hour is a sensible minimum."
        },
        {
            "id": "include-custom-post-types-in-search",
            "version": 1,
            "title": "Choose Which Post Types Appear in Search",
            "description": "Sets exactly which content types the site search returns, for example posts, pages and your portfolio, instead of every public type.",
            "category": "search",
            "tags": [
                "search",
                "post-types",
                "results"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types returned by the site search.\n$wpide_search_post_types = array( 'post', 'page', 'portfolio' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_search_post_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $types = array_values( array_filter( $wpide_search_post_types, 'post_type_exists' ) );\n\n    if ( $types ) {\n        $query->set( 'post_type', $types );\n    }\n} );",
            "requires": [],
            "notes": "List the post type names to include. Types that do not exist are ignored."
        },
        {
            "id": "search-custom-fields",
            "version": 1,
            "title": "Search Inside Custom Fields",
            "description": "Lets the site search also match text stored in custom fields, such as a subtitle, location or product code, not just titles and content.",
            "category": "search",
            "tags": [
                "search",
                "custom-fields",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Custom field names to include in search (empty = all fields not starting with an underscore).\n$wpide_search_meta_keys = array( 'subtitle', 'location' );\n\nadd_filter( 'posts_search', function ( $search, $query ) use ( $wpide_search_meta_keys ) {\n    global $wpdb;\n\n    if ( '' === $search || is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $search;\n    }\n\n    $key_sql = $wpide_search_meta_keys\n        ? \"AND pm.meta_key IN ('\" . implode( \"','\", array_map( 'esc_sql', $wpide_search_meta_keys ) ) . \"')\"\n        : \"AND pm.meta_key NOT LIKE '\\\\_%'\";\n\n    $pattern = '/\\(' . preg_quote( $wpdb->posts, '/' ) . '\\.post_content LIKE (\\'(?:[^\\'\\\\\\\\]|\\\\\\\\.)*\\')\\)/';\n\n    $result = preg_replace_callback( $pattern, function ( $m ) use ( $wpdb, $key_sql ) {\n        return $m[0] . \" OR ({$wpdb->posts}.ID IN (SELECT pm.post_id FROM {$wpdb->postmeta} pm WHERE pm.meta_value LIKE {$m[1]} {$key_sql}))\";\n    }, $search );\n\n    return null === $result ? $search : $result;\n}, 10, 2 );",
            "requires": [],
            "notes": "List the field names to search. Leave the list empty to search every visible custom field, which is slower on big sites."
        },
        {
            "id": "search-category-and-tag-names",
            "version": 1,
            "title": "Match Category and Tag Names in Search",
            "description": "Makes the site search also return posts whose categories or tags match the search words, so searching \"recipes\" finds everything filed under Recipes.",
            "category": "search",
            "tags": [
                "search",
                "categories",
                "tags"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomies whose term names are searched.\n$wpide_search_taxonomies = array( 'category', 'post_tag' );\n\nadd_filter( 'posts_search', function ( $search, $query ) use ( $wpide_search_taxonomies ) {\n    global $wpdb;\n\n    if ( '' === $search || is_admin() || ! $query->is_main_query() || ! $query->is_search() || ! $wpide_search_taxonomies ) {\n        return $search;\n    }\n\n    $taxonomies = \"'\" . implode( \"','\", array_map( 'esc_sql', $wpide_search_taxonomies ) ) . \"'\";\n    $pattern    = '/\\(' . preg_quote( $wpdb->posts, '/' ) . '\\.post_title LIKE (\\'(?:[^\\'\\\\\\\\]|\\\\\\\\.)*\\')\\)/';\n\n    $result = preg_replace_callback( $pattern, function ( $m ) use ( $wpdb, $taxonomies ) {\n        return $m[0] . \" OR ({$wpdb->posts}.ID IN (SELECT tr.object_id FROM {$wpdb->term_relationships} tr INNER JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id INNER JOIN {$wpdb->terms} t ON t.term_id = tt.term_id WHERE tt.taxonomy IN ({$taxonomies}) AND t.name LIKE {$m[1]}))\";\n    }, $search );\n\n    return null === $result ? $search : $result;\n}, 10, 2 );",
            "requires": [],
            "notes": "Add custom taxonomies to $wpide_search_taxonomies to include them too."
        },
        {
            "id": "exclude-category-from-search",
            "version": 1,
            "title": "Exclude a Category From Search Results",
            "description": "Keeps posts from the categories you choose, such as internal updates or sponsored posts, out of the site search results.",
            "category": "search",
            "tags": [
                "search",
                "categories",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category IDs to leave out of search results.\n$wpide_search_excluded_categories = array( 0 );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_search_excluded_categories ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $query->set( 'category__not_in', array_merge( (array) $query->get( 'category__not_in' ), array_map( 'absint', $wpide_search_excluded_categories ) ) );\n} );",
            "requires": [],
            "notes": "Use category IDs. Posts stay visible in their category archives."
        },
        {
            "id": "sort-search-results-by-date",
            "version": 1,
            "title": "Sort Search Results by Newest First",
            "description": "Lists search results by publish date, newest first, instead of WordPress's relevance order, which suits news sites and blogs where freshness matters most.",
            "category": "search",
            "tags": [
                "search",
                "order",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $query->set( 'orderby', 'date' );\n    $query->set( 'order', 'DESC' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "minimum-search-length",
            "version": 1,
            "title": "Minimum Search Term Length",
            "description": "Ignores searches shorter than a few characters, which return huge, useless result lists and put extra load on the database.",
            "category": "search",
            "tags": [
                "search",
                "performance",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum number of characters for a search.\n$wpide_min_search_length = 3;\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_min_search_length ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $term   = trim( (string) $query->get( 's' ) );\n    $length = function_exists( 'mb_strlen' ) ? mb_strlen( $term ) : strlen( $term );\n\n    if ( '' !== $term && $length < $wpide_min_search_length ) {\n        $query->set( 'post__in', array( 0 ) );\n    }\n} );",
            "requires": [],
            "notes": "Short searches show the normal \"nothing found\" page."
        },
        {
            "id": "block-spam-searches",
            "version": 1,
            "title": "Block Spam Searches",
            "description": "Answers search requests that contain links, markup, very long strings or words you block with a 404, stopping bots from filling search pages with spam.",
            "category": "search",
            "tags": [
                "search",
                "spam",
                "bots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Searches longer than this many characters are treated as spam.\n$wpide_search_max_length = 80;\n// Words that mark a search as spam.\n$wpide_blocked_search_words = array( 'casino', 'viagra', 'crypto loan' );\n\nadd_action( 'template_redirect', function () use ( $wpide_search_max_length, $wpide_blocked_search_words ) {\n    global $wp_query;\n\n    if ( ! is_search() ) {\n        return;\n    }\n\n    $term   = (string) get_search_query( false );\n    $length = function_exists( 'mb_strlen' ) ? mb_strlen( $term ) : strlen( $term );\n    $spam   = $length > $wpide_search_max_length || preg_match( '#https?://|www\\.|[<>{}\\[\\]]#i', $term );\n\n    foreach ( $wpide_blocked_search_words as $word ) {\n        if ( '' !== $word && false !== stripos( $term, $word ) ) {\n            $spam = true;\n        }\n    }\n\n    if ( $spam ) {\n        $wp_query->set_404();\n        status_header( 404 );\n        nocache_headers();\n    }\n}, 1 );",
            "requires": [],
            "notes": "Add words to $wpide_blocked_search_words. Legitimate visitors rarely search for URLs, so they are blocked too."
        },
        {
            "id": "noindex-search-results",
            "version": 1,
            "title": "Noindex Search Result Pages",
            "description": "Asks search engines not to index your internal search result pages, which are thin, endless and a favorite target for spam links.",
            "category": "search",
            "tags": [
                "search",
                "noindex",
                "robots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( is_search() ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "search-form-shortcode",
            "version": 1,
            "title": "Search Form Shortcode With Post Type Filter",
            "description": "Adds a [search_form] shortcode that places a search box anywhere and can limit results to certain post types, such as a docs-only or products-only search.",
            "category": "search",
            "tags": [
                "search",
                "shortcode",
                "form"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'search_form', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'post_type'   => '',\n        'placeholder' => 'Search...',\n        'button'      => 'Search',\n    ), $atts, 'search_form' );\n\n    $id    = wp_unique_id( 'wpide-search-' );\n    $types = array_filter( array_map( 'sanitize_key', explode( ',', $atts['post_type'] ) ), 'post_type_exists' );\n\n    $html  = '<form role=\"search\" method=\"get\" class=\"search-form wpide-search-form\" action=\"' . esc_url( home_url( '/' ) ) . '\">';\n    $html .= '<label class=\"screen-reader-text\" for=\"' . esc_attr( $id ) . '\">Search for:</label>';\n    $html .= '<input type=\"search\" id=\"' . esc_attr( $id ) . '\" class=\"search-field\" name=\"s\" value=\"' . esc_attr( get_search_query() ) . '\" placeholder=\"' . esc_attr( $atts['placeholder'] ) . '\" />';\n\n    foreach ( $types as $type ) {\n        $html .= '<input type=\"hidden\" name=\"post_type[]\" value=\"' . esc_attr( $type ) . '\" />';\n    }\n\n    return $html . ' <button type=\"submit\" class=\"search-submit wp-element-button\">' . esc_html( $atts['button'] ) . '</button></form>';\n} );",
            "requires": [],
            "notes": "Example: [search_form post_type=\"docs\" placeholder=\"Search the docs\" button=\"Go\"]. Pages cannot be targeted this way because WordPress does not allow them in query URLs."
        },
        {
            "id": "log-site-searches",
            "version": 1,
            "title": "Log Site Searches in a Dashboard Widget",
            "description": "Records what visitors search for, how often, and whether anything was found, and shows the top searches in a dashboard widget so you can spot missing content.",
            "category": "search",
            "tags": [
                "search",
                "statistics",
                "dashboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( ! is_search() || is_paged() || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $term = strtolower( trim( (string) get_search_query( false ) ) );\n\n    if ( '' === $term || strlen( $term ) > 100 ) {\n        return;\n    }\n\n    $log = get_option( 'wpide_search_log', array() );\n    $log = is_array( $log ) ? $log : array();\n\n    if ( ! isset( $log[ $term ] ) ) {\n        $log[ $term ] = array( 'count' => 0, 'results' => 0, 'last' => 0 );\n    }\n\n    $log[ $term ]['count']++;\n    $log[ $term ]['results'] = (int) $wp_query->found_posts;\n    $log[ $term ]['last']    = time();\n\n    if ( count( $log ) > 300 ) {\n        uasort( $log, function ( $a, $b ) {\n            return $b['last'] <=> $a['last'];\n        } );\n        $log = array_slice( $log, 0, 300, true );\n    }\n\n    update_option( 'wpide_search_log', $log, false );\n} );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_search_log', 'Top Site Searches', function () {\n        $log = get_option( 'wpide_search_log', array() );\n\n        if ( ! is_array( $log ) || ! $log ) {\n            echo '<p>No searches recorded yet.</p>';\n            return;\n        }\n\n        uasort( $log, function ( $a, $b ) {\n            return $b['count'] <=> $a['count'];\n        } );\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>Search</th><th>Times</th><th>Results</th></tr></thead><tbody>';\n\n        foreach ( array_slice( $log, 0, 15, true ) as $term => $row ) {\n            $results = (int) $row['results'];\n            echo '<tr><td>' . esc_html( $term ) . '</td><td>' . esc_html( number_format_i18n( (int) $row['count'] ) ) . '</td><td' . ( 0 === $results ? ' style=\"color:#d63638;font-weight:600;\"' : '' ) . '>' . esc_html( number_format_i18n( $results ) ) . '</td></tr>';\n        }\n\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Searches by logged-in editors are not counted. The log keeps the 300 most recent distinct terms in one option."
        },
        {
            "id": "search-results-count-heading",
            "version": 1,
            "title": "Show the Number of Search Results",
            "description": "Prints a line such as 12 results for \"pricing\" above the search results, so visitors know how much was found.",
            "category": "search",
            "tags": [
                "search",
                "results",
                "count"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'loop_start', function ( $query ) {\n    static $done = false;\n\n    if ( $done || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $done  = true;\n    $count = (int) $query->found_posts;\n    $label = 1 === $count ? '%1$s result for \"%2$s\"' : '%1$s results for \"%2$s\"';\n\n    echo '<p class=\"wpide-search-count\">' . esc_html( sprintf( $label, number_format_i18n( $count ), get_search_query( false ) ) ) . '</p>';\n} );",
            "requires": [],
            "notes": "The line appears where your theme starts the results loop."
        },
        {
            "id": "rate-limit-site-search",
            "version": 1,
            "title": "Rate-Limit Site Searches",
            "description": "Limits how many searches one visitor can run per minute and answers the rest with \"429 Too Many Requests\", protecting your database from search floods.",
            "category": "search",
            "tags": [
                "search",
                "security",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Maximum searches per visitor per minute.\n$wpide_search_rate_limit = 20;\n\nadd_action( 'template_redirect', function () use ( $wpide_search_rate_limit ) {\n    if ( ! is_search() || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    if ( '' === $ip ) {\n        return;\n    }\n\n    $key   = 'wpide_search_rl_' . substr( wp_hash( $ip ), 0, 20 );\n    $count = (int) get_transient( $key );\n\n    if ( $count >= $wpide_search_rate_limit ) {\n        header( 'Retry-After: 60' );\n        wp_die( 'Too many searches in a short time. Please wait a minute and try again.', 'Too many requests', array( 'response' => 429 ) );\n    }\n\n    set_transient( $key, $count + 1, MINUTE_IN_SECONDS );\n}, 1 );",
            "requires": [],
            "notes": "Visitors are identified by an anonymous hash of their IP address. Behind a proxy or CDN that hides visitor IPs, everyone may share one limit."
        },
        {
            "id": "search-whole-phrases",
            "version": 1,
            "title": "Search for the Whole Phrase",
            "description": "Makes multi-word searches match the exact phrase instead of any page that contains all the words somewhere, giving tighter results on large sites.",
            "category": "search",
            "tags": [
                "search",
                "phrase",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {\n        $query->set( 'sentence', true );\n    }\n} );",
            "requires": [],
            "notes": "Visitors get fewer results, so test it with common searches first."
        },
        {
            "id": "instant-search-suggestions",
            "version": 1,
            "title": "Instant Search Suggestions",
            "description": "Shows a dropdown of matching posts and pages under every search box as the visitor types, using the built-in REST API search.",
            "category": "search",
            "tags": [
                "search",
                "autocomplete",
                "rest-api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n    <style>\n        .wpide-live-results{position:absolute;z-index:999;left:0;right:0;top:100%;margin:4px 0 0;padding:4px 0;list-style:none;background:#fff;color:#1d2327;border:1px solid rgba(0,0,0,.15);border-radius:6px;box-shadow:0 6px 20px rgba(0,0,0,.12);}\n        .wpide-live-results a{display:block;padding:.45em .9em;color:inherit;text-decoration:none;}\n        .wpide-live-results a:hover,.wpide-live-results a:focus{background:#f0f6fc;}\n    </style>\n    <script>\n        (function () {\n            var endpoint = <?php echo wp_json_encode( esc_url_raw( rest_url( 'wp/v2/search' ) ) ); ?>;\n\n            function decode(text) {\n                var area = document.createElement('textarea');\n                area.innerHTML = text;\n                return area.value;\n            }\n\n            document.querySelectorAll('form input[name=\"s\"]').forEach(function (input) {\n                var form = input.form;\n                var box = document.createElement('ul');\n                var timer;\n                var controller;\n\n                box.className = 'wpide-live-results';\n                box.hidden = true;\n\n                if (getComputedStyle(form).position === 'static') {\n                    form.style.position = 'relative';\n                }\n\n                form.appendChild(box);\n                input.setAttribute('autocomplete', 'off');\n\n                input.addEventListener('input', function () {\n                    var term = input.value.trim();\n\n                    clearTimeout(timer);\n\n                    if (term.length < 3) {\n                        box.hidden = true;\n                        return;\n                    }\n\n                    timer = setTimeout(function () {\n                        if (controller) {\n                            controller.abort();\n                        }\n\n                        controller = new AbortController();\n\n                        fetch(endpoint + (endpoint.indexOf('?') === -1 ? '?' : '&') + 'per_page=6&search=' + encodeURIComponent(term), { signal: controller.signal })\n                            .then(function (response) {\n                                return response.json();\n                            })\n                            .then(function (items) {\n                                box.innerHTML = '';\n\n                                if (!Array.isArray(items) || !items.length) {\n                                    box.hidden = true;\n                                    return;\n                                }\n\n                                items.forEach(function (item) {\n                                    var li = document.createElement('li');\n                                    var link = document.createElement('a');\n                                    link.href = item.url;\n                                    link.textContent = decode(item.title);\n                                    li.appendChild(link);\n                                    box.appendChild(li);\n                                });\n\n                                box.hidden = false;\n                            })\n                            .catch(function () {});\n                    }, 250);\n                });\n\n                input.addEventListener('keydown', function (event) {\n                    if (event.key === 'Escape') {\n                        box.hidden = true;\n                    }\n                });\n\n                document.addEventListener('click', function (event) {\n                    if (!form.contains(event.target)) {\n                        box.hidden = true;\n                    }\n                });\n            });\n        })();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Suggestions start after three characters. Style the dropdown with the .wpide-live-results class."
        },
        {
            "id": "slash-key-focuses-search",
            "version": 1,
            "title": "Press \"/\" to Jump to the Search Box",
            "description": "Lets visitors press the slash key on any page to put the cursor in the search box, a shortcut many people know from popular websites.",
            "category": "search",
            "tags": [
                "search",
                "keyboard",
                "accessibility"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "document.addEventListener('keydown', function (event) {\n    if (event.key !== '/' || event.ctrlKey || event.metaKey || event.altKey) {\n        return;\n    }\n\n    var active = document.activeElement;\n\n    if (active && (active.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(active.tagName))) {\n        return;\n    }\n\n    var field = document.querySelector('input[type=\"search\"], input[name=\"s\"]');\n\n    if (field) {\n        event.preventDefault();\n        field.focus();\n        field.select();\n    }\n});",
            "requires": [],
            "notes": "The shortcut is ignored while the visitor is typing in a form field."
        },
        {
            "id": "search-excerpt-around-match",
            "version": 1,
            "title": "Show the Matching Text in Search Results",
            "description": "Replaces the usual opening-lines excerpt in search results with the passage of the post that actually contains the search words.",
            "category": "search",
            "tags": [
                "search",
                "excerpts",
                "results"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_the_excerpt', function ( $excerpt, $post = null ) {\n    if ( is_admin() || ! is_search() || ! in_the_loop() || ! $post || ! function_exists( 'mb_stripos' ) ) {\n        return $excerpt;\n    }\n\n    $terms = array_filter( preg_split( '/\\s+/', trim( (string) get_search_query( false ) ) ), function ( $term ) {\n        return mb_strlen( $term ) > 2;\n    } );\n\n    if ( ! $terms ) {\n        return $excerpt;\n    }\n\n    $text = trim( preg_replace( '/\\s+/u', ' ', wp_strip_all_tags( strip_shortcodes( excerpt_remove_blocks( $post->post_content ) ) ) ) );\n\n    foreach ( $terms as $term ) {\n        $position = mb_stripos( $text, $term );\n\n        if ( false === $position ) {\n            continue;\n        }\n\n        $start   = max( 0, $position - 100 );\n        $passage = mb_substr( $text, $start, 260 );\n\n        if ( $start > 0 ) {\n            $passage = preg_replace( '/^\\S*\\s/u', '', $passage );\n        }\n\n        if ( $start + 260 < mb_strlen( $text ) ) {\n            $passage = preg_replace( '/\\s\\S*$/u', '', $passage );\n        }\n\n        return ( $start > 0 ? '&hellip; ' : '' ) . esc_html( $passage ) . ( $start + 260 < mb_strlen( $text ) ? ' &hellip;' : '' );\n    }\n\n    return $excerpt;\n}, 20, 2 );",
            "requires": [],
            "notes": "Combine it with the Highlight Search Terms snippet to mark the words inside the passage."
        },
        {
            "id": "woo-search-products-by-sku",
            "version": 1,
            "title": "Search WooCommerce Products by SKU",
            "description": "Lets shoppers find products by typing their SKU into the store search, including SKUs of individual variations.",
            "category": "search",
            "tags": [
                "woocommerce",
                "search",
                "sku"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_search', function ( $search, $query ) {\n    global $wpdb;\n\n    if ( '' === $search || is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $search;\n    }\n\n    $types = array_filter( (array) $query->get( 'post_type' ) );\n\n    if ( $types && ! array_intersect( $types, array( 'product', 'any' ) ) ) {\n        return $search;\n    }\n\n    $lookup  = $wpdb->prefix . 'wc_product_meta_lookup';\n    $pattern = '/\\(' . preg_quote( $wpdb->posts, '/' ) . '\\.post_title LIKE (\\'(?:[^\\'\\\\\\\\]|\\\\\\\\.)*\\')\\)/';\n\n    $result = preg_replace_callback( $pattern, function ( $m ) use ( $wpdb, $lookup ) {\n        return $m[0] . \" OR ({$wpdb->posts}.ID IN (SELECT IF(sku_posts.post_parent > 0, sku_posts.post_parent, sku_posts.ID) FROM {$lookup} sku_lookup INNER JOIN {$wpdb->posts} sku_posts ON sku_posts.ID = sku_lookup.product_id WHERE sku_lookup.sku LIKE {$m[1]}))\";\n    }, $search );\n\n    return null === $result ? $search : $result;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's product lookup table, so it stays fast on large catalogs."
        },
        {
            "id": "product-search-above-shop",
            "version": 1,
            "title": "Product Search Box Above the Shop",
            "description": "Places the WooCommerce product search box above the product grid on the shop and product category pages.",
            "category": "search",
            "tags": [
                "woocommerce",
                "search",
                "shop"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "wc_before_products_list",
            "insert_number": 1,
            "code": "<div class=\"wpide-shop-search\" style=\"margin:0 0 1.5em;max-width:480px;\">\n    <?php if ( function_exists( 'get_product_search_form' ) ) { get_product_search_form(); } ?>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Style it with the .wpide-shop-search class."
        },
        {
            "id": "search-post-titles-only",
            "version": 1,
            "title": "Search Post Titles Only",
            "description": "Restricts the site search to titles, so results contain only posts whose title matches, which is useful for glossaries, directories and recipe sites.",
            "category": "search",
            "tags": [
                "search",
                "titles",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {\n        $query->set( 'search_columns', array( 'post_title' ) );\n    }\n} );",
            "requires": [],
            "notes": "Requires WordPress 6.2 or newer."
        },
        {
            "id": "search-box-in-menu",
            "version": 1,
            "title": "Search Box in the Main Menu",
            "description": "Adds the search form as the last item of a classic theme's main navigation menu.",
            "category": "search",
            "tags": [
                "search",
                "menu",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Theme menu location that gets the search box.\n$wpide_search_menu_location = 'primary';\n\nadd_filter( 'wp_nav_menu_items', function ( $items, $args ) use ( $wpide_search_menu_location ) {\n    if ( empty( $args->theme_location ) || $wpide_search_menu_location !== $args->theme_location ) {\n        return $items;\n    }\n\n    return $items . '<li class=\"menu-item wpide-menu-search\">' . get_search_form( array( 'echo' => false ) ) . '</li>';\n}, 10, 2 );",
            "requires": [],
            "notes": "Set the theme menu location; it is often primary or main. Block themes should add a Search block to the Navigation block instead."
        },
        {
            "id": "pill-search-form-scss",
            "version": 1,
            "title": "Rounded Pill Search Form (SCSS)",
            "description": "Restyles the theme search form and the Search block as a single rounded pill with an inline button and a clear focus ring.",
            "category": "search",
            "tags": [
                "scss",
                "search",
                "form"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #2271b1;\n$radius: 999px;\n\n.search-form,\n.wp-block-search__inside-wrapper {\n    display: flex;\n    align-items: center;\n    overflow: hidden;\n    border: 1px solid rgba(0, 0, 0, 0.2);\n    border-radius: $radius;\n    background: #fff;\n\n    &:focus-within {\n        border-color: $accent;\n        box-shadow: 0 0 0 3px rgba($accent, 0.2);\n    }\n\n    label {\n        flex: 1;\n        margin: 0;\n    }\n\n    input[type=\"search\"],\n    .wp-block-search__input {\n        flex: 1;\n        width: 100%;\n        min-width: 0;\n        padding: 0.6em 1.1em;\n        border: 0;\n        background: transparent;\n        outline: none;\n    }\n\n    button,\n    input[type=\"submit\"],\n    .wp-block-search__button {\n        margin: 3px;\n        padding: 0.55em 1.2em;\n        border: 0;\n        border-radius: $radius;\n        background: $accent;\n        color: #fff;\n        cursor: pointer;\n\n        &:hover,\n        &:focus {\n            background: darken($accent, 8%);\n        }\n    }\n}",
            "requires": [],
            "notes": "Change $accent to your brand color."
        },
        {
            "id": "search-highlight-style-scss",
            "version": 1,
            "title": "Search Term Highlight Style (SCSS)",
            "description": "Gives highlighted search words a soft marker-pen look instead of the browser's default yellow box, matching the Highlight Search Terms snippet and any mark elements.",
            "category": "search",
            "tags": [
                "scss",
                "search",
                "highlight"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$marker: #ffe58a;\n$marker-dark: #8a6d00;\n\nmark.search-highlight,\n.search-results mark {\n    padding: 0 0.15em;\n    border-radius: 0.2em;\n    background: linear-gradient(to bottom, transparent 15%, $marker 15%, $marker 85%, transparent 85%);\n    color: inherit;\n    box-decoration-break: clone;\n    -webkit-box-decoration-break: clone;\n}\n\n@media (prefers-color-scheme: dark) {\n    mark.search-highlight,\n    .search-results mark {\n        background: linear-gradient(to bottom, transparent 15%, $marker-dark 15%, $marker-dark 85%, transparent 85%);\n    }\n}",
            "requires": [],
            "notes": "Change $marker to your brand color; dark mode uses a deeper tone automatically."
        },
        {
            "id": "widget-area-before-post-content",
            "version": 1,
            "title": "Widget Area Above Single Posts",
            "description": "Registers a \"Before Post Content\" widget area and shows its widgets at the top of every blog post, ideal for notices, ads or a table of contents widget.",
            "category": "widgets",
            "tags": [
                "widgets",
                "sidebar",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'widgets_init', function () {\n    register_sidebar( array(\n        'name'          => 'Before Post Content',\n        'id'            => 'wpide-before-content',\n        'description'   => 'Shown above the content of single posts.',\n        'before_widget' => '<div id=\"%1$s\" class=\"widget %2$s\">',\n        'after_widget'  => '</div>',\n        'before_title'  => '<h3 class=\"widget-title\">',\n        'after_title'   => '</h3>',\n    ) );\n} );\n\nadd_filter( 'the_content', function ( $content ) {\n    static $running = false;\n\n    if ( $running || ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! is_active_sidebar( 'wpide-before-content' ) ) {\n        return $content;\n    }\n\n    $running = true;\n    ob_start();\n    dynamic_sidebar( 'wpide-before-content' );\n    $widgets = ob_get_clean();\n    $running = false;\n\n    return '<div class=\"wpide-before-content-widgets\">' . $widgets . '</div>' . $content;\n}, 5 );",
            "requires": [],
            "notes": "Add widgets under Appearance > Widgets. Nothing is printed while the area is empty."
        },
        {
            "id": "header-widget-area",
            "version": 1,
            "title": "Widget Area at the Top of Every Page",
            "description": "Adds a \"Top Bar\" widget area that prints right after the opening body tag, handy for a contact line, social links or a short promo across the whole site.",
            "category": "widgets",
            "tags": [
                "widgets",
                "header",
                "top-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'widgets_init', function () {\n    register_sidebar( array(\n        'name'          => 'Top Bar',\n        'id'            => 'wpide-top-bar',\n        'description'   => 'Shown at the very top of every page.',\n        'before_widget' => '<div id=\"%1$s\" class=\"widget %2$s\">',\n        'after_widget'  => '</div>',\n        'before_title'  => '<span class=\"widget-title screen-reader-text\">',\n        'after_title'   => '</span>',\n    ) );\n} );\n\nadd_action( 'wp_body_open', function () {\n    if ( ! is_active_sidebar( 'wpide-top-bar' ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-top-bar\" style=\"display:flex;flex-wrap:wrap;justify-content:center;gap:1.5em;padding:.5em 1em;font-size:.9em;background:#1d2327;color:#fff;\">';\n    dynamic_sidebar( 'wpide-top-bar' );\n    echo '</div>';\n} );",
            "requires": [],
            "notes": "Your theme must call wp_body_open(), which all modern themes do."
        },
        {
            "id": "widget-visibility-logged-in-out",
            "version": 1,
            "title": "Show Widgets Only to Logged-In or Logged-Out Visitors",
            "description": "Adds a \"Show to\" setting to every classic widget so you can display it to everyone, only to logged-in users, or only to visitors who are not logged in.",
            "category": "widgets",
            "tags": [
                "widgets",
                "visibility",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'in_widget_form', function ( $widget, $return, $instance ) {\n    $value   = isset( $instance['wpide_visibility'] ) ? $instance['wpide_visibility'] : '';\n    $options = array(\n        ''           => 'Everyone',\n        'logged_in'  => 'Logged-in users',\n        'logged_out' => 'Logged-out visitors',\n    );\n\n    echo '<p><label for=\"' . esc_attr( $widget->get_field_id( 'wpide_visibility' ) ) . '\">Show to:</label> ';\n    echo '<select class=\"widefat\" id=\"' . esc_attr( $widget->get_field_id( 'wpide_visibility' ) ) . '\" name=\"' . esc_attr( $widget->get_field_name( 'wpide_visibility' ) ) . '\">';\n\n    foreach ( $options as $key => $label ) {\n        echo '<option value=\"' . esc_attr( $key ) . '\"' . selected( $value, $key, false ) . '>' . esc_html( $label ) . '</option>';\n    }\n\n    echo '</select></p>';\n}, 10, 3 );\n\nadd_filter( 'widget_update_callback', function ( $instance, $new_instance ) {\n    $value = isset( $new_instance['wpide_visibility'] ) ? sanitize_key( $new_instance['wpide_visibility'] ) : '';\n\n    $instance['wpide_visibility'] = in_array( $value, array( 'logged_in', 'logged_out' ), true ) ? $value : '';\n\n    return $instance;\n}, 10, 2 );\n\nadd_filter( 'widget_display_callback', function ( $instance ) {\n    if ( ! is_array( $instance ) || empty( $instance['wpide_visibility'] ) ) {\n        return $instance;\n    }\n\n    if ( 'logged_in' === $instance['wpide_visibility'] && ! is_user_logged_in() ) {\n        return false;\n    }\n\n    if ( 'logged_out' === $instance['wpide_visibility'] && is_user_logged_in() ) {\n        return false;\n    }\n\n    return $instance;\n} );",
            "requires": [],
            "notes": "Works with classic widgets and the Legacy Widget block. Block widgets do not have the setting."
        },
        {
            "id": "hide-sidebar-on-selected-pages",
            "version": 1,
            "title": "Hide a Sidebar on Selected Pages or Post Types",
            "description": "Empties a widget area on the pages or post types you choose, for example to keep the sidebar off landing pages and checkout pages.",
            "category": "widgets",
            "tags": [
                "widgets",
                "sidebar",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// ID of the widget area to hide.\n$wpide_hidden_sidebar = 'sidebar-1';\n// Page or post IDs where it is hidden.\n$wpide_hide_sidebar_ids = array( 0 );\n// Post types whose single views hide it, for example array( 'page' ).\n$wpide_hide_sidebar_types = array();\n\nadd_filter( 'sidebars_widgets', function ( $sidebars ) use ( $wpide_hidden_sidebar, $wpide_hide_sidebar_ids, $wpide_hide_sidebar_types ) {\n    if ( is_admin() || ! isset( $sidebars[ $wpide_hidden_sidebar ] ) || ! did_action( 'wp' ) ) {\n        return $sidebars;\n    }\n\n    $hide = is_singular() && in_array( get_queried_object_id(), array_map( 'absint', $wpide_hide_sidebar_ids ), true );\n\n    if ( ! $hide && $wpide_hide_sidebar_types && is_singular( $wpide_hide_sidebar_types ) ) {\n        $hide = true;\n    }\n\n    if ( $hide ) {\n        $sidebars[ $wpide_hidden_sidebar ] = array();\n    }\n\n    return $sidebars;\n} );",
            "requires": [],
            "notes": "Set the sidebar ID (shown in Appearance > Widgets or your theme's code). Some themes still reserve the column; switch those pages to a full-width template if so."
        },
        {
            "id": "recent-posts-widget-exclude-category",
            "version": 1,
            "title": "Exclude a Category From the Recent Posts Widget",
            "description": "Keeps posts from the categories you choose out of the classic Recent Posts widget, for example sponsored posts or internal news.",
            "category": "widgets",
            "tags": [
                "widgets",
                "recent-posts",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category IDs to leave out of the widget.\n$wpide_widget_excluded_categories = array( 0 );\n\nadd_filter( 'widget_posts_args', function ( $args ) use ( $wpide_widget_excluded_categories ) {\n    $args['category__not_in'] = array_map( 'absint', $wpide_widget_excluded_categories );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Use category IDs. The Latest Posts block has its own category setting."
        },
        {
            "id": "recent-comments-widget-hide-staff",
            "version": 1,
            "title": "Hide Staff Replies in the Recent Comments Widget",
            "description": "Leaves comments written by authors, editors and administrators out of the Recent Comments widget, so it highlights what readers are saying.",
            "category": "widgets",
            "tags": [
                "widgets",
                "comments",
                "community"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_comments_args', function ( $args ) {\n    $staff = get_users( array(\n        'capability' => 'edit_posts',\n        'fields'     => 'ID',\n        'number'     => 200,\n    ) );\n\n    if ( $staff ) {\n        $args['author__not_in'] = array_map( 'intval', $staff );\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "archives-widget-options",
            "version": 1,
            "title": "Archives Widget: Yearly Links and a Limit",
            "description": "Changes the classic Archives widget to list years instead of months and caps how many entries it shows, keeping the sidebar short on old blogs.",
            "category": "widgets",
            "tags": [
                "widgets",
                "archives",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// yearly, monthly or weekly.\n$wpide_archives_widget_type = 'yearly';\n// Maximum number of links (0 = all).\n$wpide_archives_widget_limit = 10;\n\n$wpide_archives_widget_args = function ( $args ) use ( $wpide_archives_widget_type, $wpide_archives_widget_limit ) {\n    $args['type'] = $wpide_archives_widget_type;\n\n    if ( $wpide_archives_widget_limit > 0 ) {\n        $args['limit'] = (int) $wpide_archives_widget_limit;\n    }\n\n    return $args;\n};\n\nadd_filter( 'widget_archives_args', $wpide_archives_widget_args );\nadd_filter( 'widget_archives_dropdown_args', $wpide_archives_widget_args );",
            "requires": [],
            "notes": "Set the type to monthly to keep months and only apply the limit."
        },
        {
            "id": "tag-cloud-widget-popular-only",
            "version": 1,
            "title": "Tag Cloud Widget: Most Popular Tags Only",
            "description": "Limits the Tag Cloud widget and block to your most used tags and orders them by popularity, so the cloud stays readable on sites with hundreds of tags.",
            "category": "widgets",
            "tags": [
                "widgets",
                "tags",
                "tag-cloud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of tags to show.\n$wpide_tag_cloud_number = 20;\n\nadd_filter( 'widget_tag_cloud_args', function ( $args ) use ( $wpide_tag_cloud_number ) {\n    $args['number']  = absint( $wpide_tag_cloud_number );\n    $args['orderby'] = 'count';\n    $args['order']   = 'DESC';\n\n    return $args;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pages-widget-options",
            "version": 1,
            "title": "Pages Widget: Exclude Pages and Sort by Menu Order",
            "description": "Hides pages such as Privacy Policy or Thank You from the Pages widget and sorts the rest by their menu order.",
            "category": "widgets",
            "tags": [
                "widgets",
                "pages",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Page IDs to hide from the Pages widget.\n$wpide_pages_widget_exclude = array( 0 );\n\nadd_filter( 'widget_pages_args', function ( $args ) use ( $wpide_pages_widget_exclude ) {\n    $existing = isset( $args['exclude'] ) ? wp_parse_id_list( $args['exclude'] ) : array();\n\n    $args['exclude']     = implode( ',', array_merge( $existing, array_map( 'absint', $wpide_pages_widget_exclude ) ) );\n    $args['sort_column'] = 'menu_order, post_title';\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Use page IDs."
        },
        {
            "id": "widget-css-class-field",
            "version": 1,
            "title": "Custom CSS Classes for Widgets",
            "description": "Adds a \"CSS classes\" field to every classic widget, so you can style individual widgets from your theme or a CSS snippet.",
            "category": "widgets",
            "tags": [
                "widgets",
                "css",
                "classes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'in_widget_form', function ( $widget, $return, $instance ) {\n    $value = isset( $instance['wpide_css_class'] ) ? $instance['wpide_css_class'] : '';\n\n    echo '<p><label for=\"' . esc_attr( $widget->get_field_id( 'wpide_css_class' ) ) . '\">CSS classes:</label>';\n    echo '<input type=\"text\" class=\"widefat\" id=\"' . esc_attr( $widget->get_field_id( 'wpide_css_class' ) ) . '\" name=\"' . esc_attr( $widget->get_field_name( 'wpide_css_class' ) ) . '\" value=\"' . esc_attr( $value ) . '\" /></p>';\n}, 10, 3 );\n\nadd_filter( 'widget_update_callback', function ( $instance, $new_instance ) {\n    $classes = isset( $new_instance['wpide_css_class'] ) ? explode( ' ', (string) $new_instance['wpide_css_class'] ) : array();\n\n    $instance['wpide_css_class'] = implode( ' ', array_filter( array_map( 'sanitize_html_class', $classes ) ) );\n\n    return $instance;\n}, 10, 2 );\n\nadd_filter( 'dynamic_sidebar_params', function ( $params ) {\n    global $wp_registered_widgets;\n\n    $widget_id = isset( $params[0]['widget_id'] ) ? $params[0]['widget_id'] : '';\n    $callback  = isset( $wp_registered_widgets[ $widget_id ]['callback'][0] ) ? $wp_registered_widgets[ $widget_id ]['callback'][0] : null;\n\n    if ( ! $callback instanceof WP_Widget || ! isset( $params[1]['number'] ) ) {\n        return $params;\n    }\n\n    $settings = $callback->get_settings();\n    $classes  = isset( $settings[ $params[1]['number'] ]['wpide_css_class'] ) ? $settings[ $params[1]['number'] ]['wpide_css_class'] : '';\n\n    if ( '' !== $classes ) {\n        $params[0]['before_widget'] = preg_replace( '/class=\"/', 'class=\"' . esc_attr( $classes ) . ' ', $params[0]['before_widget'], 1 );\n    }\n\n    return $params;\n} );",
            "requires": [],
            "notes": "Separate several classes with spaces. Works with classic widgets and the Legacy Widget block."
        },
        {
            "id": "recent-posts-with-thumbnails-widget",
            "version": 1,
            "title": "Recent Posts Widget With Thumbnails",
            "description": "Adds a \"Recent Posts With Thumbnails\" widget that lists your latest posts with a small featured image and optional date, for classic sidebars.",
            "category": "widgets",
            "tags": [
                "widgets",
                "recent-posts",
                "thumbnails"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Recent_Posts_Thumbs_Widget' ) ) {\n    class WPIDE_Recent_Posts_Thumbs_Widget extends WP_Widget {\n        public function __construct() {\n            parent::__construct( 'wpide_recent_thumbs', 'Recent Posts With Thumbnails', array(\n                'description'                 => 'Your latest posts with their featured images.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $title  = isset( $instance['title'] ) && '' !== $instance['title'] ? $instance['title'] : 'Recent Posts';\n            $title  = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n            $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;\n            $posts  = get_posts( array(\n                'numberposts'         => $number,\n                'ignore_sticky_posts' => true,\n                'no_found_rows'       => true,\n                'post__not_in'        => is_singular() ? array( get_queried_object_id() ) : array(),\n            ) );\n\n            if ( ! $posts ) {\n                return;\n            }\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- markup from the theme.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<ul class=\"wpide-recent-thumbs\" style=\"list-style:none;margin:0;padding:0;\">';\n\n            foreach ( $posts as $item ) {\n                echo '<li style=\"display:flex;gap:.75em;align-items:center;margin:0 0 .75em;\">';\n\n                if ( has_post_thumbnail( $item ) ) {\n                    echo '<a href=\"' . esc_url( get_permalink( $item ) ) . '\" style=\"flex:0 0 64px;\" tabindex=\"-1\" aria-hidden=\"true\">' . get_the_post_thumbnail( $item, 'thumbnail', array( 'style' => 'display:block;width:64px;height:64px;object-fit:cover;border-radius:4px;', 'alt' => '' ) ) . '</a>';\n                }\n\n                echo '<span><a href=\"' . esc_url( get_permalink( $item ) ) . '\">' . esc_html( get_the_title( $item ) ) . '</a>';\n\n                if ( ! empty( $instance['show_date'] ) ) {\n                    echo '<br /><small>' . esc_html( get_the_date( '', $item ) ) . '</small>';\n                }\n\n                echo '</span></li>';\n            }\n\n            echo '</ul>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title     = isset( $instance['title'] ) ? $instance['title'] : '';\n            $number    = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;\n            $show_date = ! empty( $instance['show_date'] );\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'number' ) ) . '\">Number of posts:</label> <input class=\"tiny-text\" type=\"number\" min=\"1\" max=\"20\" id=\"' . esc_attr( $this->get_field_id( 'number' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'number' ) ) . '\" value=\"' . esc_attr( $number ) . '\" /></p>';\n            echo '<p><input type=\"checkbox\" class=\"checkbox\" id=\"' . esc_attr( $this->get_field_id( 'show_date' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'show_date' ) ) . '\"' . checked( $show_date, true, false ) . ' /> <label for=\"' . esc_attr( $this->get_field_id( 'show_date' ) ) . '\">Show post date</label></p>';\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title'     => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',\n                'number'    => isset( $new_instance['number'] ) ? max( 1, min( 20, absint( $new_instance['number'] ) ) ) : 5,\n                'show_date' => ! empty( $new_instance['show_date'] ),\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Recent_Posts_Thumbs_Widget' );\n} );",
            "requires": [],
            "notes": "Add it under Appearance > Widgets (as a Legacy Widget in the block widget editor)."
        },
        {
            "id": "contact-details-widget",
            "version": 1,
            "title": "Contact Details Widget",
            "description": "Adds a \"Contact Details\" widget for your address, phone number, email and opening hours, with tap-to-call and spam-protected email links.",
            "category": "widgets",
            "tags": [
                "widgets",
                "contact",
                "business"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Contact_Details_Widget' ) ) {\n    class WPIDE_Contact_Details_Widget extends WP_Widget {\n        private $fields = array(\n            'address' => 'Address',\n            'phone'   => 'Phone',\n            'email'   => 'Email',\n            'hours'   => 'Opening hours',\n        );\n\n        public function __construct() {\n            parent::__construct( 'wpide_contact_details', 'Contact Details', array(\n                'description'                 => 'Your address, phone, email and opening hours.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $instance = wp_parse_args( (array) $instance, array_fill_keys( array_merge( array( 'title' ), array_keys( $this->fields ) ), '' ) );\n            $title    = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- markup from the theme.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<address class=\"wpide-contact-details\" style=\"font-style:normal;\">';\n\n            if ( '' !== $instance['address'] ) {\n                echo '<p>' . nl2br( esc_html( $instance['address'] ) ) . '</p>';\n            }\n\n            if ( '' !== $instance['phone'] ) {\n                echo '<p><a href=\"tel:' . esc_attr( preg_replace( '/[^0-9+]/', '', $instance['phone'] ) ) . '\">' . esc_html( $instance['phone'] ) . '</a></p>';\n            }\n\n            if ( is_email( $instance['email'] ) ) {\n                echo '<p><a href=\"mailto:' . antispambot( $instance['email'], 1 ) . '\">' . antispambot( $instance['email'] ) . '</a></p>';\n            }\n\n            if ( '' !== $instance['hours'] ) {\n                echo '<p>' . nl2br( esc_html( $instance['hours'] ) ) . '</p>';\n            }\n\n            echo '</address>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $instance = wp_parse_args( (array) $instance, array_fill_keys( array_merge( array( 'title' ), array_keys( $this->fields ) ), '' ) );\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $instance['title'] ) . '\" /></p>';\n\n            foreach ( $this->fields as $key => $label ) {\n                $id   = $this->get_field_id( $key );\n                $name = $this->get_field_name( $key );\n\n                if ( in_array( $key, array( 'address', 'hours' ), true ) ) {\n                    echo '<p><label for=\"' . esc_attr( $id ) . '\">' . esc_html( $label ) . ':</label><textarea class=\"widefat\" rows=\"3\" id=\"' . esc_attr( $id ) . '\" name=\"' . esc_attr( $name ) . '\">' . esc_textarea( $instance[ $key ] ) . '</textarea></p>';\n                } else {\n                    echo '<p><label for=\"' . esc_attr( $id ) . '\">' . esc_html( $label ) . ':</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $id ) . '\" name=\"' . esc_attr( $name ) . '\" value=\"' . esc_attr( $instance[ $key ] ) . '\" /></p>';\n                }\n            }\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title'   => sanitize_text_field( isset( $new_instance['title'] ) ? $new_instance['title'] : '' ),\n                'address' => sanitize_textarea_field( isset( $new_instance['address'] ) ? $new_instance['address'] : '' ),\n                'phone'   => sanitize_text_field( isset( $new_instance['phone'] ) ? $new_instance['phone'] : '' ),\n                'email'   => sanitize_email( isset( $new_instance['email'] ) ? $new_instance['email'] : '' ),\n                'hours'   => sanitize_textarea_field( isset( $new_instance['hours'] ) ? $new_instance['hours'] : '' ),\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Contact_Details_Widget' );\n} );",
            "requires": [],
            "notes": "Add it under Appearance > Widgets (as a Legacy Widget in the block widget editor)."
        },
        {
            "id": "hide-widget-titles-with-exclamation",
            "version": 1,
            "title": "Hide Widget Titles That Start With \"!\"",
            "description": "Lets you keep a descriptive widget title in the admin but hide it on the site, just by starting the title with an exclamation mark.",
            "category": "widgets",
            "tags": [
                "widgets",
                "titles",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_title', function ( $title ) {\n    return ( is_string( $title ) && 0 === strpos( $title, '!' ) ) ? '' : $title;\n} );",
            "requires": [],
            "notes": "Example: a widget titled \"!Newsletter form\" shows no heading on the site."
        },
        {
            "id": "sticky-last-sidebar-widget",
            "version": 1,
            "title": "Keep the Last Sidebar Widget in View",
            "description": "Makes the last widget in the sidebar stick to the top of the screen while the visitor scrolls long posts, a good spot for a newsletter form or ad.",
            "category": "widgets",
            "tags": [
                "widgets",
                "sticky",
                "sidebar"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (min-width: 1024px) {\n    #secondary .widget:last-child,\n    .widget-area .widget:last-child,\n    .sidebar .widget:last-child {\n        position: sticky;\n        top: 2rem;\n    }\n\n    .admin-bar #secondary .widget:last-child,\n    .admin-bar .widget-area .widget:last-child,\n    .admin-bar .sidebar .widget:last-child {\n        top: calc(2rem + 32px);\n    }\n}",
            "requires": [],
            "notes": "Only applies on screens wider than 1024 pixels. Adjust the selectors if your theme uses different sidebar classes."
        },
        {
            "id": "widget-area-shortcode",
            "version": 1,
            "title": "Widget Area Shortcode",
            "description": "Adds a [widget_area] shortcode that prints any widget area inside a page, post or block template, so the same widgets can be reused anywhere.",
            "category": "widgets",
            "tags": [
                "widgets",
                "shortcode",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'widget_area', function ( $atts ) {\n    static $depth = 0;\n\n    $atts = shortcode_atts( array( 'id' => '' ), $atts, 'widget_area' );\n    $id   = sanitize_text_field( $atts['id'] );\n\n    if ( '' === $id || $depth > 0 || ! is_active_sidebar( $id ) ) {\n        return '';\n    }\n\n    $depth++;\n    ob_start();\n    dynamic_sidebar( $id );\n    $html = ob_get_clean();\n    $depth--;\n\n    return '<div class=\"wpide-widget-area wpide-widget-area-' . esc_attr( sanitize_html_class( $id ) ) . '\">' . $html . '</div>';\n} );",
            "requires": [],
            "notes": "Example: [widget_area id=\"sidebar-1\"]. Find the IDs of your widget areas in your theme or with the Register Extra Widget Areas snippet."
        },
        {
            "id": "register-extra-widget-areas",
            "version": 1,
            "title": "Register Extra Widget Areas",
            "description": "Creates additional widget areas, such as a shop sidebar or a blog sidebar, that you can fill under Appearance > Widgets and place with a shortcode or theme hook.",
            "category": "widgets",
            "tags": [
                "widgets",
                "sidebar",
                "register"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Widget area ID => name.\n$wpide_extra_widget_areas = array(\n    'wpide-blog-sidebar' => 'Blog Sidebar',\n    'wpide-shop-sidebar' => 'Shop Sidebar',\n);\n\nadd_action( 'widgets_init', function () use ( $wpide_extra_widget_areas ) {\n    foreach ( $wpide_extra_widget_areas as $id => $name ) {\n        register_sidebar( array(\n            'id'            => $id,\n            'name'          => $name,\n            'description'   => sprintf( 'Show it with [widget_area id=\"%s\"].', $id ),\n            'before_widget' => '<section id=\"%1$s\" class=\"widget %2$s\">',\n            'after_widget'  => '</section>',\n            'before_title'  => '<h2 class=\"widget-title\">',\n            'after_title'   => '</h2>',\n        ) );\n    }\n} );",
            "requires": [],
            "notes": "Add or rename areas in the list. Show them with [widget_area id=\"...\"] from the Widget Area Shortcode snippet."
        },
        {
            "id": "limit-blocks-in-widget-editor",
            "version": 1,
            "title": "Limit the Blocks Available in the Widget Editor",
            "description": "Restricts the block widget editor and the Customizer to a short list of blocks that make sense in sidebars, keeping widget areas tidy for clients.",
            "category": "widgets",
            "tags": [
                "widgets",
                "blocks",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Blocks allowed in widget areas.\n$wpide_widget_blocks = array(\n    'core/paragraph',\n    'core/heading',\n    'core/image',\n    'core/list',\n    'core/list-item',\n    'core/buttons',\n    'core/button',\n    'core/search',\n    'core/latest-posts',\n    'core/categories',\n    'core/archives',\n    'core/tag-cloud',\n    'core/social-links',\n    'core/social-link',\n    'core/shortcode',\n    'core/legacy-widget',\n    'core/widget-group',\n);\n\nadd_filter( 'allowed_block_types_all', function ( $allowed, $context ) use ( $wpide_widget_blocks ) {\n    if ( isset( $context->name ) && in_array( $context->name, array( 'core/edit-widgets', 'core/customize-widgets' ), true ) ) {\n        return $wpide_widget_blocks;\n    }\n\n    return $allowed;\n}, 10, 2 );",
            "requires": [],
            "notes": "Edit the list to allow more blocks. Blocks already placed in widget areas keep working."
        },
        {
            "id": "recent-posts-widget-current-category",
            "version": 1,
            "title": "Recent Posts Widget Follows the Current Category",
            "description": "Makes the classic Recent Posts widget list posts from the same category as the post being read, turning it into a simple related posts list.",
            "category": "widgets",
            "tags": [
                "widgets",
                "recent-posts",
                "related"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_posts_args', function ( $args ) {\n    if ( ! is_singular( 'post' ) ) {\n        return $args;\n    }\n\n    $categories = wp_get_post_categories( get_queried_object_id() );\n\n    if ( $categories ) {\n        $args['category__in'] = $categories;\n    }\n\n    $args['post__not_in'] = array( get_queried_object_id() );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "On pages other than single posts the widget shows the latest posts as usual."
        },
        {
            "id": "woo-shop-filters-widget-area",
            "version": 1,
            "title": "Filters Widget Area Above the Shop",
            "description": "Registers a \"Shop Filters\" widget area and shows it above the product grid on shop and category pages, a natural home for price, attribute and rating filters.",
            "category": "widgets",
            "tags": [
                "woocommerce",
                "widgets",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'widgets_init', function () {\n    register_sidebar( array(\n        'name'          => 'Shop Filters',\n        'id'            => 'wpide-shop-filters',\n        'description'   => 'Shown above the products on shop and product category pages.',\n        'before_widget' => '<div id=\"%1$s\" class=\"widget %2$s\" style=\"flex:1 1 200px;\">',\n        'after_widget'  => '</div>',\n        'before_title'  => '<h3 class=\"widget-title\">',\n        'after_title'   => '</h3>',\n    ) );\n} );\n\nadd_action( 'woocommerce_before_shop_loop', function () {\n    if ( ! is_active_sidebar( 'wpide-shop-filters' ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-shop-filters\" style=\"display:flex;flex-wrap:wrap;gap:1.5em;margin:0 0 1.5em;\">';\n    dynamic_sidebar( 'wpide-shop-filters' );\n    echo '</div>';\n}, 35 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with classic WooCommerce shop templates. Block-based shop templates should use the Product Filters blocks instead."
        },
        {
            "id": "sidebar-widget-cards-scss",
            "version": 1,
            "title": "Sidebar Widgets as Cards (SCSS)",
            "description": "Styles each sidebar widget as a card with a border, rounded corners, an underlined title and tidy list dividers.",
            "category": "widgets",
            "tags": [
                "scss",
                "widgets",
                "sidebar"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$card-radius: 8px;\n$card-padding: 1.25rem;\n$divider: rgba(0, 0, 0, 0.1);\n\n#secondary .widget,\n.widget-area .widget,\n.sidebar .widget {\n    margin-bottom: 1.5rem;\n    padding: $card-padding;\n    border: 1px solid $divider;\n    border-radius: $card-radius;\n    background: var(--wp--preset--color--base, #fff);\n\n    .widget-title,\n    .widgettitle {\n        margin-top: 0;\n        padding-bottom: 0.5rem;\n        border-bottom: 2px solid currentColor;\n        font-size: 1rem;\n        letter-spacing: 0.05em;\n        text-transform: uppercase;\n    }\n\n    ul {\n        margin: 0;\n        padding-left: 0;\n        list-style: none;\n\n        li {\n            padding: 0.4rem 0;\n            border-bottom: 1px solid $divider;\n\n            &:last-child {\n                border-bottom: 0;\n            }\n        }\n    }\n}",
            "requires": [],
            "notes": "Adjust the variables at the top. Selectors cover the usual classic theme sidebar classes."
        },
        {
            "id": "tag-cloud-pills-scss",
            "version": 1,
            "title": "Tag Cloud as Pills (SCSS)",
            "description": "Restyles the Tag Cloud widget and block as a tidy row of rounded pills with equal font sizes, which reads better than a jumble of sizes.",
            "category": "widgets",
            "tags": [
                "scss",
                "tag-cloud",
                "widgets"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #2271b1;\n\n.tagcloud,\n.wp-block-tag-cloud {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.4em;\n\n    a {\n        display: inline-block;\n        padding: 0.3em 0.85em;\n        border: 1px solid rgba(0, 0, 0, 0.15);\n        border-radius: 999px;\n        font-size: 0.85rem !important;\n        line-height: 1.4;\n        text-decoration: none;\n\n        &:hover,\n        &:focus {\n            border-color: $accent;\n            background: $accent;\n            color: #fff;\n        }\n    }\n}",
            "requires": [],
            "notes": "Font sizes set by the tag cloud are overridden on purpose."
        },
        {
            "id": "allowed-blocks-per-post-type",
            "version": 1,
            "title": "Allowed Blocks per Post Type",
            "description": "Limits the blocks that can be inserted for specific post types, so structured content such as testimonials or team members only uses the blocks that fit.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "post-types",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => block names allowed in its editor.\n$wpide_allowed_blocks_by_type = array(\n    'testimonial' => array( 'core/paragraph', 'core/quote', 'core/image' ),\n);\n\nadd_filter( 'allowed_block_types_all', function ( $allowed, $context ) use ( $wpide_allowed_blocks_by_type ) {\n    if ( empty( $context->post ) || ! isset( $wpide_allowed_blocks_by_type[ $context->post->post_type ] ) ) {\n        return $allowed;\n    }\n\n    return $wpide_allowed_blocks_by_type[ $context->post->post_type ];\n}, 10, 2 );",
            "requires": [],
            "notes": "Post types that are not listed keep every block. Blocks already in a post keep working."
        },
        {
            "id": "classic-editor-for-selected-pages",
            "version": 1,
            "title": "Use the Classic Editor for Selected Pages or Templates",
            "description": "Opens specific pages, or pages using certain templates, in the classic editor while everything else keeps the block editor.",
            "category": "gutenberg",
            "tags": [
                "classic-editor",
                "pages",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post or page IDs that open in the classic editor.\n$wpide_classic_editor_ids = array( 0 );\n// Page template file names that open in the classic editor.\n$wpide_classic_editor_templates = array( 'template-legacy.php' );\n\nadd_filter( 'use_block_editor_for_post', function ( $use, $post ) use ( $wpide_classic_editor_ids, $wpide_classic_editor_templates ) {\n    if ( ! $post ) {\n        return $use;\n    }\n\n    if ( in_array( (int) $post->ID, array_map( 'absint', $wpide_classic_editor_ids ), true ) ) {\n        return false;\n    }\n\n    $template = get_page_template_slug( $post );\n\n    if ( $template && in_array( $template, $wpide_classic_editor_templates, true ) ) {\n        return false;\n    }\n\n    return $use;\n}, 10, 2 );",
            "requires": [],
            "notes": "List page IDs and template file names, such as template-legacy.php. Requires the classic editor to be available (it is built in for posts that opt out)."
        },
        {
            "id": "register-custom-block-pattern",
            "version": 1,
            "title": "Register Your Own Block Pattern",
            "description": "Adds a reusable \"Call to action\" pattern in its own \"My Patterns\" category to the block inserter, as a starting point for your own patterns.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "blocks",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_pattern_category( 'wpide-patterns', array( 'label' => 'My Patterns' ) );\n\n    register_block_pattern( 'wpide/call-to-action', array(\n        'title'       => 'Call to action',\n        'description' => 'A centered heading, short text and a button.',\n        'categories'  => array( 'wpide-patterns', 'call-to-action' ),\n        'keywords'    => array( 'cta', 'button', 'banner' ),\n        'content'     => '<!-- wp:group {\"style\":{\"spacing\":{\"padding\":{\"top\":\"3em\",\"bottom\":\"3em\",\"left\":\"2em\",\"right\":\"2em\"}}},\"layout\":{\"type\":\"constrained\"}} -->\n<div class=\"wp-block-group\" style=\"padding-top:3em;padding-right:2em;padding-bottom:3em;padding-left:2em\"><!-- wp:heading {\"textAlign\":\"center\"} -->\n<h2 class=\"wp-block-heading has-text-align-center\">Ready to get started?</h2>\n<!-- /wp:heading -->\n\n<!-- wp:paragraph {\"align\":\"center\"} -->\n<p class=\"has-text-align-center\">Tell visitors what they get and why it matters.</p>\n<!-- /wp:paragraph -->\n\n<!-- wp:buttons {\"layout\":{\"type\":\"flex\",\"justifyContent\":\"center\"}} -->\n<div class=\"wp-block-buttons\"><!-- wp:button -->\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"/contact/\">Contact us</a></div>\n<!-- /wp:button --></div>\n<!-- /wp:buttons --></div>\n<!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Design the layout in the editor, copy it with \"Copy\" from the block toolbar and paste the markup into the content setting."
        },
        {
            "id": "custom-block-styles",
            "version": 1,
            "title": "Extra Block Styles: Card, Pill Button, Shadow Image and Checklist",
            "description": "Adds four ready-made style options to the block editor: a card style for Group blocks, a pill style for buttons, a soft shadow for images and a checkmark style for lists.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "design",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/group', array(\n        'name'         => 'wpide-card',\n        'label'        => 'Card',\n        'inline_style' => '.wp-block-group.is-style-wpide-card{padding:1.5em;border-radius:10px;background:#fff;color:#1d2327;box-shadow:0 2px 12px rgba(0,0,0,.08);}',\n    ) );\n\n    register_block_style( 'core/button', array(\n        'name'         => 'wpide-pill',\n        'label'        => 'Pill',\n        'inline_style' => '.wp-block-button.is-style-wpide-pill .wp-block-button__link{border-radius:999px;padding-left:1.75em;padding-right:1.75em;}',\n    ) );\n\n    register_block_style( 'core/image', array(\n        'name'         => 'wpide-shadow',\n        'label'        => 'Soft shadow',\n        'inline_style' => '.wp-block-image.is-style-wpide-shadow img{border-radius:6px;box-shadow:0 10px 30px rgba(0,0,0,.18);}',\n    ) );\n\n    register_block_style( 'core/list', array(\n        'name'         => 'wpide-checklist',\n        'label'        => 'Checklist',\n        'inline_style' => '.wp-block-list.is-style-wpide-checklist{list-style:none;padding-left:0;}.wp-block-list.is-style-wpide-checklist li::before{content:\"\\2713\";margin-right:.6em;color:#00a32a;font-weight:700;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Pick the styles in the block sidebar under Styles. Edit the CSS in each inline_style to match your design."
        },
        {
            "id": "remove-core-block-styles",
            "version": 1,
            "title": "Remove Built-In Block Style Options",
            "description": "Removes block style choices you do not want editors to use, such as the outline button, rounded image or dotted separator, keeping the design consistent.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "editor",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Block name => style names to remove.\n$wpide_removed_block_styles = array(\n    'core/button'    => array( 'outline' ),\n    'core/image'     => array( 'rounded' ),\n    'core/separator' => array( 'dots', 'wide' ),\n    'core/quote'     => array( 'plain' ),\n);\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_removed_block_styles ) {\n    wp_add_inline_script(\n        'wp-blocks',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){var map=' . wp_json_encode( $wpide_removed_block_styles ) . ';Object.keys(map).forEach(function(block){map[block].forEach(function(style){wp.blocks.unregisterBlockStyle(block,style);});});});});'\n    );\n} );",
            "requires": [],
            "notes": "Edit the list of block names and style names. Content that already uses a removed style keeps its look."
        },
        {
            "id": "limit-embed-providers",
            "version": 1,
            "title": "Limit the Embed Providers in the Inserter",
            "description": "Hides the long list of embed blocks (TikTok, Reddit, Kickstarter and dozens more) from the inserter, keeping only the providers you actually use.",
            "category": "gutenberg",
            "tags": [
                "embeds",
                "inserter",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Embed providers to keep in the inserter.\n$wpide_kept_embeds = array( 'youtube', 'vimeo', 'twitter', 'spotify' );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_kept_embeds ) {\n    wp_add_inline_script(\n        'wp-blocks',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){var keep=' . wp_json_encode( array_values( $wpide_kept_embeds ) ) . ';(wp.blocks.getBlockVariations(\"core/embed\")||[]).forEach(function(variation){if(keep.indexOf(variation.name)===-1){wp.blocks.unregisterBlockVariation(\"core/embed\",variation.name);}});});});'\n    );\n} );",
            "requires": [],
            "notes": "Pasting a URL from a hidden provider still embeds it. Provider names are lowercase, such as youtube, vimeo, twitter, spotify or soundcloud."
        },
        {
            "id": "lock-editor-to-theme-presets",
            "version": 1,
            "title": "Lock Colors and Sizes to the Theme Presets",
            "description": "Removes the custom color picker, custom gradients and free-form font and spacing sizes from the editor, so editors can only choose from your theme's palette and scales.",
            "category": "gutenberg",
            "tags": [
                "colors",
                "typography",
                "design-system"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'color'      => array(\n                'custom'         => false,\n                'customGradient' => false,\n                'customDuotone'  => false,\n            ),\n            'typography' => array(\n                'customFontSize' => false,\n            ),\n            'spacing'    => array(\n                'customSpacingSize' => false,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Existing content with custom values keeps them. Works with block and classic themes."
        },
        {
            "id": "add-brand-colors-to-palette",
            "version": 1,
            "title": "Add Brand Colors to the Editor Palette",
            "description": "Adds your brand colors to the color palette in the block editor, next to the theme's colors, so the exact shades are one click away.",
            "category": "gutenberg",
            "tags": [
                "colors",
                "palette",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Colors to add: slug, name and hex value.\n$wpide_brand_colors = array(\n    array( 'slug' => 'brand', 'name' => 'Brand', 'color' => '#2271b1' ),\n    array( 'slug' => 'brand-dark', 'name' => 'Brand dark', 'color' => '#135e96' ),\n    array( 'slug' => 'accent', 'name' => 'Accent', 'color' => '#d63638' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_brand_colors ) {\n    $data    = $theme_json->get_data();\n    $palette = isset( $data['settings']['color']['palette'] ) && is_array( $data['settings']['color']['palette'] ) ? $data['settings']['color']['palette'] : array();\n    $palette = isset( $palette['theme'] ) && is_array( $palette['theme'] ) ? $palette['theme'] : $palette;\n    $by_slug = array();\n\n    foreach ( array_merge( $palette, $wpide_brand_colors ) as $color ) {\n        if ( isset( $color['slug'], $color['color'] ) ) {\n            $by_slug[ $color['slug'] ] = $color;\n        }\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'color' => array(\n                'palette' => array_values( $by_slug ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Colors with a slug the theme already uses replace the theme color. Each color also becomes a CSS variable such as --wp--preset--color--brand."
        },
        {
            "id": "add-custom-font-sizes",
            "version": 1,
            "title": "Add Custom Font Sizes to the Editor",
            "description": "Adds your own named font sizes, such as \"Lead\" or \"Display\", to the font size picker in the block editor alongside the theme's sizes.",
            "category": "gutenberg",
            "tags": [
                "typography",
                "font-sizes",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Font sizes to add: slug, name and CSS size.\n$wpide_font_sizes = array(\n    array( 'slug' => 'lead', 'name' => 'Lead', 'size' => '1.25rem' ),\n    array( 'slug' => 'display', 'name' => 'Display', 'size' => 'clamp(2.5rem, 5vw, 4rem)' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_font_sizes ) {\n    $data    = $theme_json->get_data();\n    $sizes   = isset( $data['settings']['typography']['fontSizes'] ) && is_array( $data['settings']['typography']['fontSizes'] ) ? $data['settings']['typography']['fontSizes'] : array();\n    $sizes   = isset( $sizes['theme'] ) && is_array( $sizes['theme'] ) ? $sizes['theme'] : $sizes;\n    $by_slug = array();\n\n    foreach ( array_merge( $sizes, $wpide_font_sizes ) as $size ) {\n        if ( isset( $size['slug'], $size['size'] ) ) {\n            $by_slug[ $size['slug'] ] = $size;\n        }\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'typography' => array(\n                'fontSizes' => array_values( $by_slug ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Sizes accept any CSS length, including clamp() for fluid type. A slug the theme already uses replaces the theme size."
        },
        {
            "id": "wider-editor-content",
            "version": 1,
            "title": "Wider Content Area in the Editor",
            "description": "Makes the writing area of the block editor wider, giving more room on large screens without changing how the site looks to visitors.",
            "category": "gutenberg",
            "tags": [
                "editor",
                "width",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Width of the content column in the editor.\n$wpide_editor_content_width = '1000px';\n\nadd_action( 'enqueue_block_assets', function () use ( $wpide_editor_content_width ) {\n    if ( ! is_admin() || ! preg_match( '/^\\d+(\\.\\d+)?(px|rem|em|vw|%)$/', $wpide_editor_content_width ) ) {\n        return;\n    }\n\n    wp_register_style( 'wpide-editor-width', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-editor-width' );\n    wp_add_inline_style( 'wpide-editor-width', '.editor-styles-wrapper{--wp--style--global--content-size:' . $wpide_editor_content_width . ';}.editor-styles-wrapper .is-root-container > .wp-block:not([data-align=\"wide\"]):not([data-align=\"full\"]){max-width:' . $wpide_editor_content_width . ';}' );\n} );",
            "requires": [],
            "notes": "Set any CSS width such as 1000px or 75rem."
        },
        {
            "id": "block-template-for-new-posts",
            "version": 1,
            "title": "Block Template for New Posts",
            "description": "Starts every new post with a set of placeholder blocks, such as a summary paragraph, a heading and an image, so writers follow the same structure.",
            "category": "gutenberg",
            "tags": [
                "templates",
                "blocks",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type that gets the template.\n$wpide_template_post_type = 'post';\n// Blocks and their attributes.\n$wpide_template_blocks = array(\n    array( 'core/paragraph', array( 'placeholder' => 'Summarize the post in one or two sentences...' ) ),\n    array( 'core/heading', array( 'level' => 2, 'placeholder' => 'First section heading' ) ),\n    array( 'core/paragraph', array( 'placeholder' => 'Write the first section...' ) ),\n    array( 'core/image', array() ),\n);\n// false, 'insert' or 'all'.\n$wpide_template_lock = false;\n\nadd_action( 'init', function () use ( $wpide_template_post_type, $wpide_template_blocks, $wpide_template_lock ) {\n    $object = get_post_type_object( $wpide_template_post_type );\n\n    if ( ! $object ) {\n        return;\n    }\n\n    $object->template = $wpide_template_blocks;\n\n    if ( $wpide_template_lock ) {\n        $object->template_lock = $wpide_template_lock;\n    }\n}, 20 );",
            "requires": [],
            "notes": "Change the post type, the blocks and the lock. Lock 'insert' stops blocks being added or removed; 'all' also stops moving them."
        },
        {
            "id": "skip-pre-publish-checks",
            "version": 1,
            "title": "Skip the Pre-Publish Checks Panel by Default",
            "description": "Publishes posts with a single click instead of opening the \"Are you ready to publish?\" panel first. Each user can still switch the panel back on in the editor preferences.",
            "category": "gutenberg",
            "tags": [
                "editor",
                "publishing",
                "preferences"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_add_inline_script(\n        'wp-preferences',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){wp.data.dispatch(\"core/preferences\").setDefaults(\"core\",{isPublishSidebarEnabled:false});});});'\n    );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "disable-template-editing-from-posts",
            "version": 1,
            "title": "Hide Template Editing in the Post Editor",
            "description": "Removes the option to switch to and edit the site template from inside the post and page editor, so content editors cannot change the site layout by accident.",
            "category": "gutenberg",
            "tags": [
                "templates",
                "editor",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings, $context ) {\n    if ( ! empty( $context->post ) ) {\n        $settings['supportsTemplateMode'] = false;\n    }\n\n    return $settings;\n}, 10, 2 );",
            "requires": [],
            "notes": "Administrators can still edit templates in the Site Editor."
        },
        {
            "id": "disable-duotone-filters",
            "version": 1,
            "title": "Disable Duotone Filters",
            "description": "Removes the duotone image filter controls from the editor and stops WordPress outputting the default duotone presets.",
            "category": "gutenberg",
            "tags": [
                "duotone",
                "images",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'color' => array(\n                'customDuotone'  => false,\n                'defaultDuotone' => false,\n                'duotone'        => array(),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "simplify-typography-controls",
            "version": 1,
            "title": "Simplify the Editor's Typography Controls",
            "description": "Hides letter spacing, text transform, text decoration, font style, font weight, line height and writing mode controls, leaving a calmer sidebar for content editors.",
            "category": "gutenberg",
            "tags": [
                "typography",
                "editor",
                "simplify"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => 3,\n        'settings' => array(\n            'typography' => array(\n                'letterSpacing'  => false,\n                'textTransform'  => false,\n                'textDecoration' => false,\n                'fontStyle'      => false,\n                'fontWeight'     => false,\n                'lineHeight'     => false,\n                'writingMode'    => false,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Remove lines from the list to keep a control."
        },
        {
            "id": "modern-block-features-classic-themes",
            "version": 1,
            "title": "Turn On Modern Block Features in Classic Themes",
            "description": "Enables wide and full alignments, responsive embeds, spacing, border, line height and link color controls for classic themes that never declared them.",
            "category": "gutenberg",
            "tags": [
                "classic-themes",
                "alignments",
                "blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    if ( wp_is_block_theme() ) {\n        return;\n    }\n\n    foreach ( array( 'align-wide', 'responsive-embeds', 'custom-spacing', 'custom-line-height', 'custom-units', 'link-color', 'border', 'appearance-tools' ) as $feature ) {\n        add_theme_support( $feature );\n    }\n}, 20 );",
            "requires": [],
            "notes": "Your theme's CSS decides how wide alignments look; test a few pages after enabling it."
        },
        {
            "id": "outline-blocks-while-editing",
            "version": 1,
            "title": "Outline Blocks While Editing",
            "description": "Draws a faint dashed outline around blocks as you hover over them in the editor, making nested groups, columns and empty blocks easy to see.",
            "category": "gutenberg",
            "tags": [
                "editor",
                "outline",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_assets', function () {\n    if ( ! is_admin() ) {\n        return;\n    }\n\n    wp_register_style( 'wpide-block-outlines', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-block-outlines' );\n    wp_add_inline_style( 'wpide-block-outlines', '.editor-styles-wrapper .wp-block:hover{outline:1px dashed rgba(120,120,120,.45);outline-offset:2px;}.editor-styles-wrapper .wp-block.is-selected:hover{outline:none;}' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "hide-custom-html-block-from-inserter",
            "version": 1,
            "title": "Hide the Custom HTML Block From Non-Administrators",
            "description": "Removes the Custom HTML block from the inserter for users who cannot post unfiltered HTML, so authors use proper blocks instead of pasting raw markup.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "html",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    if ( current_user_can( 'unfiltered_html' ) ) {\n        return;\n    }\n\n    wp_add_inline_script(\n        'wp-blocks',\n        'wp.hooks.addFilter(\"blocks.registerBlockType\",\"wpide/hide-html-block\",function(settings,name){if(name===\"core/html\"){settings.supports=Object.assign({},settings.supports,{inserter:false});}return settings;});'\n    );\n} );",
            "requires": [],
            "notes": "Existing Custom HTML blocks stay editable; only inserting new ones is hidden."
        },
        {
            "id": "synced-pattern-shortcode",
            "version": 1,
            "title": "Synced Pattern Shortcode",
            "description": "Adds a [synced_pattern] shortcode that prints a synced pattern (formerly reusable block) anywhere shortcodes work, such as classic widgets, page builders or theme files.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "shortcode",
                "reusable-blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'synced_pattern', function ( $atts ) {\n    static $depth = 0;\n\n    $atts    = shortcode_atts( array( 'id' => 0 ), $atts, 'synced_pattern' );\n    $pattern = get_post( absint( $atts['id'] ) );\n\n    if ( ! $pattern || 'wp_block' !== $pattern->post_type || 'publish' !== $pattern->post_status || $depth > 2 ) {\n        return '';\n    }\n\n    $depth++;\n    $html = do_blocks( $pattern->post_content );\n    $depth--;\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Example: [synced_pattern id=\"123\"]. Find the ID in Appearance > Patterns by opening the pattern and checking the address bar."
        },
        {
            "id": "default-image-size-large",
            "version": 1,
            "title": "Insert Images at Large Size by Default",
            "description": "Makes new Image blocks and media inserted in the editor use the Large size by default, instead of the full original file.",
            "category": "gutenberg",
            "tags": [
                "images",
                "editor",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default size for inserted images.\n$wpide_default_image_size = 'large';\n\nadd_filter( 'pre_option_image_default_size', function () use ( $wpide_default_image_size ) {\n    return $wpide_default_image_size;\n} );",
            "requires": [],
            "notes": "Change large to medium or another registered size."
        },
        {
            "id": "disable-theme-block-patterns",
            "version": 1,
            "title": "Disable the Theme's Block Patterns",
            "description": "Removes the patterns bundled with your active theme from the inserter, for sites that only want their own patterns.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "themes",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $registry = WP_Block_Patterns_Registry::get_instance();\n    $prefixes = array_unique( array( get_stylesheet() . '/', get_template() . '/' ) );\n\n    foreach ( $registry->get_all_registered() as $pattern ) {\n        foreach ( $prefixes as $prefix ) {\n            if ( 0 === strpos( $pattern['name'], $prefix ) ) {\n                unregister_block_pattern( $pattern['name'] );\n                break;\n            }\n        }\n    }\n}, 99 );",
            "requires": [],
            "notes": "Patterns from WordPress core and plugins are not affected. Use Disable Core Block Patterns for those."
        },
        {
            "id": "turn-off-starter-patterns-modal",
            "version": 1,
            "title": "Turn Off the Starter Patterns Window for New Pages",
            "description": "Stops the \"Choose a pattern\" window from popping up every time someone creates a new page. Users can switch it back on in the editor preferences.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "editor",
                "preferences"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_add_inline_script(\n        'wp-preferences',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){wp.data.dispatch(\"core/preferences\").setDefaults(\"core\",{enableChoosePatternModal:false});});});'\n    );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "limit-heading-levels",
            "version": 1,
            "title": "Limit Heading Levels in the Editor",
            "description": "Shows only the heading levels you allow (for example H2 to H4) in the Heading block toolbar, so editors keep a clean document outline and never add a second H1.",
            "category": "gutenberg",
            "tags": [
                "headings",
                "accessibility",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Heading levels to offer.\n$wpide_heading_levels = array( 2, 3, 4 );\n\nadd_filter( 'register_block_type_args', function ( $args, $block_type ) use ( $wpide_heading_levels ) {\n    if ( 'core/heading' !== $block_type || ! isset( $args['attributes'] ) ) {\n        return $args;\n    }\n\n    $args['attributes']['levelOptions'] = array(\n        'type'    => 'array',\n        'default' => array_map( 'intval', $wpide_heading_levels ),\n    );\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Requires WordPress 6.7 or newer. Existing headings keep their level."
        },
        {
            "id": "remove-rich-text-formats",
            "version": 1,
            "title": "Remove Formatting Buttons From the Block Toolbar",
            "description": "Removes rarely needed formatting options, such as text color highlight, inline image, keyboard input and language, from the rich text toolbar.",
            "category": "gutenberg",
            "tags": [
                "formatting",
                "toolbar",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Formats to remove from the toolbar.\n$wpide_removed_formats = array( 'core/text-color', 'core/image', 'core/keyboard', 'core/language' );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_removed_formats ) {\n    wp_add_inline_script(\n        'wp-rich-text',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){' . wp_json_encode( array_values( $wpide_removed_formats ) ) . '.forEach(function(name){if(wp.data.select(\"core/rich-text\").getFormatType(name)){wp.richText.unregisterFormatType(name);}});});});'\n    );\n} );",
            "requires": [],
            "notes": "Format names include core/text-color, core/image, core/keyboard, core/language, core/subscript, core/superscript, core/code and core/strikethrough."
        },
        {
            "id": "hide-blocks-by-device-classes",
            "version": 1,
            "title": "Hide Blocks on Mobile or Desktop With a CSS Class",
            "description": "Adds hide-on-mobile, hide-on-tablet and hide-on-desktop classes you can put in any block's \"Additional CSS class\" field to show it only on certain screen sizes.",
            "category": "gutenberg",
            "tags": [
                "responsive",
                "css",
                "blocks"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-width: 599px) {\n    .hide-on-mobile {\n        display: none !important;\n    }\n}\n\n@media (min-width: 600px) and (max-width: 1023px) {\n    .hide-on-tablet {\n        display: none !important;\n    }\n}\n\n@media (min-width: 1024px) {\n    .hide-on-desktop {\n        display: none !important;\n    }\n}",
            "requires": [],
            "notes": "Breakpoints are 600px and 1024px; change them to match your theme."
        },
        {
            "id": "default-editor-preferences",
            "version": 1,
            "title": "Default Block Editor Preferences for Everyone",
            "description": "Sets sensible defaults for every user of the block editor, such as the top toolbar, the List View opened and button text labels, while each person can still change them.",
            "category": "gutenberg",
            "tags": [
                "editor",
                "preferences",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Editor preferences (true or false).\n$wpide_editor_defaults = array(\n    'fixedToolbar'          => true,\n    'showListViewByDefault' => true,\n    'showIconLabels'        => false,\n    'showBlockBreadcrumbs'  => true,\n);\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_editor_defaults ) {\n    wp_add_inline_script(\n        'wp-preferences',\n        'wp.domReady(function(){(window._wpLoadBlockEditor||Promise.resolve()).then(function(){wp.data.dispatch(\"core/preferences\").setDefaults(\"core\",' . wp_json_encode( array_map( 'boolval', $wpide_editor_defaults ) ) . ');});});'\n    );\n} );",
            "requires": [],
            "notes": "Only defaults change; anything a user has already set in Preferences wins. Set a value to true or false to taste."
        },
        {
            "id": "button-hover-effects-scss",
            "version": 1,
            "title": "Button Block Hover Effects (SCSS)",
            "description": "Adds a smooth lift, shadow and color shift when visitors hover over or focus Button blocks, with motion turned off for people who prefer reduced motion.",
            "category": "gutenberg",
            "tags": [
                "scss",
                "buttons",
                "hover"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$lift: 2px;\n$shadow: 0 6px 16px rgba(0, 0, 0, 0.18);\n$speed: 0.2s;\n\n.wp-block-button__link,\n.wp-element-button {\n    transition: transform $speed ease, box-shadow $speed ease, filter $speed ease;\n\n    &:hover,\n    &:focus-visible {\n        transform: translateY(-$lift);\n        box-shadow: $shadow;\n        filter: brightness(1.08);\n    }\n\n    &:active {\n        transform: translateY(0);\n        box-shadow: none;\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wp-block-button__link,\n    .wp-element-button {\n        transition: none;\n\n        &:hover,\n        &:focus-visible {\n            transform: none;\n        }\n    }\n}",
            "requires": [],
            "notes": "Adjust the lift distance and shadow in the variables."
        },
        {
            "id": "site-details-block-bindings",
            "version": 1,
            "title": "Bind Blocks to Site Details (Block Bindings)",
            "description": "Registers a Block Bindings source so Paragraph, Heading and Button blocks can display the site name, tagline, URL or current year and stay up to date automatically.",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "blocks",
                "site-name"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/site-details', array(\n        'label'              => 'Site details',\n        'get_value_callback' => function ( $source_args ) {\n            $key = isset( $source_args['key'] ) ? $source_args['key'] : '';\n\n            switch ( $key ) {\n                case 'name':\n                    return esc_html( get_bloginfo( 'name' ) );\n                case 'tagline':\n                    return esc_html( get_bloginfo( 'description' ) );\n                case 'url':\n                    return esc_url( home_url( '/' ) );\n                case 'year':\n                    return esc_html( wp_date( 'Y' ) );\n            }\n\n            return null;\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Requires WordPress 6.5 or newer. In the code editor use for example {\"metadata\":{\"bindings\":{\"content\":{\"source\":\"wpide/site-details\",\"args\":{\"key\":\"year\"}}}}} on a Paragraph block. Keys are name, tagline, url and year."
        },
        {
            "id": "custom-field-for-block-bindings",
            "version": 1,
            "title": "Make a Custom Field Usable in Blocks",
            "description": "Registers a custom field (such as a subtitle) with the REST API so it can be connected to Paragraph or Heading blocks through Block Bindings and edited right in the block.",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "custom-fields",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type and custom field name.\n$wpide_bound_post_type = 'post';\n$wpide_bound_meta_key  = 'subtitle';\n\nadd_action( 'init', function () use ( $wpide_bound_post_type, $wpide_bound_meta_key ) {\n    register_post_meta( $wpide_bound_post_type, $wpide_bound_meta_key, array(\n        'type'              => 'string',\n        'single'            => true,\n        'show_in_rest'      => true,\n        'default'           => '',\n        'sanitize_callback' => 'sanitize_text_field',\n        'label'             => ucfirst( str_replace( array( '-', '_' ), ' ', $wpide_bound_meta_key ) ),\n        'auth_callback'     => function ( $allowed, $meta_key, $post_id ) {\n            return current_user_can( 'edit_post', $post_id );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Requires WordPress 6.5 or newer. Bind it with {\"metadata\":{\"bindings\":{\"content\":{\"source\":\"core/post-meta\",\"args\":{\"key\":\"subtitle\"}}}}}. Change the post type and field name at the top."
        },
        {
            "id": "details-block-accordion-scss",
            "version": 1,
            "title": "Accordion Style for the Details Block (SCSS)",
            "description": "Styles the core Details block as a polished accordion with borders, a rotating plus icon and comfortable spacing, so FAQs look finished without a plugin.",
            "category": "gutenberg",
            "tags": [
                "scss",
                "details",
                "accordion"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$border: rgba(0, 0, 0, 0.15);\n$accent: #2271b1;\n\n.wp-block-details {\n    margin: 0;\n    padding: 0;\n    border: 1px solid $border;\n\n    & + .wp-block-details {\n        border-top: 0;\n    }\n\n    summary {\n        position: relative;\n        padding: 1em 3em 1em 1.25em;\n        font-weight: 600;\n        list-style: none;\n        cursor: pointer;\n\n        &::-webkit-details-marker {\n            display: none;\n        }\n\n        &::after {\n            content: \"+\";\n            position: absolute;\n            top: 50%;\n            right: 1.25em;\n            font-size: 1.4em;\n            line-height: 1;\n            transform: translateY(-50%);\n            transition: transform 0.2s ease;\n        }\n\n        &:focus-visible {\n            outline: 2px solid $accent;\n            outline-offset: -2px;\n        }\n    }\n\n    &[open] summary::after {\n        transform: translateY(-50%) rotate(45deg);\n    }\n\n    > :not(summary) {\n        margin: 0;\n        padding: 0 1.25em 1em;\n    }\n}",
            "requires": [],
            "notes": "Consecutive Details blocks join into one accordion list."
        },
        {
            "id": "utility-classes-scss",
            "version": 1,
            "title": "Utility Classes for Blocks (SCSS)",
            "description": "Adds a small set of classes, such as has-shadow, is-rounded, has-hover-lift and text-balance, that editors can type into any block's \"Additional CSS class\" field.",
            "category": "gutenberg",
            "tags": [
                "scss",
                "utilities",
                "blocks"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$radius: 10px;\n$shadow: 0 6px 24px rgba(0, 0, 0, 0.1);\n\n.has-shadow {\n    box-shadow: $shadow;\n}\n\n.is-rounded,\n.is-rounded img {\n    overflow: hidden;\n    border-radius: $radius;\n}\n\n.has-hover-lift {\n    transition: transform 0.2s ease, box-shadow 0.2s ease;\n\n    &:hover {\n        transform: translateY(-4px);\n        box-shadow: $shadow;\n    }\n}\n\n.text-balance {\n    text-wrap: balance;\n}\n\n.is-sticky-top {\n    position: sticky;\n    top: 1.5rem;\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .has-hover-lift {\n        transition: none;\n\n        &:hover {\n            transform: none;\n        }\n    }\n}",
            "requires": [],
            "notes": "Add more utilities in the same style. The class names have no prefix so editors can remember them easily."
        },
        {
            "id": "legal-disclaimer-blocks-after-content",
            "version": 1,
            "title": "Legal Disclaimer After Content (Blocks)",
            "description": "Adds a small, separated disclaimer built from blocks at the end of posts and pages, for example a \"not financial or medical advice\" notice.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "disclaimer",
                "legal"
            ],
            "code_type": "blocks",
            "insert_method": "auto",
            "location": "after_content",
            "insert_number": 1,
            "code": "<!-- wp:separator {\"style\":{\"spacing\":{\"margin\":{\"top\":\"2.5em\",\"bottom\":\"1em\"}}},\"className\":\"is-style-wide\"} -->\n<hr class=\"wp-block-separator has-alpha-channel-opacity is-style-wide\" style=\"margin-top:2.5em;margin-bottom:1em\"/>\n<!-- /wp:separator -->\n\n<!-- wp:paragraph {\"style\":{\"typography\":{\"fontSize\":\"0.8em\"},\"color\":{\"text\":\"#646970\"}}} -->\n<p class=\"has-text-color\" style=\"color:#646970;font-size:0.8em\"><strong>Disclaimer:</strong> This content is for general information only and is not professional advice. Please consult a qualified professional before making decisions based on it.</p>\n<!-- /wp:paragraph -->",
            "requires": [],
            "notes": "Edit the wording in the snippet editor and use conditions to limit it to certain categories if needed."
        },
        {
            "id": "force-https-redirect",
            "version": 1,
            "title": "Force HTTPS on the Front End",
            "description": "Sends visitors who open a page over plain HTTP to the secure HTTPS address with a permanent redirect, once your site URL is set to https.",
            "category": "redirects",
            "tags": [
                "https",
                "ssl",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_ssl() || 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) {\n        return;\n    }\n\n    $forwarded = isset( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_PROTO'] ) ) ) : '';\n    $visitor   = isset( $_SERVER['HTTP_CF_VISITOR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_VISITOR'] ) ) : '';\n\n    if ( false !== strpos( $forwarded, 'https' ) || false !== strpos( $visitor, 'https' ) ) {\n        return;\n    }\n\n    $host    = wp_parse_url( home_url(), PHP_URL_HOST );\n    $port    = wp_parse_url( home_url(), PHP_URL_PORT );\n    $request = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';\n\n    wp_safe_redirect( 'https://' . $host . ( $port ? ':' . $port : '' ) . $request, 301 );\n    exit;\n}, 0 );",
            "requires": [],
            "notes": "Only runs when the Site Address already starts with https. Proxies and CDNs that report HTTPS through standard headers are detected, so it does not loop behind Cloudflare or a load balancer."
        },
        {
            "id": "www-host-consistency",
            "version": 1,
            "title": "Redirect to the www or Non-www Version of Your Domain",
            "description": "Sends visitors who arrive on the other version of your domain (with or without www) to the one set as your Site Address, so search engines see a single site.",
            "category": "redirects",
            "tags": [
                "www",
                "domain",
                "canonical"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    $home_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n    $request   = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : '';\n    $request   = preg_replace( '/:\\d+$/', '', $request );\n\n    if ( '' === $request || $request === $home_host ) {\n        return;\n    }\n\n    $bare_home    = preg_replace( '/^www\\./', '', $home_host );\n    $bare_request = preg_replace( '/^www\\./', '', $request );\n\n    if ( $bare_home !== $bare_request ) {\n        return;\n    }\n\n    $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME );\n    $uri    = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';\n\n    wp_safe_redirect( $scheme . '://' . $home_host . $uri, 301 );\n    exit;\n}, 0 );",
            "requires": [],
            "notes": "Your host must point both versions to this site. A redirect at the server or DNS level is faster when available."
        },
        {
            "id": "lowercase-url-redirect",
            "version": 1,
            "title": "Redirect URLs With Capital Letters to Lowercase",
            "description": "Permanently redirects addresses typed or linked with capital letters, such as /About-Us/, to their lowercase form, avoiding duplicate URLs and broken links.",
            "category": "redirects",
            "tags": [
                "urls",
                "lowercase",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    $uri  = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';\n    $path = (string) wp_parse_url( $uri, PHP_URL_PATH );\n\n    if ( '' === $path || ! preg_match( '/[A-Z]/', preg_replace( '/%[0-9A-Fa-f]{2}/', '', $path ) ) ) {\n        return;\n    }\n\n    $lower = preg_replace_callback( '/(%[0-9A-Fa-f]{2})|([A-Z]+)/', function ( $m ) {\n        return '' !== $m[1] ? $m[1] : strtolower( $m[2] );\n    }, $path );\n\n    if ( null === $lower || $lower === $path ) {\n        return;\n    }\n\n    $query  = (string) wp_parse_url( $uri, PHP_URL_QUERY );\n    $scheme = wp_parse_url( home_url(), PHP_URL_SCHEME );\n    $host   = wp_parse_url( home_url(), PHP_URL_HOST );\n    $port   = wp_parse_url( home_url(), PHP_URL_PORT );\n\n    wp_safe_redirect( $scheme . '://' . $host . ( $port ? ':' . $port : '' ) . $lower . ( '' !== $query ? '?' . $query : '' ), 301 );\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "Encoded characters such as %C3%A9 are left as they are. Query strings are kept unchanged."
        },
        {
            "id": "redirect-post-type-singles-to-archive",
            "version": 1,
            "title": "Send Single Entries of a Post Type to Its Archive",
            "description": "Redirects the single pages of post types that only make sense in a list, such as testimonials, FAQs or team members, to their archive or another page.",
            "category": "redirects",
            "tags": [
                "post-types",
                "redirect",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types whose single pages redirect.\n$wpide_list_only_types = array( 'testimonial' );\n// Optional fixed target path, such as '/reviews/'. Leave empty to use the archive.\n$wpide_list_only_target = '';\n\nadd_action( 'template_redirect', function () use ( $wpide_list_only_types, $wpide_list_only_target ) {\n    if ( ! is_singular( $wpide_list_only_types ) ) {\n        return;\n    }\n\n    if ( '' !== $wpide_list_only_target ) {\n        $target = home_url( $wpide_list_only_target );\n    } else {\n        $archive = get_post_type_archive_link( get_post_type() );\n        $target  = $archive ? $archive : home_url( '/' );\n    }\n\n    wp_safe_redirect( $target, 301 );\n    exit;\n} );",
            "requires": [],
            "notes": "Set the post types and, optionally, a fixed target path. Without a target, the post type archive (or the homepage) is used."
        },
        {
            "id": "empty-parent-page-to-first-child",
            "version": 1,
            "title": "Send Empty Parent Pages to Their First Child Page",
            "description": "When a parent page has no content of its own (it only groups subpages in the menu), visitors who open it are sent to its first child page instead of a blank page.",
            "category": "redirects",
            "tags": [
                "pages",
                "redirect",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_page() || is_front_page() ) {\n        return;\n    }\n\n    $page = get_queried_object();\n\n    if ( ! $page instanceof WP_Post || '' !== trim( $page->post_content ) ) {\n        return;\n    }\n\n    $children = get_posts( array(\n        'post_type'   => 'page',\n        'post_parent' => $page->ID,\n        'post_status' => 'publish',\n        'orderby'     => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n        'numberposts' => 1,\n    ) );\n\n    if ( $children ) {\n        wp_safe_redirect( get_permalink( $children[0] ), 302 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Only pages whose content is completely empty are redirected; the first child is chosen by menu order, then title. The redirect is temporary, so adding content later works right away."
        },
        {
            "id": "wildcard-redirects",
            "version": 1,
            "title": "Wildcard Redirects for Moved Sections",
            "description": "Redirects whole groups of old URLs with simple * patterns, for example everything under /old-blog/ to the same address under /blog/, without a redirect plugin.",
            "category": "redirects",
            "tags": [
                "redirects",
                "wildcard",
                "migration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Pattern => target. * matches anything; $1, $2 ... insert the matched text. Examples:\n// '/old-blog/*'         => '/blog/$1',\n// '/products/*/manual/' => '/docs/$1/',\n$wpide_wildcard_redirects = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_wildcard_redirects ) {\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n\n    if ( '' === $path ) {\n        return;\n    }\n\n    foreach ( $wpide_wildcard_redirects as $pattern => $target ) {\n        $regex = '#^' . str_replace( '\\*', '(.*?)', preg_quote( $pattern, '#' ) ) . '$#i';\n\n        if ( ! preg_match( $regex, $path, $matches ) ) {\n            continue;\n        }\n\n        $url = preg_replace_callback( '/\\$(\\d+)/', function ( $ref ) use ( $matches ) {\n            return isset( $matches[ (int) $ref[1] ] ) ? $matches[ (int) $ref[1] ] : '';\n        }, $target );\n\n        $url       = preg_match( '#^https?://#i', $url ) ? $url : home_url( $url );\n        $same_host = ! wp_parse_url( $url, PHP_URL_HOST ) || wp_parse_url( $url, PHP_URL_HOST ) === wp_parse_url( home_url(), PHP_URL_HOST );\n\n        if ( $same_host && untrailingslashit( (string) wp_parse_url( $url, PHP_URL_PATH ) ) === untrailingslashit( $path ) ) {\n            return;\n        }\n\n        wp_safe_redirect( $url, 301 );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "The rule list is empty by default; add your own using the examples. Use * for any text and $1, $2 in the target for what each * matched. On a subdirectory install include the subdirectory in the patterns. Redirects to the same address are skipped, so a rule cannot loop."
        },
        {
            "id": "redirect-404-to-search",
            "version": 1,
            "title": "Turn 404 Errors Into Search Results",
            "description": "Sends visitors who hit a missing page to a site search for the words in the broken address, so they land on likely matches instead of a dead end.",
            "category": "redirects",
            "tags": [
                "404",
                "search",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n\n    if ( '' === $path || preg_match( '/\\.[a-z0-9]{2,5}$/i', $path ) ) {\n        return;\n    }\n\n    $segments = array_filter( explode( '/', trim( rawurldecode( $path ), '/' ) ) );\n    $last     = $segments ? end( $segments ) : '';\n    $words    = trim( preg_replace( '/[^\\p{L}\\p{N}]+/u', ' ', (string) $last ) );\n\n    if ( '' === $words || is_numeric( $words ) ) {\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 's', rawurlencode( $words ), home_url( '/' ) ), 302 );\n    exit;\n} );",
            "requires": [],
            "notes": "Missing files such as images, scripts and PDFs still return a normal 404."
        },
        {
            "id": "log-404-errors",
            "version": 1,
            "title": "Log 404 Errors Under Tools",
            "description": "Records the addresses visitors request that do not exist, with hit counts, last seen time and referring page, and lists them under Tools > 404 Log so you can fix or redirect them.",
            "category": "redirects",
            "tags": [
                "404",
                "logging",
                "broken-links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 0, 255 ) : '';\n\n    if ( '' === $path ) {\n        return;\n    }\n\n    $referrer = wp_get_raw_referer();\n    $log      = get_option( 'wpide_404_log', array() );\n    $log      = is_array( $log ) ? $log : array();\n    $entry    = isset( $log[ $path ] ) ? $log[ $path ] : array( 'count' => 0, 'last' => 0, 'referrer' => '' );\n\n    $entry['count']++;\n    $entry['last'] = time();\n\n    if ( $referrer ) {\n        $entry['referrer'] = substr( esc_url_raw( $referrer ), 0, 255 );\n    }\n\n    $log[ $path ] = $entry;\n\n    if ( count( $log ) > 200 ) {\n        uasort( $log, function ( $a, $b ) {\n            return $b['last'] <=> $a['last'];\n        } );\n        $log = array_slice( $log, 0, 200, true );\n    }\n\n    update_option( 'wpide_404_log', $log, false );\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( '404 Log', '404 Log', 'manage_options', 'wpide-404-log', function () {\n        if ( isset( $_POST['wpide_clear_404_log'] ) && check_admin_referer( 'wpide_clear_404_log' ) ) {\n            delete_option( 'wpide_404_log' );\n            echo '<div class=\"notice notice-success\"><p>The 404 log was cleared.</p></div>';\n        }\n\n        $log = get_option( 'wpide_404_log', array() );\n        $log = is_array( $log ) ? $log : array();\n\n        uasort( $log, function ( $a, $b ) {\n            return $b['count'] <=> $a['count'];\n        } );\n\n        echo '<div class=\"wrap\"><h1>404 Log</h1>';\n\n        if ( ! $log ) {\n            echo '<p>No missing pages recorded yet.</p></div>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>Address</th><th>Hits</th><th>Last seen</th><th>Referrer</th></tr></thead><tbody>';\n\n        foreach ( $log as $path => $entry ) {\n            echo '<tr><td><code>' . esc_html( $path ) . '</code></td><td>' . esc_html( number_format_i18n( (int) $entry['count'] ) ) . '</td><td>' . esc_html( human_time_diff( (int) $entry['last'] ) . ' ago' ) . '</td><td>' . ( $entry['referrer'] ? '<a href=\"' . esc_url( $entry['referrer'] ) . '\" target=\"_blank\" rel=\"noopener noreferrer\">' . esc_html( $entry['referrer'] ) . '</a>' : '&mdash;' ) . '</td></tr>';\n        }\n\n        echo '</tbody></table><form method=\"post\" style=\"margin-top:1em;\">';\n        wp_nonce_field( 'wpide_clear_404_log' );\n        echo '<button type=\"submit\" name=\"wpide_clear_404_log\" value=\"1\" class=\"button\">Clear log</button></form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Keeps the 200 most recent distinct addresses in one option. Clear the log from the same screen."
        },
        {
            "id": "logged-in-homepage-redirect",
            "version": 1,
            "title": "Send Logged-In Members From the Homepage to Their Page",
            "description": "Redirects logged-in users who visit the homepage to a members area, dashboard or account page, while guests still see the normal homepage.",
            "category": "redirects",
            "tags": [
                "members",
                "homepage",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Path members are sent to.\n$wpide_member_home = '/members/';\n// Users with this capability are not redirected.\n$wpide_member_home_skip = 'edit_posts';\n\nadd_action( 'template_redirect', function () use ( $wpide_member_home, $wpide_member_home_skip ) {\n    if ( ! is_front_page() || is_paged() || ! is_user_logged_in() || current_user_can( $wpide_member_home_skip ) ) {\n        return;\n    }\n\n    $target = home_url( $wpide_member_home );\n\n    // Only redirect to a page that exists, and never to the homepage itself.\n    if ( untrailingslashit( $target ) === untrailingslashit( home_url( '/' ) ) || ! url_to_postid( $target ) ) {\n        return;\n    }\n\n    wp_safe_redirect( $target, 302 );\n    exit;\n} );",
            "requires": [],
            "notes": "Nothing happens until the target page exists. Users with the capability in $wpide_member_home_skip (editors and up by default) still see the homepage."
        },
        {
            "id": "per-post-redirect-field",
            "version": 1,
            "title": "Redirect Field for Posts and Pages",
            "description": "Adds a \"Redirect to\" box to the post and page editor; when filled in, visitors who open that post are sent to the address you entered, on your site or elsewhere.",
            "category": "redirects",
            "tags": [
                "redirect",
                "posts",
                "meta-box"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function () {\n    add_meta_box( 'wpide-redirect', 'Redirect to', function ( $post ) {\n        wp_nonce_field( 'wpide_redirect_save', 'wpide_redirect_nonce' );\n        echo '<label class=\"screen-reader-text\" for=\"wpide-redirect-url\">Redirect to</label>';\n        echo '<input type=\"url\" class=\"widefat\" id=\"wpide-redirect-url\" name=\"wpide_redirect_url\" placeholder=\"https://\" value=\"' . esc_attr( get_post_meta( $post->ID, '_wpide_redirect_url', true ) ) . '\" />';\n        echo '<p class=\"description\">Visitors to this page are sent to this address. Leave empty to turn it off.</p>';\n    }, array( 'post', 'page' ), 'side', 'low' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_redirect_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wpide_redirect_nonce'] ) ), 'wpide_redirect_save' ) ) {\n        return;\n    }\n\n    if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $url = isset( $_POST['wpide_redirect_url'] ) ? esc_url_raw( wp_unslash( $_POST['wpide_redirect_url'] ), array( 'http', 'https' ) ) : '';\n\n    if ( '' === $url ) {\n        delete_post_meta( $post_id, '_wpide_redirect_url' );\n    } else {\n        update_post_meta( $post_id, '_wpide_redirect_url', $url );\n    }\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( ! is_singular() || is_preview() ) {\n        return;\n    }\n\n    $url = get_post_meta( get_queried_object_id(), '_wpide_redirect_url', true );\n\n    if ( ! $url || untrailingslashit( $url ) === untrailingslashit( (string) get_permalink( get_queried_object_id() ) ) ) {\n        return;\n    }\n\n    wp_redirect( esc_url_raw( $url ), 301, 'WordPress' ); // phpcs:ignore WordPress.Security.SafeRedirect -- the editor chose this address.\n    exit;\n} );",
            "requires": [],
            "notes": "Uses a permanent (301) redirect. Clear the field to switch it off. Only users who can edit the post can set it."
        },
        {
            "id": "require-login-for-selected-pages",
            "version": 1,
            "title": "Require Login for Selected Pages",
            "description": "Sends visitors who are not logged in to the login screen when they open the pages you list, and brings them back to the page after they log in.",
            "category": "redirects",
            "tags": [
                "login",
                "members",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IDs of the pages that need a login.\n$wpide_protected_page_ids = array( 0 );\n\nadd_action( 'template_redirect', function () use ( $wpide_protected_page_ids ) {\n    if ( is_user_logged_in() || ! is_singular() ) {\n        return;\n    }\n\n    if ( in_array( get_queried_object_id(), array_map( 'absint', $wpide_protected_page_ids ), true ) ) {\n        wp_safe_redirect( wp_login_url( get_permalink( get_queried_object_id() ) ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "List page or post IDs. Child pages are not included automatically."
        },
        {
            "id": "old-domain-redirect",
            "version": 1,
            "title": "Redirect an Old Domain to This Site",
            "description": "Permanently redirects every request that arrives on your previous domain to the same address on your current domain, after a rebrand or domain move.",
            "category": "redirects",
            "tags": [
                "domain",
                "migration",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Old host names that should redirect to the current site.\n$wpide_old_domains = array( 'old-example.com', 'www.old-example.com' );\n\nadd_action( 'template_redirect', function () use ( $wpide_old_domains ) {\n    $request = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : '';\n    $request = preg_replace( '/:\\d+$/', '', $request );\n    $home    = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n\n    if ( '' === $request || $request === $home || ! in_array( $request, array_map( 'strtolower', $wpide_old_domains ), true ) ) {\n        return;\n    }\n\n    $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/';\n\n    wp_safe_redirect( wp_parse_url( home_url(), PHP_URL_SCHEME ) . '://' . $home . $uri, 301 );\n    exit;\n}, 0 );",
            "requires": [],
            "notes": "The old domain must still point to this hosting account. List each old host name, including www versions."
        },
        {
            "id": "redirect-old-date-based-urls",
            "version": 1,
            "title": "Redirect Old Date-Based Post URLs",
            "description": "After switching from /2019/05/post-name/ style permalinks to plain post names, sends visitors and search engines from the old dated addresses to the right posts.",
            "category": "redirects",
            "tags": [
                "permalinks",
                "migration",
                "404"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n\n    if ( ! preg_match( '#/\\d{4}/\\d{1,2}/(?:\\d{1,2}/)?([^/]+)/?$#', $path, $match ) ) {\n        return;\n    }\n\n    $post = get_page_by_path( sanitize_title( rawurldecode( $match[1] ) ), OBJECT, 'post' );\n\n    if ( ! $post || 'publish' !== $post->post_status ) {\n        return;\n    }\n\n    $target = get_permalink( $post );\n\n    if ( untrailingslashit( (string) wp_parse_url( $target, PHP_URL_PATH ) ) !== untrailingslashit( $path ) ) {\n        wp_safe_redirect( $target, 301 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Only runs when the old address would otherwise be a 404."
        },
        {
            "id": "woo-skip-cart-to-checkout",
            "version": 1,
            "title": "Skip the Cart and Go Straight to Checkout",
            "description": "Sends shoppers who open the cart page directly to checkout whenever their cart has products, shortening the path to purchase.",
            "category": "redirects",
            "tags": [
                "woocommerce",
                "cart",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! function_exists( 'is_cart' ) || ! is_cart() || ! function_exists( 'WC' ) || ! WC()->cart || WC()->cart->is_empty() ) {\n        return;\n    }\n\n    wp_safe_redirect( wc_get_checkout_url() );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "An empty cart still shows the cart page with its \"return to shop\" message."
        },
        {
            "id": "woo-customers-to-shop-after-login",
            "version": 1,
            "title": "Send Customers to the Shop After Login",
            "description": "Takes customers to the shop page after they log in or register through WooCommerce, instead of the My Account dashboard.",
            "category": "redirects",
            "tags": [
                "woocommerce",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_customer_redirect = function ( $redirect, $user = null ) {\n    $user = $user instanceof WP_User ? $user : wp_get_current_user();\n\n    if ( ! $user || user_can( $user, 'edit_posts' ) || user_can( $user, 'manage_woocommerce' ) ) {\n        return $redirect;\n    }\n\n    $shop = wc_get_page_permalink( 'shop' );\n\n    return $shop ? $shop : $redirect;\n};\n\nadd_filter( 'woocommerce_login_redirect', $wpide_customer_redirect, 20, 2 );\nadd_filter( 'woocommerce_registration_redirect', $wpide_customer_redirect, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shop managers and administrators keep the default destination."
        },
        {
            "id": "private-posts-redirect-to-login",
            "version": 1,
            "title": "Send Visitors to Login for Private Posts",
            "description": "When a logged-out visitor opens a private post or page, they are sent to the login screen and back again, instead of seeing a \"not found\" error.",
            "category": "redirects",
            "tags": [
                "private",
                "login",
                "404"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( ! is_404() || is_user_logged_in() ) {\n        return;\n    }\n\n    $id   = (int) $wp_query->get( 'p' ) ? (int) $wp_query->get( 'p' ) : (int) $wp_query->get( 'page_id' );\n    $post = $id ? get_post( $id ) : null;\n\n    if ( ! $post ) {\n        $name = $wp_query->get( 'pagename' ) ? $wp_query->get( 'pagename' ) : $wp_query->get( 'name' );\n        $post = $name ? get_page_by_path( $name, OBJECT, array_values( get_post_types( array( 'public' => true ) ) ) ) : null;\n    }\n\n    if ( $post && 'private' === $post->post_status ) {\n        wp_safe_redirect( wp_login_url( get_permalink( $post ) ) );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "After logging in, users who are not allowed to read private content still see the 404 page."
        },
        {
            "id": "login-destination-by-role",
            "version": 1,
            "title": "Send Each Role to Its Own Page After Login",
            "description": "Chooses where people land after logging in based on their role, for example editors to the posts list, customers to their account and subscribers to a members page.",
            "category": "redirects",
            "tags": [
                "login",
                "roles",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role => destination. {admin} means the wp-admin address.\n$wpide_login_destinations = array(\n    'administrator' => '{admin}',\n    'editor'        => '{admin}edit.php',\n    'author'        => '{admin}edit.php',\n    'subscriber'    => '/',\n);\n\nadd_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) use ( $wpide_login_destinations ) {\n    if ( ! $user instanceof WP_User ) {\n        return $redirect_to;\n    }\n\n    if ( $requested && untrailingslashit( $requested ) !== untrailingslashit( admin_url() ) ) {\n        return $redirect_to;\n    }\n\n    foreach ( (array) $user->roles as $role ) {\n        if ( empty( $wpide_login_destinations[ $role ] ) ) {\n            continue;\n        }\n\n        $target = $wpide_login_destinations[ $role ];\n\n        return 0 === strpos( $target, '{admin}' ) ? admin_url( substr( $target, 7 ) ) : home_url( $target );\n    }\n\n    return $redirect_to;\n}, 20, 3 );",
            "requires": [],
            "notes": "Use {admin} at the start of a value for an admin page, or a path on your site. Explicit redirect_to links, such as from a protected page, are respected."
        },
        {
            "id": "woo-require-login-for-shop",
            "version": 1,
            "title": "Require Login to Browse the Shop",
            "description": "Sends visitors who are not logged in from the shop, product, cart and checkout pages to the My Account login form, for wholesale and members-only stores.",
            "category": "redirects",
            "tags": [
                "woocommerce",
                "b2b",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( is_user_logged_in() || ! function_exists( 'is_woocommerce' ) || is_account_page() ) {\n        return;\n    }\n\n    if ( is_woocommerce() || is_cart() || is_checkout() ) {\n        wp_safe_redirect( wc_get_page_permalink( 'myaccount' ) );\n        exit;\n    }\n} );\n\nadd_filter( 'woocommerce_login_redirect', function ( $redirect ) {\n    $shop = wc_get_page_permalink( 'shop' );\n\n    return $shop ? $shop : $redirect;\n}, 10 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The My Account page stays open so customers can log in or register. After logging in, customers go back to the shop."
        },
        {
            "id": "redirect-old-html-urls",
            "version": 1,
            "title": "Redirect Old .html Page Addresses",
            "description": "Sends visitors from addresses of a previous static site, such as /about.html or /services/web.php, to the WordPress page with the same name.",
            "category": "redirects",
            "tags": [
                "migration",
                "html",
                "404"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? rawurldecode( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) : '';\n\n    if ( ! preg_match( '#^/?(.*?)([^/]+)\\.(?:html?|php|aspx?)$#i', $path, $match ) ) {\n        return;\n    }\n\n    if ( 'index' === strtolower( $match[2] ) && '' === trim( $match[1], '/' ) ) {\n        wp_safe_redirect( home_url( '/' ), 301 );\n        exit;\n    }\n\n    $post = get_page_by_path( trim( $match[1] . $match[2], '/' ), OBJECT, array( 'page', 'post' ) );\n\n    if ( ! $post ) {\n        $post = get_page_by_path( sanitize_title( $match[2] ), OBJECT, array( 'page', 'post' ) );\n    }\n\n    if ( $post && 'publish' === $post->post_status ) {\n        wp_safe_redirect( get_permalink( $post ), 301 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Only runs when the old address would otherwise be a 404. /index.html goes to the homepage."
        },
        {
            "id": "redirect-category-to-page",
            "version": 1,
            "title": "Redirect a Category Archive to a Page",
            "description": "Sends visitors from a category archive to a hand-built landing page, for example /category/news/ to your Newsroom page, while posts keep their category.",
            "category": "redirects",
            "tags": [
                "categories",
                "redirect",
                "landing-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slug => page path, for example 'news' => '/newsroom/'.\n$wpide_category_redirects = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_category_redirects ) {\n    if ( ! is_category() || is_feed() ) {\n        return;\n    }\n\n    $term = get_queried_object();\n\n    if ( $term instanceof WP_Term && isset( $wpide_category_redirects[ $term->slug ] ) ) {\n        wp_safe_redirect( home_url( $wpide_category_redirects[ $term->slug ] ), 301 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "The list is empty by default. Map category slugs to paths of pages that exist on your site."
        },
        {
            "id": "temporary-homepage-redirect",
            "version": 1,
            "title": "Temporary Homepage Redirect Between Two Dates",
            "description": "Sends homepage visitors to a promotion or event page during a date range you set, such as a Black Friday sale, then switches back automatically.",
            "category": "redirects",
            "tags": [
                "promotion",
                "homepage",
                "schedule"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Promotion period (YYYY-MM-DD HH:MM) and target page, for example '/black-friday/'.\n$wpide_promo_from   = '';\n$wpide_promo_until  = '';\n$wpide_promo_target = '';\n\nadd_action( 'template_redirect', function () use ( $wpide_promo_from, $wpide_promo_until, $wpide_promo_target ) {\n    if ( ! is_front_page() || current_user_can( 'edit_posts' ) || '' === $wpide_promo_from || '' === $wpide_promo_until || '' === $wpide_promo_target ) {\n        return;\n    }\n\n    $from  = date_create( $wpide_promo_from, wp_timezone() );\n    $until = date_create( $wpide_promo_until, wp_timezone() );\n\n    if ( ! $from || ! $until || time() < $from->getTimestamp() || time() > $until->getTimestamp() ) {\n        return;\n    }\n\n    $target = home_url( $wpide_promo_target );\n\n    if ( untrailingslashit( $target ) !== untrailingslashit( home_url( '/' ) ) ) {\n        wp_safe_redirect( $target, 302 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Fill in both dates and the target first; nothing happens while they are empty. Dates use the site time zone, editors are not redirected, and page caching may delay the switch."
        },
        {
            "id": "analytics-consent-banner",
            "version": 1,
            "title": "Cookie Consent Banner With Google Consent Mode",
            "description": "Shows a simple Accept / Reject cookie banner, sets Google Consent Mode v2 defaults to \"denied\" until the visitor accepts, and remembers the choice for six months.",
            "category": "analytics",
            "tags": [
                "consent",
                "cookies",
                "gdpr",
                "google"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_consent = array(\n    'text'       => 'We use cookies to understand how our site is used and to improve your experience.',\n    'policy_url' => get_privacy_policy_url(), // Link to your privacy policy.\n    'days'       => 180,                       // How long the choice is remembered.\n);\n\nadd_action( 'wp_head', function () {\n    ?>\n<script>\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\n(function () {\n    var granted = /(?:^|; )wpide_consent=granted/.test(document.cookie);\n    var state = granted ? 'granted' : 'denied';\n    gtag('consent', 'default', { ad_storage: state, ad_user_data: state, ad_personalization: state, analytics_storage: state, wait_for_update: 500 });\n})();\n</script>\n    <?php\n}, 1 );\n\nadd_action( 'wp_footer', function () use ( $wpide_consent ) {\n    ?>\n<div id=\"wpide-consent\" role=\"dialog\" aria-live=\"polite\" aria-label=\"Cookie consent\" hidden style=\"position:fixed;left:16px;right:16px;bottom:16px;z-index:99999;max-width:640px;margin:0 auto;padding:16px 20px;border-radius:8px;background:#1e1e1e;color:#fff;box-shadow:0 4px 20px rgba(0,0,0,.25);font-size:14px;line-height:1.5;\">\n    <p style=\"margin:0 0 12px;\"><?php echo esc_html( $wpide_consent['text'] ); ?>\n    <?php if ( $wpide_consent['policy_url'] ) : ?>\n        <a href=\"<?php echo esc_url( $wpide_consent['policy_url'] ); ?>\" style=\"color:#fff;text-decoration:underline;\">Privacy policy</a>\n    <?php endif; ?></p>\n    <button type=\"button\" data-consent=\"granted\" style=\"margin-right:8px;padding:8px 16px;border:0;border-radius:4px;background:#fff;color:#1e1e1e;cursor:pointer;\">Accept</button>\n    <button type=\"button\" data-consent=\"denied\" style=\"padding:8px 16px;border:1px solid #fff;border-radius:4px;background:transparent;color:#fff;cursor:pointer;\">Reject</button>\n</div>\n<script>\n(function () {\n    var box = document.getElementById('wpide-consent');\n    if (!box) { return; }\n    if (!/(?:^|; )wpide_consent=/.test(document.cookie)) { box.hidden = false; }\n    box.addEventListener('click', function (e) {\n        var choice = e.target.getAttribute('data-consent');\n        if (!choice) { return; }\n        var days = <?php echo (int) $wpide_consent['days']; ?>;\n        document.cookie = 'wpide_consent=' + choice + ';path=/;max-age=' + (days * 86400) + ';SameSite=Lax' + (location.protocol === 'https:' ? ';Secure' : '');\n        if (typeof gtag === 'function') {\n            gtag('consent', 'update', { ad_storage: choice, ad_user_data: choice, ad_personalization: choice, analytics_storage: choice });\n        }\n        document.dispatchEvent(new CustomEvent('wpide:consent', { detail: choice }));\n        box.hidden = true;\n    });\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "A basic banner, not a full consent platform; WPIDE pixel snippets wait for it when their $require_consent is set to true."
        },
        {
            "id": "analytics-consent-mode-defaults",
            "version": 1,
            "title": "Google Consent Mode v2 Default Settings",
            "description": "Sets Google Consent Mode defaults before any Google tag loads, denying ad and analytics storage for visitors in the EEA, UK and Switzerland until your consent tool grants it.",
            "category": "analytics",
            "tags": [
                "consent",
                "google",
                "gdpr",
                "gtag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    // Regions where storage is denied by default (ISO country codes). Empty array = everywhere.\n    $regions = array( 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IS', 'IE', 'IT', 'LV', 'LI', 'LT', 'LU', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB', 'CH' );\n\n    $denied = array(\n        'ad_storage'         => 'denied',\n        'ad_user_data'       => 'denied',\n        'ad_personalization' => 'denied',\n        'analytics_storage'  => 'denied',\n        'wait_for_update'    => 500,\n    );\n\n    if ( $regions ) {\n        $denied['region'] = $regions;\n    }\n\n    echo \"<script>\\nwindow.dataLayer = window.dataLayer || [];\\nfunction gtag(){dataLayer.push(arguments);}\\n\";\n\n    if ( $regions ) {\n        echo \"gtag('consent', 'default', { ad_storage: 'granted', ad_user_data: 'granted', ad_personalization: 'granted', analytics_storage: 'granted' });\\n\";\n    }\n\n    echo 'gtag(\\'consent\\', \\'default\\', ' . wp_json_encode( $denied ) . \");\\ngtag('set', 'ads_data_redaction', true);\\n</script>\\n\";\n}, 1 );",
            "requires": [],
            "notes": "Your cookie banner must send the matching gtag('consent', 'update', ...) call. Edit $regions to apply the denied default elsewhere."
        },
        {
            "id": "analytics-respect-gpc",
            "version": 1,
            "title": "Honor Global Privacy Control Signals",
            "description": "Detects visitors whose browser sends a Global Privacy Control (GPC) or Do Not Track signal and updates Google Consent Mode to deny ad storage and personalization for them.",
            "category": "analytics",
            "tags": [
                "privacy",
                "consent",
                "gpc",
                "google"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $deny_analytics = false; // Also deny analytics storage for GPC/DNT visitors.\n\n    $update = array(\n        'ad_storage'         => 'denied',\n        'ad_user_data'       => 'denied',\n        'ad_personalization' => 'denied',\n    );\n\n    if ( $deny_analytics ) {\n        $update['analytics_storage'] = 'denied';\n    }\n    ?>\n<script>\n(function () {\n    var optOut = navigator.globalPrivacyControl === true || navigator.doNotTrack === '1' || window.doNotTrack === '1';\n    if (!optOut) { return; }\n    window.dataLayer = window.dataLayer || [];\n    function gtag(){dataLayer.push(arguments);}\n    gtag('consent', 'update', <?php echo wp_json_encode( $update ); ?>);\n    window.wpideOptOut = true;\n})();\n</script>\n    <?php\n}, 2 );",
            "requires": [],
            "notes": "Analytics storage is left to your consent banner. Set $deny_analytics to true to deny it too for these visitors."
        },
        {
            "id": "analytics-ga4-woo-purchase",
            "version": 1,
            "title": "GA4 Purchase Event for WooCommerce Orders",
            "description": "Sends a Google Analytics 4 \"purchase\" event with the order ID, value, tax, shipping and items when a customer reaches the order received page, once per order.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "woocommerce",
                "ecommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_thankyou', function ( $order_id ) {\n    $order = wc_get_order( $order_id );\n\n    if ( ! $order || $order->get_meta( '_wpide_ga4_purchase_sent' ) ) {\n        return;\n    }\n\n    $items = array();\n\n    foreach ( $order->get_items() as $item ) {\n        $product = $item->get_product();\n        $qty     = max( 1, (int) $item->get_quantity() );\n        $items[] = array(\n            'item_id'   => $product && $product->get_sku() ? $product->get_sku() : (string) $item->get_product_id(),\n            'item_name' => $item->get_name(),\n            'price'     => round( (float) $order->get_item_total( $item, false ), 2 ),\n            'quantity'  => $qty,\n        );\n    }\n\n    $data = array(\n        'transaction_id' => (string) $order->get_order_number(),\n        'value'          => round( (float) $order->get_total(), 2 ),\n        'tax'            => round( (float) $order->get_total_tax(), 2 ),\n        'shipping'       => round( (float) $order->get_shipping_total(), 2 ),\n        'currency'       => $order->get_currency(),\n        'coupon'         => implode( ',', $order->get_coupon_codes() ),\n        'items'          => $items,\n    );\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('event', 'purchase', \" . wp_json_encode( $data ) . \");</script>\\n\";\n\n    $order->update_meta_data( '_wpide_ga4_purchase_sent', time() );\n    $order->save();\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the GA4 tag (gtag.js) on the page, for example from the Google Analytics 4 snippet. Reloading the thank-you page does not send the event again."
        },
        {
            "id": "analytics-ga4-woo-product-events",
            "version": 1,
            "title": "GA4 view_item and add_to_cart Events for WooCommerce",
            "description": "Sends GA4 ecommerce events when a shopper views a product page and when they add a product to the cart, from the product page or with AJAX buttons on shop pages.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "woocommerce",
                "ecommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_product' ) ) {\n        return;\n    }\n\n    $currency = get_woocommerce_currency();\n    $view     = null;\n\n    if ( is_product() ) {\n        $product = wc_get_product( get_queried_object_id() );\n\n        if ( $product ) {\n            $view = array(\n                'currency' => $currency,\n                'value'    => round( (float) wc_get_price_to_display( $product ), 2 ),\n                'items'    => array(\n                    array(\n                        'item_id'   => $product->get_sku() ? $product->get_sku() : (string) $product->get_id(),\n                        'item_name' => $product->get_name(),\n                        'price'     => round( (float) wc_get_price_to_display( $product ), 2 ),\n                    ),\n                ),\n            );\n        }\n    }\n    ?>\n<script>\n(function () {\n    window.dataLayer = window.dataLayer || [];\n    function gtag(){dataLayer.push(arguments);}\n    var currency = <?php echo wp_json_encode( $currency ); ?>;\n    var view = <?php echo wp_json_encode( $view ); ?>;\n    if (view) {\n        gtag('event', 'view_item', view);\n        var form = document.querySelector('form.cart');\n        if (form) {\n            form.addEventListener('submit', function () {\n                var qty = parseInt((form.querySelector('input.qty') || {}).value, 10) || 1;\n                var item = Object.assign({}, view.items[0], { quantity: qty });\n                gtag('event', 'add_to_cart', { currency: currency, value: item.price * qty, items: [item] });\n            });\n        }\n    }\n    if (window.jQuery) {\n        jQuery(document.body).on('added_to_cart', function (e, fragments, hash, button) {\n            if (!button || !button.length) { return; }\n            var b = button[0];\n            gtag('event', 'add_to_cart', { currency: currency, items: [{ item_id: b.getAttribute('data-product_sku') || b.getAttribute('data-product_id'), item_name: b.getAttribute('aria-label') || '', quantity: parseInt(b.getAttribute('data-quantity'), 10) || 1 }] });\n        });\n    }\n})();\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the GA4 tag (gtag.js) on the page. Prices are sent as displayed (including or excluding tax per your store settings)."
        },
        {
            "id": "analytics-ga4-woo-begin-checkout",
            "version": 1,
            "title": "GA4 begin_checkout Event for WooCommerce",
            "description": "Sends a GA4 \"begin_checkout\" event with the cart value and items when the customer opens the checkout page.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "woocommerce",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_checkout' ) || ! is_checkout() || is_wc_endpoint_url() || ! WC()->cart || WC()->cart->is_empty() ) {\n        return;\n    }\n\n    $items = array();\n\n    foreach ( WC()->cart->get_cart() as $line ) {\n        $product = $line['data'];\n        $items[] = array(\n            'item_id'   => $product->get_sku() ? $product->get_sku() : (string) $product->get_id(),\n            'item_name' => $product->get_name(),\n            'price'     => round( (float) wc_get_price_to_display( $product ), 2 ),\n            'quantity'  => (int) $line['quantity'],\n        );\n    }\n\n    $data = array(\n        'currency' => get_woocommerce_currency(),\n        'value'    => round( (float) WC()->cart->get_cart_contents_total() + (float) WC()->cart->get_cart_contents_tax(), 2 ),\n        'coupon'   => implode( ',', WC()->cart->get_applied_coupons() ),\n        'items'    => $items,\n    );\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('event', 'begin_checkout', \" . wp_json_encode( $data ) . \");</script>\\n\";\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the GA4 tag (gtag.js) on the page."
        },
        {
            "id": "analytics-gtm-woo-purchase-datalayer",
            "version": 1,
            "title": "WooCommerce Purchase Data in the GTM dataLayer",
            "description": "Pushes a GA4-style \"purchase\" ecommerce object to the Google Tag Manager dataLayer on the order received page, ready for GA4, Google Ads or Meta tags in GTM.",
            "category": "analytics",
            "tags": [
                "google-tag-manager",
                "datalayer",
                "woocommerce",
                "ecommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_thankyou', function ( $order_id ) {\n    $order = wc_get_order( $order_id );\n\n    if ( ! $order || $order->get_meta( '_wpide_gtm_purchase_sent' ) ) {\n        return;\n    }\n\n    $items = array();\n\n    foreach ( $order->get_items() as $item ) {\n        $product = $item->get_product();\n        $cats    = $product ? wc_get_product_terms( $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(), 'product_cat', array( 'fields' => 'names' ) ) : array();\n        $items[] = array(\n            'item_id'       => $product && $product->get_sku() ? $product->get_sku() : (string) $item->get_product_id(),\n            'item_name'     => $item->get_name(),\n            'item_category' => $cats ? $cats[0] : '',\n            'price'         => round( (float) $order->get_item_total( $item, false ), 2 ),\n            'quantity'      => (int) $item->get_quantity(),\n        );\n    }\n\n    $push = array(\n        'event'     => 'purchase',\n        'ecommerce' => array(\n            'transaction_id' => (string) $order->get_order_number(),\n            'value'          => round( (float) $order->get_total(), 2 ),\n            'tax'            => round( (float) $order->get_total_tax(), 2 ),\n            'shipping'       => round( (float) $order->get_shipping_total(), 2 ),\n            'currency'       => $order->get_currency(),\n            'coupon'         => implode( ',', $order->get_coupon_codes() ),\n            'items'          => $items,\n        ),\n    );\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; window.dataLayer.push({ ecommerce: null }); window.dataLayer.push(\" . wp_json_encode( $push ) . \");</script>\\n\";\n\n    $order->update_meta_data( '_wpide_gtm_purchase_sent', time() );\n    $order->save();\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs your GTM container on the page; create a Custom Event trigger for \"purchase\" in GTM. Sent once per order."
        },
        {
            "id": "analytics-meta-pixel-woo-purchase",
            "version": 1,
            "title": "Meta Pixel Purchase Event for WooCommerce",
            "description": "Fires the Meta (Facebook) Pixel \"Purchase\" event with the order value, currency and product IDs on the order received page, once per order.",
            "category": "analytics",
            "tags": [
                "meta-pixel",
                "facebook",
                "woocommerce",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_thankyou', function ( $order_id ) {\n    $order = wc_get_order( $order_id );\n\n    if ( ! $order || $order->get_meta( '_wpide_meta_purchase_sent' ) ) {\n        return;\n    }\n\n    $ids = array();\n\n    foreach ( $order->get_items() as $item ) {\n        $ids[] = (string) ( $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id() );\n    }\n\n    $data = array(\n        'value'        => round( (float) $order->get_total(), 2 ),\n        'currency'     => $order->get_currency(),\n        'content_ids'  => $ids,\n        'content_type' => 'product',\n        'num_items'    => (int) $order->get_item_count(),\n    );\n\n    printf(\n        \"<script>if (typeof fbq === 'function') { fbq('track', 'Purchase', %s, { eventID: %s }); }</script>\\n\",\n        wp_json_encode( $data ),\n        wp_json_encode( 'order-' . $order->get_order_key() )\n    );\n\n    $order->update_meta_data( '_wpide_meta_purchase_sent', time() );\n    $order->save();\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the Meta Pixel base code on the page. The order key is sent as event ID so you can deduplicate with the Conversions API."
        },
        {
            "id": "analytics-meta-pixel-woo-browse",
            "version": 1,
            "title": "Meta Pixel ViewContent and AddToCart for WooCommerce",
            "description": "Sends Meta (Facebook) Pixel \"ViewContent\" on product pages and \"AddToCart\" when products are added, so you can build retargeting audiences and optimize ads.",
            "category": "analytics",
            "tags": [
                "meta-pixel",
                "facebook",
                "woocommerce",
                "retargeting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_product' ) ) {\n        return;\n    }\n\n    $view = null;\n\n    if ( is_product() ) {\n        $product = wc_get_product( get_queried_object_id() );\n\n        if ( $product ) {\n            $view = array(\n                'content_ids'  => array( (string) $product->get_id() ),\n                'content_name' => $product->get_name(),\n                'content_type' => $product->is_type( 'variable' ) ? 'product_group' : 'product',\n                'value'        => round( (float) wc_get_price_to_display( $product ), 2 ),\n                'currency'     => get_woocommerce_currency(),\n            );\n        }\n    }\n    ?>\n<script>\n(function () {\n    if (typeof fbq !== 'function') { return; }\n    var view = <?php echo wp_json_encode( $view ); ?>;\n    if (view) {\n        fbq('track', 'ViewContent', view);\n        var form = document.querySelector('form.cart');\n        if (form) {\n            form.addEventListener('submit', function () {\n                fbq('track', 'AddToCart', { content_ids: view.content_ids, content_type: 'product', value: view.value, currency: view.currency });\n            });\n        }\n    }\n    if (window.jQuery) {\n        jQuery(document.body).on('added_to_cart', function (e, f, h, button) {\n            var id = button && button.length ? button[0].getAttribute('data-product_id') : '';\n            fbq('track', 'AddToCart', { content_ids: id ? [String(id)] : [], content_type: 'product' });\n        });\n    }\n})();\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the Meta Pixel base code on the page."
        },
        {
            "id": "analytics-google-ads-woo-conversion",
            "version": 1,
            "title": "Google Ads Conversion Tracking for WooCommerce",
            "description": "Loads the Google Ads tag and reports a purchase conversion with the order value and transaction ID on the WooCommerce order received page.",
            "category": "analytics",
            "tags": [
                "google-ads",
                "woocommerce",
                "conversion",
                "gtag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gads = array(\n    'conversion_id'    => 'AW-XXXXXXXXX',     // Your Google Ads tag ID.\n    'conversion_label' => 'XXXXXXXXXXXXXXXX', // Conversion label of the purchase action.\n);\n\nadd_action( 'wp_head', function () use ( $wpide_gads ) {\n    if ( 'AW-XXXXXXXXX' === $wpide_gads['conversion_id'] ) {\n        return;\n    }\n\n    printf(\n        \"<script async src=\\\"%s\\\"></script>\\n<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('js', new Date()); gtag('config', %s);</script>\\n\",\n        esc_url( 'https://www.googletagmanager.com/gtag/js?id=' . rawurlencode( $wpide_gads['conversion_id'] ) ),\n        wp_json_encode( $wpide_gads['conversion_id'] )\n    );\n}, 5 );\n\nadd_action( 'woocommerce_thankyou', function ( $order_id ) use ( $wpide_gads ) {\n    $order = wc_get_order( $order_id );\n\n    if ( 'AW-XXXXXXXXX' === $wpide_gads['conversion_id'] || ! $order || $order->get_meta( '_wpide_gads_sent' ) ) {\n        return;\n    }\n\n    $data = array(\n        'send_to'        => $wpide_gads['conversion_id'] . '/' . $wpide_gads['conversion_label'],\n        'value'          => round( (float) $order->get_total(), 2 ),\n        'currency'       => $order->get_currency(),\n        'transaction_id' => (string) $order->get_order_number(),\n    );\n\n    echo \"<script>if (typeof gtag === 'function') { gtag('event', 'conversion', \" . wp_json_encode( $data ) . \"); }</script>\\n\";\n\n    $order->update_meta_data( '_wpide_gads_sent', time() );\n    $order->save();\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $conversion_id (AW-...) and $conversion_label from Google Ads > Goals > Conversions. Nothing is output while the placeholders are in place."
        },
        {
            "id": "analytics-pinterest-tag",
            "version": 1,
            "title": "Pinterest Tag",
            "description": "Adds the Pinterest tag to every public page and records page visits, with an option to wait for cookie consent.",
            "category": "analytics",
            "tags": [
                "pinterest",
                "pixel",
                "tracking",
                "ads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $tag_id          = 'YOUR_TAG_ID'; // Pinterest tag ID.\n    $require_consent = false;         // true: wait for consent from the WPIDE cookie banner.\n\n    if ( 'YOUR_TAG_ID' === $tag_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    if (!window.pintrk) {\n        window.pintrk = function () { window.pintrk.queue.push(Array.prototype.slice.call(arguments)); };\n        window.pintrk.queue = [];\n        window.pintrk.version = '3.0';\n        var s = document.createElement('script');\n        s.async = true;\n        s.src = 'https://s.pinimg.com/ct/core.js';\n        document.head.appendChild(s);\n    }\n    pintrk('load', <?php echo wp_json_encode( $tag_id ); ?>);\n    pintrk('page');\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace YOUR_TAG_ID with the tag ID from Pinterest Ads Manager > Conversions; nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-x-pixel",
            "version": 1,
            "title": "X (Twitter) Pixel",
            "description": "Adds the X (formerly Twitter) website tag to every public page for conversion tracking and audiences, with an option to wait for cookie consent.",
            "category": "analytics",
            "tags": [
                "x",
                "twitter",
                "pixel",
                "ads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $pixel_id        = 'YOUR_PIXEL_ID'; // X pixel ID.\n    $require_consent = false;           // true: wait for consent from the WPIDE cookie banner.\n\n    if ( 'YOUR_PIXEL_ID' === $pixel_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    if (!window.twq) {\n        var q = window.twq = function () { q.exe ? q.exe.apply(q, arguments) : q.queue.push(arguments); };\n        q.version = '1.1';\n        q.queue = [];\n        var s = document.createElement('script');\n        s.async = true;\n        s.src = 'https://static.ads-twitter.com/uwt.js';\n        document.head.appendChild(s);\n    }\n    twq('config', <?php echo wp_json_encode( $pixel_id ); ?>);\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace YOUR_PIXEL_ID with the pixel ID from X Ads > Tools > Events Manager. Nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-reddit-pixel",
            "version": 1,
            "title": "Reddit Pixel",
            "description": "Adds the Reddit Ads pixel to every public page and tracks page visits, with an option to wait for cookie consent.",
            "category": "analytics",
            "tags": [
                "reddit",
                "pixel",
                "ads",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $pixel_id        = 'YOUR_PIXEL_ID'; // Reddit pixel ID.\n    $require_consent = false;           // true: wait for consent from the WPIDE cookie banner.\n\n    if ( 'YOUR_PIXEL_ID' === $pixel_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    if (!window.rdt) {\n        var p = window.rdt = function () { p.sendEvent ? p.sendEvent.apply(p, arguments) : p.callQueue.push(arguments); };\n        p.callQueue = [];\n        var s = document.createElement('script');\n        s.async = true;\n        s.src = 'https://www.redditstatic.com/ads/pixel.js';\n        document.head.appendChild(s);\n    }\n    rdt('init', <?php echo wp_json_encode( $pixel_id ); ?>);\n    rdt('track', 'PageVisit');\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace YOUR_PIXEL_ID with your Reddit pixel ID (it starts with a2_ or t2_). Nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-tiktok-pixel",
            "version": 1,
            "title": "TikTok Pixel",
            "description": "Adds the TikTok pixel to every public page and records page views, with an option to wait for cookie consent.",
            "category": "analytics",
            "tags": [
                "tiktok",
                "pixel",
                "ads",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $pixel_id        = 'YOUR_PIXEL_ID'; // TikTok pixel ID.\n    $require_consent = false;           // true: wait for consent from the WPIDE cookie banner.\n\n    if ( 'YOUR_PIXEL_ID' === $pixel_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    var id = <?php echo wp_json_encode( $pixel_id ); ?>;\n    window.TiktokAnalyticsObject = 'ttq';\n    var ttq = window.ttq = window.ttq || [];\n    ttq.methods = ['page', 'track', 'identify', 'instances', 'debug', 'on', 'off', 'once', 'ready', 'alias', 'group', 'enableCookie', 'disableCookie', 'holdConsent', 'revokeConsent', 'grantConsent'];\n    ttq.setAndDefer = function (target, method) {\n        target[method] = function () { target.push([method].concat(Array.prototype.slice.call(arguments, 0))); };\n    };\n    ttq.methods.forEach(function (method) { ttq.setAndDefer(ttq, method); });\n    ttq.instance = function (name) {\n        var inst = ttq._i[name] || [];\n        ttq.methods.forEach(function (method) { ttq.setAndDefer(inst, method); });\n        return inst;\n    };\n    ttq.load = function (pixel, options) {\n        var url = 'https://analytics.tiktok.com/i18n/pixel/events.js';\n        ttq._i = ttq._i || {}; ttq._i[pixel] = []; ttq._i[pixel]._u = url;\n        ttq._t = ttq._t || {}; ttq._t[pixel] = +new Date();\n        ttq._o = ttq._o || {}; ttq._o[pixel] = options || {};\n        var s = document.createElement('script');\n        s.async = true;\n        s.src = url + '?sdkid=' + encodeURIComponent(pixel) + '&lib=ttq';\n        document.head.appendChild(s);\n    };\n    ttq.load(id);\n    ttq.page();\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace YOUR_PIXEL_ID with the pixel ID from TikTok Ads Manager > Assets > Events. Nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-hotjar",
            "version": 1,
            "title": "Hotjar Tracking Code",
            "description": "Adds Hotjar heatmaps and session recordings to your public pages, without recording logged-in staff.",
            "category": "analytics",
            "tags": [
                "hotjar",
                "heatmaps",
                "recordings",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $site_id         = 'YOUR_SITE_ID'; // Hotjar Site ID (numbers only).\n    $require_consent = false;          // true: wait for consent from the WPIDE cookie banner.\n\n    if ( ! ctype_digit( (string) $site_id ) || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    window.hj = window.hj || function () { (window.hj.q = window.hj.q || []).push(arguments); };\n    window._hjSettings = { hjid: <?php echo (int) $site_id; ?>, hjsv: 6 };\n    var s = document.createElement('script');\n    s.async = true;\n    s.src = 'https://static.hotjar.com/c/hotjar-' + window._hjSettings.hjid + '.js?sv=' + window._hjSettings.hjsv;\n    document.head.appendChild(s);\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace YOUR_SITE_ID with your Hotjar Site ID; nothing is output while the placeholder is in place. Mention Hotjar in your privacy policy."
        },
        {
            "id": "analytics-matomo",
            "version": 1,
            "title": "Matomo Analytics (Self-Hosted or Cloud)",
            "description": "Adds the Matomo tracking code to every public page, with optional cookieless tracking and without counting logged-in staff.",
            "category": "analytics",
            "tags": [
                "matomo",
                "analytics",
                "privacy",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $matomo_url = '';    // For example 'https://analytics.example.com/' or 'https://yourname.matomo.cloud/'.\n    $site_id    = 1;     // Matomo site ID.\n    $cookieless = true;  // true: no tracking cookies are set.\n\n    if ( '' === $matomo_url || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\nvar _paq = window._paq = window._paq || [];\n<?php if ( $cookieless ) : ?>\n_paq.push(['disableCookies']);\n<?php endif; ?>\n_paq.push(['trackPageView']);\n_paq.push(['enableLinkTracking']);\n(function () {\n    var u = <?php echo wp_json_encode( trailingslashit( esc_url_raw( $matomo_url ) ) ); ?>;\n    _paq.push(['setTrackerUrl', u + 'matomo.php']);\n    _paq.push(['setSiteId', <?php echo wp_json_encode( (string) (int) $site_id ); ?>]);\n    var g = document.createElement('script');\n    g.async = true;\n    g.src = u + 'matomo.js';\n    document.head.appendChild(g);\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Set $matomo_url to your Matomo address (with a trailing slash) and $site_id. Nothing is output while $matomo_url is empty."
        },
        {
            "id": "analytics-umami",
            "version": 1,
            "title": "Umami Analytics",
            "description": "Adds the privacy-friendly Umami analytics script (cloud or self-hosted) to every public page, skipping logged-in staff.",
            "category": "analytics",
            "tags": [
                "umami",
                "analytics",
                "privacy",
                "cookieless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $website_id = 'YOUR-WEBSITE-ID';                  // Umami website ID.\n    $script_url = 'https://cloud.umami.is/script.js'; // Your script URL if self-hosted.\n\n    if ( 'YOUR-WEBSITE-ID' === $website_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    printf( \"<script defer src=\\\"%s\\\" data-website-id=\\\"%s\\\"></script>\\n\", esc_url( $script_url ), esc_attr( $website_id ) );\n} );",
            "requires": [],
            "notes": "Replace YOUR-WEBSITE-ID with your Umami website ID (and $script_url if self-hosted); nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-simple-analytics",
            "version": 1,
            "title": "Simple Analytics",
            "description": "Adds the cookie-free Simple Analytics script to your public pages. No tracking ID is needed: data is matched to the domain you added in Simple Analytics.",
            "category": "analytics",
            "tags": [
                "simple-analytics",
                "analytics",
                "privacy",
                "cookieless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $enabled = false; // Set to true once your domain is added in Simple Analytics.\n\n    if ( ! $enabled || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    echo \"<script async src=\\\"https://scripts.simpleanalyticscdn.com/latest.js\\\"></script>\\n\";\n} );",
            "requires": [],
            "notes": "Add your domain in the Simple Analytics dashboard first. Set $enabled to true to start loading the script."
        },
        {
            "id": "analytics-cloudflare-web-analytics",
            "version": 1,
            "title": "Cloudflare Web Analytics Beacon",
            "description": "Adds Cloudflare's free, cookieless Web Analytics beacon to your public pages, for sites not proxied through Cloudflare or that want manual setup.",
            "category": "analytics",
            "tags": [
                "cloudflare",
                "analytics",
                "privacy",
                "cookieless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $token = 'YOUR_TOKEN'; // Web Analytics site token.\n\n    if ( 'YOUR_TOKEN' === $token || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    printf( \"<script defer src=\\\"https://static.cloudflareinsights.com/beacon.min.js\\\" data-cf-beacon=\\\"%s\\\"></script>\\n\", esc_attr( wp_json_encode( array( 'token' => $token ) ) ) );\n} );",
            "requires": [],
            "notes": "Replace YOUR_TOKEN with the token from Cloudflare dashboard > Analytics & Logs > Web Analytics. Nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-microsoft-uet",
            "version": 1,
            "title": "Microsoft Advertising UET Tag",
            "description": "Adds the Microsoft Advertising (Bing Ads) Universal Event Tracking tag to every public page for conversion tracking and remarketing.",
            "category": "analytics",
            "tags": [
                "microsoft-ads",
                "bing",
                "uet",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $tag_id          = '00000000'; // UET tag ID.\n    $require_consent = false;      // true: wait for consent from the WPIDE cookie banner.\n\n    if ( '00000000' === $tag_id || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function (run) {\n    if (!<?php echo $require_consent ? 'true' : 'false'; ?> || /(?:^|; )wpide_consent=granted/.test(document.cookie)) { run(); return; }\n    document.addEventListener('wpide:consent', function (e) { if (e.detail === 'granted') { run(); } });\n})(function () {\n    window.uetq = window.uetq || [];\n    var s = document.createElement('script');\n    s.async = true;\n    s.src = 'https://bat.bing.com/bat.js';\n    s.onload = function () {\n        if (typeof UET !== 'function') { return; }\n        var options = { ti: <?php echo wp_json_encode( $tag_id ); ?>, enableAutoSpaTracking: true, q: window.uetq };\n        window.uetq = new UET(options);\n        window.uetq.push('pageLoad');\n    };\n    document.head.appendChild(s);\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Replace 00000000 with your UET tag ID from Microsoft Advertising > Tools > UET tag. Nothing is output while the placeholder is in place."
        },
        {
            "id": "analytics-contact-click-events",
            "version": 1,
            "title": "Track Phone, Email and WhatsApp Link Clicks",
            "description": "Sends an event to Google Analytics 4 (or the GTM dataLayer) whenever a visitor clicks a phone number, email address or WhatsApp link, so you can count leads from these contacts.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "events",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n<script>\ndocument.addEventListener('click', function (e) {\n    var link = e.target.closest ? e.target.closest('a[href]') : null;\n    if (!link) { return; }\n    var href = link.getAttribute('href') || '';\n    var method = /^tel:/i.test(href) ? 'phone' : /^mailto:/i.test(href) ? 'email' : /(wa\\.me|api\\.whatsapp\\.com|whatsapp:)/i.test(href) ? 'whatsapp' : '';\n    if (!method) { return; }\n    var params = { method: method, link_url: href.replace(/^(tel:|mailto:)/i, ''), link_text: (link.textContent || '').trim().slice(0, 100) };\n    if (typeof gtag === 'function') {\n        gtag('event', 'contact_click', params);\n    } else {\n        window.dataLayer = window.dataLayer || [];\n        window.dataLayer.push(Object.assign({ event: 'contact_click' }, params));\n    }\n}, true);\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Events are named contact_click with a method parameter (phone, email, whatsapp). Mark contact_click as a key event in GA4 to count it as a conversion."
        },
        {
            "id": "analytics-scroll-depth",
            "version": 1,
            "title": "Scroll Depth Events in the dataLayer",
            "description": "Pushes scroll_depth events to the Google Tag Manager dataLayer when visitors scroll 25, 50, 75 and 90 percent of a page, to measure how far content is read.",
            "category": "analytics",
            "tags": [
                "google-tag-manager",
                "datalayer",
                "scroll",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    var marks = [25, 50, 75, 90], sent = {};\n    window.dataLayer = window.dataLayer || [];\n    function check() {\n        var doc = document.documentElement;\n        var max = doc.scrollHeight - window.innerHeight;\n        if (max <= 0) { return; }\n        var percent = Math.round((window.pageYOffset || doc.scrollTop) / max * 100);\n        marks.forEach(function (mark) {\n            if (percent >= mark && !sent[mark]) {\n                sent[mark] = true;\n                window.dataLayer.push({ event: 'scroll_depth', scroll_percent: mark, page_path: location.pathname });\n            }\n        });\n    }\n    var ticking = false;\n    window.addEventListener('scroll', function () {\n        if (ticking) { return; }\n        ticking = true;\n        window.requestAnimationFrame(function () { check(); ticking = false; });\n    }, { passive: true });\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "In GTM, create a Custom Event trigger for \"scroll_depth\" and read the percent value from the dataLayer variable \"scroll_percent\"."
        },
        {
            "id": "analytics-ga4-user-properties",
            "version": 1,
            "title": "Send Login Status and User Role to GA4",
            "description": "Sets GA4 user properties for whether the visitor is logged in and their role (for example customer or subscriber), so you can compare members and guests in reports.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "users",
                "segmentation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $user  = wp_get_current_user();\n    $props = array(\n        'logged_in' => $user->exists() ? 'yes' : 'no',\n        'user_role' => $user->exists() && $user->roles ? (string) reset( $user->roles ) : 'guest',\n    );\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('set', 'user_properties', \" . wp_json_encode( $props ) . \");</script>\\n\";\n}, 2 );",
            "requires": [],
            "notes": "Register logged_in and user_role as user-scoped custom dimensions in GA4 > Admin > Custom definitions. No personal data such as names or emails is sent."
        },
        {
            "id": "analytics-ga4-content-group",
            "version": 1,
            "title": "Send Post Category and Author to GA4",
            "description": "Tells Google Analytics 4 which category, author and post type each page belongs to, so you can see which topics and writers bring the most engagement.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "content",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $post   = get_queried_object();\n    $params = array(\n        'content_group' => '',\n        'post_author'   => get_the_author_meta( 'display_name', $post->post_author ),\n        'post_type'     => $post->post_type,\n    );\n\n    $cats = get_the_category( $post->ID );\n\n    if ( $cats ) {\n        $params['content_group'] = $cats[0]->name;\n    } else {\n        $params['content_group'] = get_post_type_object( $post->post_type )->labels->singular_name;\n    }\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('set', \" . wp_json_encode( $params ) . \");</script>\\n\";\n}, 2 );",
            "requires": [],
            "notes": "content_group appears in GA4 reports automatically. Register post_author and post_type as event-scoped custom dimensions to report on them."
        },
        {
            "id": "analytics-ga4-404-event",
            "version": 1,
            "title": "Track 404 Errors in GA4",
            "description": "Sends a page_not_found event to Google Analytics 4 with the broken URL and the page the visitor came from, so you can find and fix dead links.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "404",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n    ?>\n<script>\nif (typeof gtag === 'function') {\n    gtag('event', 'page_not_found', { page_location: location.href, page_referrer: document.referrer || '(direct)' });\n}\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Needs the GA4 tag on the page. Find the events under Reports > Engagement > Events."
        },
        {
            "id": "analytics-ga4-search-no-results",
            "version": 1,
            "title": "Track Searches With No Results in GA4",
            "description": "Sends a search_no_results event with the search term to Google Analytics 4 when a site search finds nothing, showing you what visitors want but cannot find.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "search",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    global $wp_query;\n\n    if ( ! is_search() || $wp_query->found_posts > 0 ) {\n        return;\n    }\n    ?>\n<script>\nif (typeof gtag === 'function') {\n    gtag('event', 'search_no_results', { search_term: <?php echo wp_json_encode( mb_substr( get_search_query( false ), 0, 100 ) ); ?> });\n}\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Needs the GA4 tag on the page. Register search_term as an event-scoped custom dimension to report on terms."
        },
        {
            "id": "analytics-gtm-page-datalayer",
            "version": 1,
            "title": "Page Details in the GTM dataLayer",
            "description": "Pushes page information (page type, post type, categories, author, publish date and login status) to the dataLayer before Google Tag Manager loads, for use in tags and triggers.",
            "category": "analytics",
            "tags": [
                "google-tag-manager",
                "datalayer",
                "content",
                "segmentation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $data = array(\n        'pageType' => is_front_page() ? 'home' : ( is_singular() ? 'single' : ( is_archive() ? 'archive' : ( is_search() ? 'search' : ( is_404() ? '404' : 'other' ) ) ) ),\n        'loggedIn' => is_user_logged_in(),\n    );\n\n    if ( is_singular() ) {\n        $post                   = get_queried_object();\n        $data['postType']       = $post->post_type;\n        $data['postId']         = $post->ID;\n        $data['postAuthor']     = get_the_author_meta( 'display_name', $post->post_author );\n        $data['postDate']       = get_the_date( 'Y-m-d', $post );\n        $data['postCategories'] = wp_list_pluck( (array) get_the_category( $post->ID ), 'name' );\n    } elseif ( is_category() || is_tag() || is_tax() ) {\n        $data['termName']     = single_term_title( '', false );\n        $data['termTaxonomy'] = get_queried_object()->taxonomy;\n    }\n\n    echo '<script>window.dataLayer = window.dataLayer || []; window.dataLayer.push(' . wp_json_encode( $data ) . \");</script>\\n\";\n}, 1 );",
            "requires": [],
            "notes": "Create Data Layer Variables in GTM for the keys you need, for example pageType or postCategories."
        },
        {
            "id": "analytics-utm-capture",
            "version": 1,
            "title": "Remember UTM Parameters and Fill Hidden Form Fields",
            "description": "Saves utm_source, utm_medium, utm_campaign, utm_term, utm_content and gclid from the landing page in a first-party cookie, then fills matching hidden fields in your forms so leads keep their campaign source.",
            "category": "analytics",
            "tags": [
                "utm",
                "attribution",
                "forms",
                "marketing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    var keys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid'];\n    var params = new URLSearchParams(location.search), found = {}, has = false;\n    keys.forEach(function (k) { var v = params.get(k); if (v) { found[k] = v.slice(0, 200); has = true; } });\n    if (has) {\n        document.cookie = 'wpide_utm=' + encodeURIComponent(JSON.stringify(found)) + ';path=/;max-age=' + (30 * 86400) + ';SameSite=Lax' + (location.protocol === 'https:' ? ';Secure' : '');\n    }\n    var match = document.cookie.match(/(?:^|; )wpide_utm=([^;]+)/), stored = {};\n    try { stored = match ? JSON.parse(decodeURIComponent(match[1])) : {}; } catch (e) { stored = {}; }\n    function fill(root) {\n        Object.keys(stored).forEach(function (k) {\n            root.querySelectorAll('input[name=\"' + k + '\"], input[name$=\"[' + k + ']\"], input.' + k).forEach(function (input) {\n                if (!input.value) { input.value = stored[k]; }\n            });\n        });\n    }\n    fill(document);\n    document.addEventListener('focusin', function (e) { if (e.target.form) { fill(e.target.form); } });\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Add hidden fields named utm_source, utm_medium and so on to your forms (most form plugins support this). The cookie lasts 30 days and holds only campaign data."
        },
        {
            "id": "analytics-post-views-counter",
            "version": 1,
            "title": "Post Views Counter With Admin Column",
            "description": "Counts views of posts and pages, shows the totals in a sortable \"Views\" column in the admin, and adds a [wpide_views] shortcode to display the count.",
            "category": "analytics",
            "tags": [
                "views",
                "counter",
                "statistics",
                "popular-posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! is_singular() || current_user_can( 'edit_posts' ) || is_preview() ) {\n        return;\n    }\n    ?>\n<script>\n(function () {\n    var id = <?php echo (int) get_queried_object_id(); ?>, key = 'wpide_viewed_' + id;\n    try { if (sessionStorage.getItem(key)) { return; } sessionStorage.setItem(key, '1'); } catch (e) {}\n    if (/bot|crawl|spider|slurp|lighthouse/i.test(navigator.userAgent)) { return; }\n    var data = new FormData();\n    data.append('action', 'wpide_count_view');\n    data.append('post_id', id);\n    if (navigator.sendBeacon) { navigator.sendBeacon(<?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, data); }\n})();\n</script>\n    <?php\n} );\n\n$wpide_count_view = function () {\n    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification -- public, cache-safe counter.\n\n    if ( $post_id && 'publish' === get_post_status( $post_id ) && is_post_type_viewable( get_post_type( $post_id ) ) ) {\n        update_post_meta( $post_id, '_wpide_views', (int) get_post_meta( $post_id, '_wpide_views', true ) + 1 );\n    }\n\n    wp_die( '', '', array( 'response' => 204 ) );\n};\n\nadd_action( 'wp_ajax_nopriv_wpide_count_view', $wpide_count_view );\nadd_action( 'wp_ajax_wpide_count_view', $wpide_count_view );\n\nadd_shortcode( 'wpide_views', function ( $atts ) {\n    $atts = shortcode_atts( array( 'id' => get_the_ID() ), $atts, 'wpide_views' );\n\n    return esc_html( number_format_i18n( (int) get_post_meta( absint( $atts['id'] ), '_wpide_views', true ) ) );\n} );\n\nforeach ( array( 'post', 'page' ) as $wpide_type ) {\n    add_filter( \"manage_{$wpide_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_views'] = 'Views';\n\n        return $columns;\n    } );\n\n    add_action( \"manage_{$wpide_type}_posts_custom_column\", function ( $column, $post_id ) {\n        if ( 'wpide_views' === $column ) {\n            echo esc_html( number_format_i18n( (int) get_post_meta( $post_id, '_wpide_views', true ) ) );\n        }\n    }, 10, 2 );\n\n    add_filter( \"manage_edit-{$wpide_type}_sortable_columns\", function ( $columns ) {\n        $columns['wpide_views'] = 'wpide_views';\n\n        return $columns;\n    } );\n}\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() && $query->is_main_query() && 'wpide_views' === $query->get( 'orderby' ) ) {\n        $query->set( 'meta_key', '_wpide_views' );\n        $query->set( 'orderby', 'meta_value_num' );\n    }\n} );",
            "requires": [],
            "notes": "Counting happens with a small request after the page loads, so it works with page caching; editors and repeat views in the same session are not counted."
        },
        {
            "id": "analytics-clarity-tags",
            "version": 1,
            "title": "Tag Microsoft Clarity Sessions With Page and User Type",
            "description": "Adds custom tags to Microsoft Clarity recordings for the page type, post type and whether the visitor is logged in, so you can filter recordings by them.",
            "category": "analytics",
            "tags": [
                "microsoft-clarity",
                "recordings",
                "segmentation",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $tags = array(\n        'page_type' => is_front_page() ? 'home' : ( is_singular() ? 'single' : ( is_archive() ? 'archive' : ( is_search() ? 'search' : 'other' ) ) ),\n        'post_type' => is_singular() ? (string) get_post_type() : '',\n        'visitor'   => is_user_logged_in() ? 'member' : 'guest',\n    );\n    ?>\n<script>\n(function () {\n    var tags = <?php echo wp_json_encode( array_filter( $tags ) ); ?>;\n    window.clarity = window.clarity || function () { (window.clarity.q = window.clarity.q || []).push(arguments); };\n    Object.keys(tags).forEach(function (k) { window.clarity('set', k, tags[k]); });\n})();\n</script>\n    <?php\n}, 20 );",
            "requires": [],
            "notes": "Needs the Microsoft Clarity tracking code on the page. Filter recordings by these tags under Filters > Custom tags."
        },
        {
            "id": "analytics-edd-ga4-purchase",
            "version": 1,
            "title": "GA4 Purchase Event for Easy Digital Downloads",
            "description": "Sends a Google Analytics 4 \"purchase\" event with the order total and downloads when a customer lands on the Easy Digital Downloads purchase confirmation page.",
            "category": "analytics",
            "tags": [
                "google-analytics",
                "ga4",
                "edd",
                "ecommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'edd_is_success_page' ) || ! edd_is_success_page() || ! function_exists( 'edd_get_order_by' ) ) {\n        return;\n    }\n\n    $session = edd_get_purchase_session();\n\n    if ( empty( $session['purchase_key'] ) ) {\n        return;\n    }\n\n    $order = edd_get_order_by( 'payment_key', $session['purchase_key'] );\n\n    if ( ! $order || ! in_array( $order->status, array( 'complete', 'publish' ), true ) || edd_get_order_meta( $order->id, '_wpide_ga4_sent', true ) ) {\n        return;\n    }\n\n    $items = array();\n\n    foreach ( $order->get_items() as $item ) {\n        $items[] = array(\n            'item_id'   => (string) $item->product_id,\n            'item_name' => $item->product_name,\n            'price'     => round( (float) $item->total / max( 1, (int) $item->quantity ), 2 ),\n            'quantity'  => (int) $item->quantity,\n        );\n    }\n\n    $data = array(\n        'transaction_id' => (string) $order->get_number(),\n        'value'          => round( (float) $order->total, 2 ),\n        'tax'            => round( (float) $order->tax, 2 ),\n        'currency'       => $order->currency,\n        'items'          => $items,\n    );\n\n    echo \"<script>window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} gtag('event', 'purchase', \" . wp_json_encode( $data ) . \");</script>\\n\";\n\n    edd_update_order_meta( $order->id, '_wpide_ga4_sent', time() );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Needs the GA4 tag (gtag.js) on the page. Uses EDD 3 order functions and sends the event once per order."
        },
        {
            "id": "dev-debug-log-viewer",
            "version": 1,
            "title": "View the Debug Log in the Admin",
            "description": "Adds a Tools > Debug Log screen that shows the last lines of wp-content/debug.log, so administrators can check errors without FTP access.",
            "category": "developer",
            "tags": [
                "debugging",
                "logs",
                "admin",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Debug Log', 'Debug Log', 'manage_options', 'wpide-debug-log', function () {\n        $lines = 200; // Number of lines to show from the end of the file.\n\n        $file = WP_CONTENT_DIR . '/debug.log';\n\n        if ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) {\n            $file = WP_DEBUG_LOG;\n        }\n\n        echo '<div class=\"wrap\"><h1>Debug Log</h1>';\n\n        if ( ! is_readable( $file ) ) {\n            echo '<p>No log file found at <code>' . esc_html( $file ) . '</code>.</p></div>';\n            return;\n        }\n\n        $size   = filesize( $file );\n        $handle = fopen( $file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n        $chunk  = min( $size, 256 * 1024 );\n\n        fseek( $handle, -$chunk, SEEK_END );\n        $content = (string) fread( $handle, $chunk ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n        fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n\n        $rows = array_slice( explode( \"\\n\", trim( $content ) ), -$lines );\n\n        printf( '<p><code>%1$s</code> (%2$s, last modified %3$s)</p>', esc_html( $file ), esc_html( size_format( $size ) ), esc_html( wp_date( 'Y-m-d H:i:s', filemtime( $file ) ) ) );\n        echo '<pre style=\"max-height:70vh;overflow:auto;padding:12px;background:#1e1e1e;color:#f0f0f1;font-size:12px;line-height:1.5;white-space:pre-wrap;\">' . esc_html( implode( \"\\n\", $rows ) ) . '</pre></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Turn on WP_DEBUG and WP_DEBUG_LOG in wp-config.php to write the log. Change $lines to show more or fewer lines."
        },
        {
            "id": "dev-clear-expired-transients",
            "version": 1,
            "title": "Delete Expired Transients From the Admin Bar",
            "description": "Adds a toolbar button for administrators that removes expired transients from the database in one click, keeping the options table lean.",
            "category": "developer",
            "tags": [
                "database",
                "transients",
                "cleanup",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'     => 'wpide-clear-transients',\n        'parent' => 'top-secondary',\n        'title'  => 'Clear expired transients',\n        'href'   => wp_nonce_url( admin_url( 'admin-post.php?action=wpide_clear_transients' ), 'wpide_clear_transients' ),\n    ) );\n}, 999 );\n\nadd_action( 'admin_post_wpide_clear_transients', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( 'Sorry, you are not allowed to do that.' );\n    }\n\n    check_admin_referer( 'wpide_clear_transients' );\n\n    delete_expired_transients( true );\n\n    wp_safe_redirect( add_query_arg( 'wpide_transients_cleared', '1', wp_get_referer() ? wp_get_referer() : admin_url() ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_transients_cleared'] ) && current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        echo '<div class=\"notice notice-success is-dismissible\"><p>Expired transients deleted.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Only expired transients are deleted, so cached data that is still valid is kept."
        },
        {
            "id": "dev-flush-object-cache-button",
            "version": 1,
            "title": "Flush the Object Cache From the Admin Bar",
            "description": "Adds a toolbar button for administrators that empties the object cache (Redis, Memcached or the built-in cache) after deployments or data fixes.",
            "category": "developer",
            "tags": [
                "cache",
                "object-cache",
                "admin-bar",
                "deployment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'     => 'wpide-flush-cache',\n        'parent' => 'top-secondary',\n        'title'  => wp_using_ext_object_cache() ? 'Flush object cache' : 'Flush cache',\n        'href'   => wp_nonce_url( admin_url( 'admin-post.php?action=wpide_flush_cache' ), 'wpide_flush_cache' ),\n    ) );\n}, 999 );\n\nadd_action( 'admin_post_wpide_flush_cache', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( 'Sorry, you are not allowed to do that.' );\n    }\n\n    check_admin_referer( 'wpide_flush_cache' );\n\n    wp_cache_flush();\n\n    wp_safe_redirect( add_query_arg( 'wpide_cache_flushed', '1', wp_get_referer() ? wp_get_referer() : admin_url() ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_cache_flushed'] ) && current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        echo '<div class=\"notice notice-success is-dismissible\"><p>Object cache flushed.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Flushing a shared persistent cache affects every site using it; on multisite it clears the whole network's cache."
        },
        {
            "id": "dev-custom-rest-route",
            "version": 1,
            "title": "Example Custom REST API Endpoint",
            "description": "Registers a read-only endpoint at /wp-json/wpide/v1/latest that returns your latest posts in a compact format, as a starting point for your own API routes.",
            "category": "developer",
            "tags": [
                "rest-api",
                "api",
                "json",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'wpide/v1', '/latest', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true', // Public, read-only data.\n        'args'                => array(\n            'count' => array(\n                'default'           => 5,\n                'sanitize_callback' => 'absint',\n                'validate_callback' => function ( $value ) {\n                    return is_numeric( $value ) && $value >= 1 && $value <= 20;\n                },\n            ),\n        ),\n        'callback'            => function ( WP_REST_Request $request ) {\n            $posts = get_posts( array(\n                'numberposts' => (int) $request->get_param( 'count' ),\n                'post_status' => 'publish',\n            ) );\n\n            return rest_ensure_response( array_map( function ( $post ) {\n                return array(\n                    'id'      => $post->ID,\n                    'title'   => get_the_title( $post ),\n                    'url'     => get_permalink( $post ),\n                    'date'    => get_post_time( 'c', true, $post ),\n                    'excerpt' => wp_strip_all_tags( get_the_excerpt( $post ) ),\n                    'image'   => get_the_post_thumbnail_url( $post, 'medium' ) ? get_the_post_thumbnail_url( $post, 'medium' ) : null,\n                );\n            }, $posts ) );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Change the namespace, route and callback to build your own endpoint. Make permission_callback stricter for anything that is not public."
        },
        {
            "id": "dev-register-meta-rest",
            "version": 1,
            "title": "Expose a Custom Field in the REST API and Block Editor",
            "description": "Registers a post meta field (a subtitle by default) so it is saved safely, available in the REST API and usable by the block editor and block bindings.",
            "category": "developer",
            "tags": [
                "rest-api",
                "meta",
                "custom-fields",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $post_type = 'post';            // Post type the field belongs to.\n    $meta_key  = 'wpide_subtitle';  // Meta key.\n\n    register_post_meta( $post_type, $meta_key, array(\n        'type'              => 'string',\n        'single'            => true,\n        'default'           => '',\n        'show_in_rest'      => true,\n        'sanitize_callback' => 'sanitize_text_field',\n        'auth_callback'     => function ( $allowed, $key, $post_id ) {\n            return current_user_can( 'edit_post', $post_id );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Change $meta_key and $post_type. Only users who can edit the post can update the field."
        },
        {
            "id": "dev-register-block-style",
            "version": 1,
            "title": "Add a Custom Block Style",
            "description": "Registers extra style variations for core blocks, such as an \"Outline\" button and a \"Card\" group, that editors can pick in the block sidebar.",
            "category": "developer",
            "tags": [
                "block-editor",
                "block-styles",
                "gutenberg",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    // Block => array( style name, label, CSS ).\n    $styles = array(\n        array( 'core/button', 'wpide-outline', 'Outline (thin)', '.wp-block-button.is-style-wpide-outline .wp-block-button__link{background:transparent;color:currentColor;border:1px solid currentColor;}' ),\n        array( 'core/group', 'wpide-card', 'Card', '.wp-block-group.is-style-wpide-card{padding:1.5rem;border-radius:12px;background:#fff;box-shadow:0 2px 12px rgba(0,0,0,.08);}' ),\n    );\n\n    foreach ( $styles as $style ) {\n        register_block_style( $style[0], array(\n            'name'         => $style[1],\n            'label'        => $style[2],\n            'inline_style' => $style[3],\n        ) );\n    }\n} );",
            "requires": [],
            "notes": "Edit the CSS in $styles. Each entry adds one option under Styles in the block settings."
        },
        {
            "id": "dev-load-script-on-pages",
            "version": 1,
            "title": "Load a Script or Stylesheet Only on Selected Pages",
            "description": "Enqueues a JavaScript or CSS file only on the pages you list, instead of on every page, to keep the rest of the site fast.",
            "category": "developer",
            "tags": [
                "scripts",
                "styles",
                "enqueue",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    $pages      = array( 'contact', 'booking' ); // Page slugs or IDs.\n    $script_url = '';                            // Full URL of the JavaScript file.\n    $style_url  = '';                            // Full URL of the CSS file.\n    $version    = '1.0.0';                       // Change to bust browser caches.\n\n    if ( ! is_page( $pages ) ) {\n        return;\n    }\n\n    if ( $script_url ) {\n        wp_enqueue_script( 'wpide-page-script', esc_url_raw( $script_url ), array(), $version, array( 'strategy' => 'defer', 'in_footer' => true ) );\n    }\n\n    if ( $style_url ) {\n        wp_enqueue_style( 'wpide-page-style', esc_url_raw( $style_url ), array(), $version );\n    }\n} );",
            "requires": [],
            "notes": "Set $pages to page slugs or IDs and $script_url / $style_url to your files. Leave a URL empty to skip it."
        },
        {
            "id": "dev-dequeue-assets",
            "version": 1,
            "title": "Remove Specific Scripts and Styles by Handle",
            "description": "Stops chosen plugin or theme scripts and stylesheets from loading on the front end, optionally everywhere except the pages where they are needed.",
            "category": "developer",
            "tags": [
                "scripts",
                "styles",
                "performance",
                "dequeue"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    // Handles to remove.\n    $scripts = array( 'contact-form-7' );\n    $styles  = array( 'contact-form-7' );\n\n    // Keep them on these pages (slugs or IDs). Empty = remove everywhere.\n    $keep_on = array( 'contact' );\n\n    if ( $keep_on && is_page( $keep_on ) ) {\n        return;\n    }\n\n    foreach ( $scripts as $handle ) {\n        wp_dequeue_script( $handle );\n    }\n\n    foreach ( $styles as $handle ) {\n        wp_dequeue_style( $handle );\n    }\n}, 100 );",
            "requires": [],
            "notes": "Find handles with your browser's view source (the id attribute minus -js or -css) or the \"List Enqueued Assets\" snippet. Removing a needed file can break features, so test after each change."
        },
        {
            "id": "dev-wp-cli-command",
            "version": 1,
            "title": "Add a Custom WP-CLI Command",
            "description": "Registers a \"wp site-stats\" WP-CLI command that prints counts of posts, pages, comments and users, as a template for your own command-line tools.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "cli",
                "automation",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_CLI' ) ) {\n    WP_CLI::add_command( 'site-stats', function ( $args, $assoc_args ) {\n        $rows = array(\n            array( 'item' => 'Published posts', 'count' => (int) wp_count_posts( 'post' )->publish ),\n            array( 'item' => 'Published pages', 'count' => (int) wp_count_posts( 'page' )->publish ),\n            array( 'item' => 'Approved comments', 'count' => (int) wp_count_comments()->approved ),\n            array( 'item' => 'Users', 'count' => (int) count_users()['total_users'] ),\n        );\n\n        WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'item', 'count' ) );\n    }, array(\n        'shortdesc' => 'Shows basic content statistics for this site.',\n        'synopsis'  => array(\n            array(\n                'type'     => 'assoc',\n                'name'     => 'format',\n                'optional' => true,\n                'default'  => 'table',\n                'options'  => array( 'table', 'json', 'csv', 'yaml' ),\n            ),\n        ),\n    ) );\n}",
            "requires": [],
            "notes": "Run \"wp site-stats\" or \"wp site-stats --format=json\". The snippet only loads when WP-CLI is running."
        },
        {
            "id": "dev-php-memory-admin-footer",
            "version": 1,
            "title": "Show PHP Version, Memory and Queries in the Admin Footer",
            "description": "Replaces the WordPress version in the bottom right of admin screens with the PHP version, peak memory use and database query count of the current page.",
            "category": "developer",
            "tags": [
                "debugging",
                "performance",
                "admin",
                "server"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'update_footer', function ( $text ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return $text;\n    }\n\n    return sprintf(\n        'PHP %1$s | Memory %2$s of %3$s | %4$d queries | %5$ss',\n        esc_html( PHP_VERSION ),\n        esc_html( size_format( memory_get_peak_usage( true ) ) ),\n        esc_html( ini_get( 'memory_limit' ) ),\n        (int) get_num_queries(),\n        esc_html( number_format_i18n( timer_float(), 2 ) )\n    );\n}, 99 );",
            "requires": [],
            "notes": "Visible to administrators only."
        },
        {
            "id": "dev-log-slow-requests",
            "version": 1,
            "title": "Log Slow Page Loads",
            "description": "Writes a line to the PHP error log for every request that takes longer than a set number of seconds, with the URL, time, memory and query count.",
            "category": "developer",
            "tags": [
                "debugging",
                "performance",
                "logs",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'shutdown', function () {\n    $threshold = 2.0; // Seconds.\n\n    $time = timer_float();\n\n    if ( $time < $threshold ) {\n        return;\n    }\n\n    $uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ( defined( 'WP_CLI' ) ? 'wp-cli' : '' );\n\n    error_log( sprintf( '[slow request] %.2fs | %s | %s | %d queries | %s', $time, isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_key( $_SERVER['REQUEST_METHOD'] ) : 'cli', $uri, get_num_queries(), size_format( memory_get_peak_usage( true ) ) ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n}, 999 );",
            "requires": [],
            "notes": "Change $threshold. Enable WP_DEBUG_LOG or check your server's PHP error log to read the entries."
        },
        {
            "id": "dev-cron-events-viewer",
            "version": 1,
            "title": "Scheduled Cron Events Viewer",
            "description": "Adds a Tools > Cron Events screen listing every scheduled WP-Cron event with its next run time, recurrence and hook name, to debug missed or duplicate tasks.",
            "category": "developer",
            "tags": [
                "cron",
                "debugging",
                "tools",
                "scheduling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Cron Events', 'Cron Events', 'manage_options', 'wpide-cron-events', function () {\n        $crons     = _get_cron_array();\n        $schedules = wp_get_schedules();\n\n        echo '<div class=\"wrap\"><h1>Cron Events</h1>';\n\n        if ( defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON ) {\n            echo '<div class=\"notice notice-info inline\"><p>DISABLE_WP_CRON is set: events only run when a server cron job calls wp-cron.php.</p></div>';\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>Hook</th><th>Next run</th><th>Recurrence</th><th>Arguments</th></tr></thead><tbody>';\n\n        foreach ( (array) $crons as $timestamp => $hooks ) {\n            foreach ( (array) $hooks as $hook => $events ) {\n                foreach ( (array) $events as $event ) {\n                    $when = $timestamp < time() ? 'overdue (' . human_time_diff( $timestamp ) . ')' : 'in ' . human_time_diff( $timestamp );\n                    $rec  = $event['schedule'] ? ( isset( $schedules[ $event['schedule'] ] ) ? $schedules[ $event['schedule'] ]['display'] : $event['schedule'] ) : 'once';\n\n                    printf(\n                        '<tr><td><code>%1$s</code></td><td>%2$s<br><small>%3$s</small></td><td>%4$s</td><td><code>%5$s</code></td></tr>',\n                        esc_html( $hook ),\n                        esc_html( $when ),\n                        esc_html( wp_date( 'Y-m-d H:i:s', $timestamp ) ),\n                        esc_html( $rec ),\n                        esc_html( $event['args'] ? wp_json_encode( $event['args'] ) : '' )\n                    );\n                }\n            }\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Read-only: nothing can be deleted or run from this screen."
        },
        {
            "id": "dev-allow-json-csv-uploads",
            "version": 1,
            "title": "Allow JSON, CSV and Other Data File Uploads",
            "description": "Lets administrators upload data files such as JSON, CSV, XML and Markdown to the Media Library, which WordPress blocks by default.",
            "category": "developer",
            "tags": [
                "uploads",
                "media",
                "mime-types",
                "files"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'upload_mimes', function ( $mimes ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return $mimes;\n    }\n\n    // Extension => MIME type.\n    $types = array(\n        'json' => 'application/json',\n        'csv'  => 'text/csv',\n        'xml'  => 'application/xml',\n        'md'   => 'text/markdown',\n    );\n\n    return array_merge( $mimes, $types );\n} );\n\nadd_filter( 'wp_check_filetype_and_ext', function ( $data, $file, $filename, $mimes ) {\n    if ( ! empty( $data['ext'] ) || ! current_user_can( 'manage_options' ) ) {\n        return $data;\n    }\n\n    $check = wp_check_filetype( $filename, $mimes );\n\n    if ( in_array( $check['ext'], array( 'json', 'csv', 'xml', 'md' ), true ) ) {\n        $data['ext']  = $check['ext'];\n        $data['type'] = $check['type'];\n    }\n\n    return $data;\n}, 10, 4 );",
            "requires": [],
            "notes": "Only users with the manage_options capability get the extra file types. Edit $types to add or remove extensions."
        },
        {
            "id": "dev-site-options-page",
            "version": 1,
            "title": "Simple Site Details Settings Page",
            "description": "Adds Settings > Site Details where you can store your phone number, address and opening hours once, then show them anywhere with [wpide_site_detail key=\"phone\"].",
            "category": "developer",
            "tags": [
                "settings",
                "options",
                "shortcode",
                "contact"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_site_fields = array(\n    'phone'   => 'Phone number',\n    'email'   => 'Contact email',\n    'address' => 'Address',\n    'hours'   => 'Opening hours',\n);\n\nadd_action( 'admin_init', function () use ( $wpide_site_fields ) {\n    register_setting( 'wpide_site_details', 'wpide_site_details', array(\n        'type'              => 'array',\n        'default'           => array(),\n        'sanitize_callback' => function ( $input ) use ( $wpide_site_fields ) {\n            $clean = array();\n\n            foreach ( array_keys( $wpide_site_fields ) as $key ) {\n                $clean[ $key ] = isset( $input[ $key ] ) ? sanitize_textarea_field( $input[ $key ] ) : '';\n            }\n\n            return $clean;\n        },\n    ) );\n} );\n\nadd_action( 'admin_menu', function () use ( $wpide_site_fields ) {\n    add_options_page( 'Site Details', 'Site Details', 'manage_options', 'wpide-site-details', function () use ( $wpide_site_fields ) {\n        $values = (array) get_option( 'wpide_site_details', array() );\n\n        echo '<div class=\"wrap\"><h1>Site Details</h1><form method=\"post\" action=\"options.php\">';\n        settings_fields( 'wpide_site_details' );\n        echo '<table class=\"form-table\" role=\"presentation\">';\n\n        foreach ( $wpide_site_fields as $key => $label ) {\n            printf(\n                '<tr><th scope=\"row\"><label for=\"wpide-%1$s\">%2$s</label></th><td><textarea class=\"large-text\" rows=\"2\" id=\"wpide-%1$s\" name=\"wpide_site_details[%1$s]\">%3$s</textarea><p class=\"description\"><code>[wpide_site_detail key=\"%1$s\"]</code></p></td></tr>',\n                esc_attr( $key ),\n                esc_html( $label ),\n                esc_textarea( isset( $values[ $key ] ) ? $values[ $key ] : '' )\n            );\n        }\n\n        echo '</table>';\n        submit_button();\n        echo '</form></div>';\n    } );\n} );\n\nadd_shortcode( 'wpide_site_detail', function ( $atts ) {\n    $atts   = shortcode_atts( array( 'key' => '' ), $atts, 'wpide_site_detail' );\n    $values = (array) get_option( 'wpide_site_details', array() );\n    $value  = isset( $values[ $atts['key'] ] ) ? $values[ $atts['key'] ] : '';\n\n    return nl2br( esc_html( $value ) );\n} );",
            "requires": [],
            "notes": "Edit $fields to add more details. Values are stored in the wpide_site_details option."
        },
        {
            "id": "dev-subtitle-meta-box",
            "version": 1,
            "title": "Subtitle Field for Posts",
            "description": "Adds a \"Subtitle\" box to the post editor, saves it securely and provides a [wpide_subtitle] shortcode and a helper to display it in your theme.",
            "category": "developer",
            "tags": [
                "custom-fields",
                "meta-box",
                "posts",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function () {\n    add_meta_box( 'wpide-subtitle', 'Subtitle', function ( $post ) {\n        wp_nonce_field( 'wpide_subtitle_save', 'wpide_subtitle_nonce' );\n        printf( '<input type=\"text\" class=\"widefat\" name=\"wpide_subtitle\" value=\"%s\" placeholder=\"Optional subtitle\">', esc_attr( get_post_meta( $post->ID, '_wpide_subtitle', true ) ) );\n    }, 'post', 'side', 'high' );\n} );\n\nadd_action( 'save_post_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_subtitle_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_subtitle_nonce'] ), 'wpide_subtitle_save' ) ) {\n        return;\n    }\n\n    if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $value = isset( $_POST['wpide_subtitle'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_subtitle'] ) ) : '';\n\n    if ( '' === $value ) {\n        delete_post_meta( $post_id, '_wpide_subtitle' );\n    } else {\n        update_post_meta( $post_id, '_wpide_subtitle', $value );\n    }\n} );\n\nadd_shortcode( 'wpide_subtitle', function () {\n    $subtitle = get_post_meta( get_the_ID(), '_wpide_subtitle', true );\n\n    return $subtitle ? '<p class=\"wpide-subtitle\">' . esc_html( $subtitle ) . '</p>' : '';\n} );",
            "requires": [],
            "notes": "In a classic theme you can print it with echo esc_html( get_post_meta( get_the_ID(), '_wpide_subtitle', true ) );"
        },
        {
            "id": "dev-register-post-status",
            "version": 1,
            "title": "Register an \"Archived\" Post Status",
            "description": "Adds an \"Archived\" status for posts that should leave the site without being deleted, with its own filter link in the Posts list and a status choice in the editor.",
            "category": "developer",
            "tags": [
                "post-status",
                "workflow",
                "content",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_status( 'wpide_archived', array(\n        'label'                     => 'Archived',\n        'public'                    => false,\n        'protected'                 => true,\n        'exclude_from_search'       => true,\n        'show_in_admin_all_list'    => false,\n        'show_in_admin_status_list' => true,\n        /* translators: %s: number of posts */\n        'label_count'               => _n_noop( 'Archived <span class=\"count\">(%s)</span>', 'Archived <span class=\"count\">(%s)</span>' ),\n    ) );\n} );\n\nadd_action( 'admin_footer-edit.php', function () {\n    global $post_type;\n\n    if ( 'post' !== $post_type ) {\n        return;\n    }\n    ?>\n<script>\njQuery(function ($) {\n    $('select[name=\"_status\"]').append('<option value=\"wpide_archived\">Archived</option>');\n});\n</script>\n    <?php\n} );\n\nadd_filter( 'display_post_states', function ( $states, $post ) {\n    if ( 'wpide_archived' === $post->post_status && ( ! isset( $_GET['post_status'] ) || 'wpide_archived' !== $_GET['post_status'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $states['wpide_archived'] = 'Archived';\n    }\n\n    return $states;\n}, 10, 2 );",
            "requires": [],
            "notes": "Archived posts are not public. Set them via Quick Edit or the status dropdown in the block editor's summary panel."
        },
        {
            "id": "dev-rest-cors-origins",
            "version": 1,
            "title": "Limit REST API CORS to Your Own Apps",
            "description": "Replaces WordPress's permissive cross-origin (CORS) headers with an allow-list, so only the browser apps you name, such as a headless front end, can call the REST API with credentials.",
            "category": "developer",
            "tags": [
                "rest-api",
                "cors",
                "headless",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    // Origins allowed to call the REST API from a browser.\n    $allowed = array( 'https://app.example.com' );\n\n    remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );\n\n    add_filter( 'rest_pre_serve_request', function ( $served ) use ( $allowed ) {\n        $origin = get_http_origin();\n\n        if ( $origin && in_array( $origin, $allowed, true ) ) {\n            header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );\n            header( 'Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS' );\n            header( 'Access-Control-Allow-Credentials: true' );\n            header( 'Access-Control-Allow-Headers: Authorization, Content-Type, X-WP-Nonce' );\n        }\n\n        header( 'Vary: Origin', false );\n\n        return $served;\n    } );\n}, 15 );",
            "requires": [],
            "notes": "List exact origins including the protocol and no trailing slash. Requests from other origins still work server to server, but browsers will block them."
        },
        {
            "id": "dev-rest-max-per-page",
            "version": 1,
            "title": "Raise the REST API per_page Limit",
            "description": "Lets REST API requests for posts and pages ask for more than 100 items at once, useful for exports and static site generators.",
            "category": "developer",
            "tags": [
                "rest-api",
                "api",
                "pagination",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rest_max = 500; // Highest per_page value allowed.\n\nforeach ( array( 'post', 'page' ) as $wpide_rest_type ) {\n    add_filter( \"rest_{$wpide_rest_type}_collection_params\", function ( $params ) use ( $wpide_rest_max ) {\n        if ( isset( $params['per_page'] ) ) {\n            $params['per_page']['maximum'] = $wpide_rest_max;\n        }\n\n        return $params;\n    } );\n}",
            "requires": [],
            "notes": "Large values make heavy queries; keep $max reasonable."
        },
        {
            "id": "dev-daily-cron-task",
            "version": 1,
            "title": "Run a Daily Task With WP-Cron",
            "description": "Schedules a custom task once a day and gives you a function to fill in, for example to clean up data or send a report. Includes the scheduling and the hook.",
            "category": "developer",
            "tags": [
                "cron",
                "scheduling",
                "automation",
                "tasks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_daily_task' ) ) {\n        // First run tomorrow at 03:00 site time.\n        $first = new DateTime( 'tomorrow 03:00', wp_timezone() );\n        wp_schedule_event( $first->getTimestamp(), 'daily', 'wpide_daily_task' );\n    }\n} );\n\nadd_action( 'wpide_daily_task', function () {\n    // Your daily code goes here. Example: log that the task ran.\n    error_log( '[wpide_daily_task] ran at ' . wp_date( 'Y-m-d H:i:s' ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n} );",
            "requires": [],
            "notes": "Put your code inside the wpide_daily_task callback. To stop the task after removing the snippet, run \"wp cron event delete wpide_daily_task\"."
        },
        {
            "id": "dev-list-enqueued-assets",
            "version": 1,
            "title": "List Enqueued Scripts and Styles in the Page Source",
            "description": "Prints an HTML comment at the bottom of each page, visible to administrators, listing every script and stylesheet handle loaded and its source, to find what to optimize.",
            "category": "developer",
            "tags": [
                "debugging",
                "scripts",
                "styles",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_print_footer_scripts', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $lines = array( 'Enqueued assets' );\n\n    foreach ( array( 'scripts' => wp_scripts(), 'styles' => wp_styles() ) as $type => $deps ) {\n        $lines[] = strtoupper( $type ) . ':';\n\n        foreach ( $deps->done as $handle ) {\n            $src     = isset( $deps->registered[ $handle ] ) ? (string) $deps->registered[ $handle ]->src : '';\n            $lines[] = '  ' . $handle . ( $src ? ' => ' . $src : ' (inline/alias)' );\n        }\n    }\n\n    echo \"\\n<!--\\n\" . esc_html( str_replace( '--', '- -', implode( \"\\n\", $lines ) ) ) . \"\\n-->\\n\";\n}, 999 );",
            "requires": [],
            "notes": "View the page source and look for \"Enqueued assets\" near the end."
        },
        {
            "id": "dev-log-redirects",
            "version": 1,
            "title": "Log Every Redirect With Its Source",
            "description": "Writes each redirect WordPress makes to the error log, including the target, status code and the file that triggered it, to track down redirect loops.",
            "category": "developer",
            "tags": [
                "debugging",
                "redirects",
                "logs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_redirect', function ( $location, $status ) {\n    $source = '';\n\n    foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) as $frame ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n        if ( isset( $frame['file'] ) && false === strpos( $frame['file'], 'wp-includes' ) ) {\n            $source = str_replace( ABSPATH, '', $frame['file'] ) . ':' . ( $frame['line'] ?? '?' );\n            break;\n        }\n    }\n\n    $from = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';\n\n    error_log( sprintf( '[redirect] %d %s -> %s (from %s)', $status, $from, $location, $source ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n\n    return $location;\n}, 999, 2 );",
            "requires": [],
            "notes": "Meant for troubleshooting; turn it off afterwards as it can write many log lines. Enable WP_DEBUG_LOG to read the entries."
        },
        {
            "id": "dev-phpinfo-page",
            "version": 1,
            "title": "PHP Info Screen for Administrators",
            "description": "Adds a Tools > PHP Info screen showing the full phpinfo() report (extensions, limits, configuration), handy when a host's control panel does not show it.",
            "category": "developer",
            "tags": [
                "php",
                "server",
                "debugging",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'PHP Info', 'PHP Info', 'manage_options', 'wpide-phpinfo', function () {\n        if ( ! current_user_can( 'manage_options' ) ) {\n            return;\n        }\n\n        ob_start();\n        phpinfo(); // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n        $info = (string) ob_get_clean();\n\n        // Keep only the body and scope the table styles to this screen.\n        if ( preg_match( '#<body[^>]*>(.*)</body>#is', $info, $match ) ) {\n            $info = $match[1];\n        }\n\n        echo '<div class=\"wrap\"><h1>PHP Info</h1><style>#wpide-phpinfo table{border-collapse:collapse;width:100%;margin-bottom:1em;background:#fff;}#wpide-phpinfo td,#wpide-phpinfo th{border:1px solid #dcdcde;padding:4px 8px;font-size:12px;vertical-align:top;word-break:break-word;}#wpide-phpinfo .e{background:#f6f7f7;font-weight:600;width:30%;}#wpide-phpinfo h2{margin-top:1.5em;}</style><div id=\"wpide-phpinfo\">';\n        echo wp_kses_post( $info );\n        echo '</div></div>';\n    } );\n} );",
            "requires": [],
            "notes": "The report reveals server details; it is limited to administrators. Remove the snippet when you no longer need it."
        },
        {
            "id": "dev-rename-posts-to-articles",
            "version": 1,
            "title": "Rename \"Posts\" to \"Articles\" in the Admin",
            "description": "Changes the labels of the built-in Posts type to \"Articles\" across the admin menu, screens and editor, for sites where \"posts\" means something else.",
            "category": "developer",
            "tags": [
                "post-types",
                "labels",
                "admin",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'post_type_labels_post', function ( $labels ) {\n    $singular = 'Article';\n    $plural   = 'Articles';\n\n    $map = array(\n        'name'                  => $plural,\n        'singular_name'         => $singular,\n        'add_new_item'          => 'Add New ' . $singular,\n        'edit_item'             => 'Edit ' . $singular,\n        'new_item'              => 'New ' . $singular,\n        'view_item'             => 'View ' . $singular,\n        'view_items'            => 'View ' . $plural,\n        'search_items'          => 'Search ' . $plural,\n        'not_found'             => 'No ' . strtolower( $plural ) . ' found.',\n        'not_found_in_trash'    => 'No ' . strtolower( $plural ) . ' found in Trash.',\n        'all_items'             => 'All ' . $plural,\n        'archives'              => $singular . ' Archives',\n        'attributes'            => $singular . ' Attributes',\n        'insert_into_item'      => 'Insert into ' . strtolower( $singular ),\n        'uploaded_to_this_item' => 'Uploaded to this ' . strtolower( $singular ),\n        'filter_items_list'     => 'Filter ' . strtolower( $plural ) . ' list',\n        'items_list'            => $plural . ' list',\n        'item_published'        => $singular . ' published.',\n        'item_updated'          => $singular . ' updated.',\n        'item_scheduled'        => $singular . ' scheduled.',\n        'menu_name'             => $plural,\n        'name_admin_bar'        => $singular,\n    );\n\n    foreach ( $map as $key => $value ) {\n        $labels->$key = $value;\n    }\n\n    return $labels;\n} );",
            "requires": [],
            "notes": "Change $singular and $plural to any wording, for example News / News."
        },
        {
            "id": "dev-register-menu-locations",
            "version": 1,
            "title": "Register Extra Menu Locations",
            "description": "Adds new menu locations, such as a footer menu and a legal links menu, that you can assign under Appearance > Menus and print with wp_nav_menu() in a classic theme.",
            "category": "developer",
            "tags": [
                "menus",
                "themes",
                "navigation",
                "classic-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    register_nav_menus( array(\n        'wpide-footer' => 'Footer menu',\n        'wpide-legal'  => 'Legal links',\n    ) );\n}, 20 );",
            "requires": [],
            "notes": "Print a location in a template with wp_nav_menu( array( 'theme_location' => 'wpide-footer' ) );. Block themes use the Navigation block instead."
        },
        {
            "id": "dev-hook-inspector",
            "version": 1,
            "title": "Show Which Functions Run on a Hook",
            "description": "Lets administrators add ?wpide_hook=the_content (or any hook name) to a URL and see the callbacks attached to that hook with their priorities and source files, printed at the bottom of the page.",
            "category": "developer",
            "tags": [
                "debugging",
                "hooks",
                "actions",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'shutdown', function () {\n    global $wp_filter;\n\n    if ( empty( $_GET['wpide_hook'] ) || ! current_user_can( 'manage_options' ) || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $hook = sanitize_text_field( wp_unslash( $_GET['wpide_hook'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<div id=\"wpide-hook-inspector\" style=\"position:relative;z-index:99999;margin:20px;padding:16px;background:#fff;border:2px solid #2271b1;font:13px/1.5 monospace;color:#1e1e1e;\">';\n    echo '<strong>Callbacks on \"' . esc_html( $hook ) . '\"</strong><br>';\n\n    if ( empty( $wp_filter[ $hook ] ) ) {\n        echo 'None.</div>';\n        return;\n    }\n\n    foreach ( $wp_filter[ $hook ]->callbacks as $priority => $callbacks ) {\n        foreach ( $callbacks as $callback ) {\n            $fn    = $callback['function'];\n            $label = 'closure';\n            $file  = '';\n\n            try {\n                if ( is_string( $fn ) ) {\n                    $label = $fn;\n                    $ref   = strpos( $fn, '::' ) ? new ReflectionMethod( $fn ) : new ReflectionFunction( $fn );\n                } elseif ( is_array( $fn ) ) {\n                    $label = ( is_object( $fn[0] ) ? get_class( $fn[0] ) : $fn[0] ) . '::' . $fn[1];\n                    $ref   = new ReflectionMethod( $fn[0], $fn[1] );\n                } else {\n                    $ref = new ReflectionFunction( $fn );\n                }\n                $file = str_replace( ABSPATH, '', (string) $ref->getFileName() ) . ':' . $ref->getStartLine();\n            } catch ( ReflectionException $e ) {\n                $file = '';\n            }\n\n            printf( '%1$d &nbsp; %2$s &nbsp; <span style=\"color:#646970;\">%3$s</span><br>', (int) $priority, esc_html( $label ), esc_html( $file ) );\n        }\n    }\n\n    echo '</div>';\n}, 0 );",
            "requires": [],
            "notes": "Only administrators see the output. Remove the parameter to hide it again."
        },
        {
            "id": "dev-disable-plugin-updates-list",
            "version": 1,
            "title": "Prevent Updates for Selected Plugins",
            "description": "Stops update checks and auto-updates for the plugins you list, for example a plugin you patched by hand or must keep on a tested version.",
            "category": "developer",
            "tags": [
                "plugins",
                "updates",
                "maintenance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_frozen_plugins = array( 'example-plugin/example-plugin.php' ); // Plugins to freeze.\n\nadd_filter( 'site_transient_update_plugins', function ( $value ) use ( $wpide_frozen_plugins ) {\n    if ( is_object( $value ) && isset( $value->response ) ) {\n        foreach ( $wpide_frozen_plugins as $plugin ) {\n            if ( isset( $value->response[ $plugin ] ) ) {\n                $value->no_update[ $plugin ] = $value->response[ $plugin ];\n                unset( $value->response[ $plugin ] );\n            }\n        }\n    }\n\n    return $value;\n} );\n\nadd_filter( 'auto_update_plugin', function ( $update, $item ) use ( $wpide_frozen_plugins ) {\n    return isset( $item->plugin ) && in_array( $item->plugin, $wpide_frozen_plugins, true ) ? false : $update;\n}, 10, 2 );",
            "requires": [],
            "notes": "List plugin paths as folder/main-file.php (see the Plugins screen). Remember to update them manually when you are ready."
        },
        {
            "id": "dev-environment-front-banner",
            "version": 1,
            "title": "Staging Banner on the Front End for Logged-In Users",
            "description": "Shows a bright fixed banner at the top of every page for logged-in users on development and staging sites, so nobody edits the wrong copy of the site.",
            "category": "developer",
            "tags": [
                "staging",
                "environment",
                "development",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_body_open', function () {\n    $env = wp_get_environment_type();\n\n    if ( 'production' === $env || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $colors = array(\n        'local'       => '#00a32a',\n        'development' => '#2271b1',\n        'staging'     => '#d63638',\n    );\n\n    printf(\n        '<div class=\"wpide-env-banner\" style=\"position:sticky;top:0;z-index:100000;padding:6px 12px;background:%1$s;color:#fff;text-align:center;font:600 13px/1.4 -apple-system,BlinkMacSystemFont,sans-serif;\">%2$s</div>',\n        esc_attr( isset( $colors[ $env ] ) ? $colors[ $env ] : '#d63638' ),\n        esc_html( strtoupper( $env ) . ' site: changes here do not affect the live site.' )\n    );\n} );",
            "requires": [],
            "notes": "Set WP_ENVIRONMENT_TYPE in wp-config.php; production sites show nothing."
        },
        {
            "id": "dev-custom-image-sizes-in-editor",
            "version": 1,
            "title": "Offer Custom Image Sizes in the Editor",
            "description": "Adds your registered custom image sizes to the size dropdown in the block editor and media modal, so editors can pick them when inserting images.",
            "category": "developer",
            "tags": [
                "images",
                "media",
                "block-editor",
                "sizes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'image_size_names_choose', function ( $sizes ) {\n    // Registered size name => label shown to editors.\n    $labels = array(\n        'medium_large' => 'Medium large',\n    );\n\n    foreach ( wp_get_registered_image_subsizes() as $name => $size ) {\n        if ( ! isset( $sizes[ $name ] ) && isset( $labels[ $name ] ) ) {\n            $sizes[ $name ] = $labels[ $name ];\n        }\n    }\n\n    return $sizes;\n} );",
            "requires": [],
            "notes": "The sizes must already be registered with add_image_size(). Edit $labels to give them readable names."
        },
        {
            "id": "dev-query-monitor-lite",
            "version": 1,
            "title": "Slow Database Query Warning for Admins",
            "description": "When SAVEQUERIES is enabled, lists the slowest database queries of the page with their duration and caller in an HTML comment visible to administrators.",
            "category": "developer",
            "tags": [
                "debugging",
                "database",
                "performance",
                "queries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    global $wpdb;\n\n    $limit = 10; // Number of slowest queries to list.\n\n    if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES || ! current_user_can( 'manage_options' ) || empty( $wpdb->queries ) ) {\n        return;\n    }\n\n    $queries = $wpdb->queries;\n\n    usort( $queries, function ( $a, $b ) {\n        return $b[1] <=> $a[1];\n    } );\n\n    $lines = array( sprintf( 'Slowest %d of %d queries', min( $limit, count( $queries ) ), count( $queries ) ) );\n\n    foreach ( array_slice( $queries, 0, $limit ) as $query ) {\n        $lines[] = sprintf( '%.4fs | %s | %s', $query[1], preg_replace( '/\\s+/', ' ', mb_substr( $query[0], 0, 300 ) ), $query[2] );\n    }\n\n    echo \"\\n<!--\\n\" . esc_html( str_replace( '--', '- -', implode( \"\\n\", $lines ) ) ) . \"\\n-->\\n\";\n}, 9999 );",
            "requires": [],
            "notes": "Add define( 'SAVEQUERIES', true ); to wp-config.php while debugging only; it slows the site down. Change $limit to show more queries."
        },
        {
            "id": "email-branded-html-template",
            "version": 1,
            "title": "Send Plain WordPress Emails in a Branded HTML Layout",
            "description": "Wraps the plain-text emails WordPress sends (password resets, notifications, form plugins) in a simple HTML layout with your site name and clickable links.",
            "category": "email",
            "tags": [
                "email",
                "html",
                "branding",
                "template"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $accent = '#2271b1'; // Brand color for the header bar and links.\n\n    $headers = is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'];\n    $message = (string) $atts['message'];\n\n    if ( stripos( $headers, 'text/html' ) !== false || stripos( $message, '<html' ) !== false || stripos( $message, '<p' ) !== false ) {\n        return $atts;\n    }\n\n    $site = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    $body = make_clickable( nl2br( esc_html( $message ) ) );\n    $body = str_replace( '<a href=', '<a style=\"color:' . esc_attr( $accent ) . ';\" href=', $body );\n\n    $atts['message'] = '<!DOCTYPE html><html><body style=\"margin:0;padding:24px;background:#f0f0f1;font-family:-apple-system,BlinkMacSystemFont,\\'Segoe UI\\',Roboto,sans-serif;\">'\n        . '<table role=\"presentation\" width=\"100%\" style=\"max-width:600px;margin:0 auto;background:#fff;border-radius:6px;overflow:hidden;border-collapse:collapse;\">'\n        . '<tr><td style=\"padding:16px 24px;background:' . esc_attr( $accent ) . ';color:#fff;font-size:18px;font-weight:600;\">' . esc_html( $site ) . '</td></tr>'\n        . '<tr><td style=\"padding:24px;font-size:15px;line-height:1.6;color:#1e1e1e;\">' . $body . '</td></tr>'\n        . '<tr><td style=\"padding:16px 24px;font-size:12px;color:#646970;border-top:1px solid #f0f0f1;\">' . esc_html( home_url() ) . '</td></tr>'\n        . '</table></body></html>';\n\n    $extra = 'Content-Type: text/html; charset=UTF-8';\n\n    if ( is_array( $atts['headers'] ) ) {\n        $atts['headers'][] = $extra;\n    } else {\n        $atts['headers'] = trim( $headers . \"\\n\" . $extra );\n    }\n\n    return $atts;\n}, 20 );",
            "requires": [],
            "notes": "Emails that are already HTML, such as WooCommerce emails, are left untouched. Change $accent to your brand color."
        },
        {
            "id": "email-bcc-archive",
            "version": 1,
            "title": "Copy Every Outgoing Email to an Archive Address",
            "description": "Adds a hidden BCC to every email the site sends, so you keep a copy of password resets, orders and form notifications in one mailbox.",
            "category": "email",
            "tags": [
                "email",
                "bcc",
                "archive",
                "logging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $archive = ''; // For example 'archive@yourdomain.com'.\n\n    if ( ! is_email( $archive ) ) {\n        return $atts;\n    }\n\n    $line = 'Bcc: ' . $archive;\n\n    if ( is_array( $atts['headers'] ) ) {\n        $atts['headers'][] = $line;\n    } else {\n        $atts['headers'] = trim( (string) $atts['headers'] . \"\\n\" . $line );\n    }\n\n    return $atts;\n} );",
            "requires": [],
            "notes": "Set $archive to a mailbox you control. Recipients do not see the BCC address."
        },
        {
            "id": "email-activity-log",
            "version": 1,
            "title": "Email Log Under Tools",
            "description": "Keeps a list of the last 100 emails the site tried to send (date, recipient, subject and whether it failed) and shows it under Tools > Email Log.",
            "category": "email",
            "tags": [
                "email",
                "logging",
                "debugging",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_email_log_max = 100; // Entries to keep.\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_email_log_max ) {\n    $log = get_option( 'wpide_email_log', array() );\n    $log = is_array( $log ) ? $log : array();\n\n    array_unshift( $log, array(\n        'time'    => time(),\n        'to'      => implode( ', ', (array) $atts['to'] ),\n        'subject' => wp_strip_all_tags( (string) $atts['subject'] ),\n        'status'  => 'sent',\n    ) );\n\n    update_option( 'wpide_email_log', array_slice( $log, 0, $wpide_email_log_max ), false );\n\n    return $atts;\n}, 999 );\n\nadd_action( 'wp_mail_failed', function ( $error ) {\n    $log = get_option( 'wpide_email_log', array() );\n\n    if ( is_array( $log ) && isset( $log[0] ) ) {\n        $log[0]['status'] = 'failed: ' . $error->get_error_message();\n        update_option( 'wpide_email_log', $log, false );\n    }\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Email Log', 'Email Log', 'manage_options', 'wpide-email-log', function () {\n        $log = get_option( 'wpide_email_log', array() );\n\n        echo '<div class=\"wrap\"><h1>Email Log</h1>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>Date</th><th>To</th><th>Subject</th><th>Status</th></tr></thead><tbody>';\n\n        if ( empty( $log ) ) {\n            echo '<tr><td colspan=\"4\">No emails logged yet.</td></tr>';\n        }\n\n        foreach ( (array) $log as $row ) {\n            printf(\n                '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td><td>%4$s</td></tr>',\n                esc_html( wp_date( 'Y-m-d H:i:s', (int) $row['time'] ) ),\n                esc_html( $row['to'] ),\n                esc_html( $row['subject'] ),\n                esc_html( $row['status'] )\n            );\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Message bodies are not stored. The log lives in the wpide_email_log option and is capped at $max_entries."
        },
        {
            "id": "email-redirect-on-staging",
            "version": 1,
            "title": "Send All Emails to One Address on Staging Sites",
            "description": "On development and staging sites, delivers every email to a single test address instead of real customers and users, and notes the original recipient in the subject.",
            "category": "email",
            "tags": [
                "email",
                "staging",
                "development",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $test_address = ''; // Inbox that receives every email on non-production sites.\n\n    if ( 'production' === wp_get_environment_type() || ! is_email( $test_address ) ) {\n        return $atts;\n    }\n\n    $original        = implode( ', ', (array) $atts['to'] );\n    $atts['to']      = $test_address;\n    $atts['subject'] = '[To: ' . $original . '] ' . $atts['subject'];\n\n    // Drop CC and BCC headers so nobody else gets a copy.\n    if ( ! empty( $atts['headers'] ) ) {\n        $headers         = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n        $atts['headers'] = array_values( array_filter( $headers, function ( $header ) {\n            return ! preg_match( '/^\\s*(cc|bcc)\\s*:/i', (string) $header );\n        } ) );\n    }\n\n    return $atts;\n}, 999 );",
            "requires": [],
            "notes": "Uses wp_get_environment_type(), so set WP_ENVIRONMENT_TYPE to staging or development in wp-config.php. Production sites are not affected."
        },
        {
            "id": "email-disable-all-on-staging",
            "version": 1,
            "title": "Block All Outgoing Email on Staging Sites",
            "description": "Stops WordPress from sending any email on development, local and staging environments, so test orders and imports never reach real people.",
            "category": "email",
            "tags": [
                "email",
                "staging",
                "development",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return ) {\n    if ( 'production' !== wp_get_environment_type() ) {\n        return false;\n    }\n\n    return $return;\n} );",
            "requires": [],
            "notes": "Set WP_ENVIRONMENT_TYPE in wp-config.php. Emails are silently dropped and wp_mail() returns false."
        },
        {
            "id": "email-test-tool",
            "version": 1,
            "title": "Send a Test Email From the Admin",
            "description": "Adds a Tools > Send Test Email screen where administrators can send a test message to any address and see right away whether WordPress could hand it off.",
            "category": "email",
            "tags": [
                "email",
                "testing",
                "deliverability",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Send Test Email', 'Send Test Email', 'manage_options', 'wpide-test-email', function () {\n        $result = null;\n        $error  = '';\n\n        if ( isset( $_POST['wpide_test_email'] ) && check_admin_referer( 'wpide_test_email' ) && current_user_can( 'manage_options' ) ) {\n            $to = sanitize_email( wp_unslash( $_POST['wpide_test_email'] ) );\n\n            if ( is_email( $to ) ) {\n                $capture = function ( $wp_error ) use ( &$error ) {\n                    $error = $wp_error->get_error_message();\n                };\n                add_action( 'wp_mail_failed', $capture );\n                $result = wp_mail( $to, 'Test email from ' . wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), \"This is a test email sent from \" . home_url() . \" at \" . wp_date( 'Y-m-d H:i:s' ) . '.' );\n                remove_action( 'wp_mail_failed', $capture );\n            } else {\n                $error = 'Please enter a valid email address.';\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Send Test Email</h1>';\n\n        if ( true === $result ) {\n            echo '<div class=\"notice notice-success\"><p>The email was handed to the mail server.</p></div>';\n        } elseif ( false === $result || $error ) {\n            echo '<div class=\"notice notice-error\"><p>' . esc_html( $error ? $error : 'The email could not be sent.' ) . '</p></div>';\n        }\n\n        echo '<form method=\"post\">';\n        wp_nonce_field( 'wpide_test_email' );\n        printf( '<p><label for=\"wpide_test_email\">Send to</label><br><input type=\"email\" class=\"regular-text\" id=\"wpide_test_email\" name=\"wpide_test_email\" value=\"%s\" required></p>', esc_attr( wp_get_current_user()->user_email ) );\n        submit_button( 'Send test email' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "A \"sent\" result means the mail server accepted it; check spam folders if it does not arrive."
        },
        {
            "id": "email-environment-subject-tag",
            "version": 1,
            "title": "Tag Email Subjects on Non-Production Sites",
            "description": "Adds [STAGING] or [DEVELOPMENT] to the start of every email subject sent from a non-production copy of the site, so nobody confuses test emails with real ones.",
            "category": "email",
            "tags": [
                "email",
                "staging",
                "subject"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $env = wp_get_environment_type();\n\n    if ( 'production' !== $env ) {\n        $atts['subject'] = '[' . strtoupper( $env ) . '] ' . $atts['subject'];\n    }\n\n    return $atts;\n} );",
            "requires": [],
            "notes": "Uses wp_get_environment_type(). Production emails are unchanged."
        },
        {
            "id": "email-custom-welcome",
            "version": 1,
            "title": "Custom Welcome Email for New Users",
            "description": "Rewrites the email new users receive with their login details, using your own subject and greeting while keeping the secure link to set a password.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "registration",
                "welcome"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_new_user_notification_email', function ( $email, $user, $blogname ) {\n    if ( ! preg_match( '#(https?://\\S+action=rp\\S+)#', $email['message'], $match ) ) {\n        return $email;\n    }\n\n    $set_password_url = rtrim( $match[1], '>' );\n\n    $email['subject'] = sprintf( 'Welcome to %s', $blogname );\n    $email['message'] = sprintf( \"Hi %s,\\n\\n\", $user->display_name ? $user->display_name : $user->user_login )\n        . sprintf( \"Thanks for joining %s. Your username is: %s\\n\\n\", $blogname, $user->user_login )\n        . \"Set your password here:\\n\" . $set_password_url . \"\\n\\n\"\n        . 'Log in any time at ' . wp_login_url() . \"\\n\\n\"\n        . \"See you soon!\\n\";\n\n    return $email;\n}, 10, 3 );",
            "requires": [],
            "notes": "Keep the $set_password_url line in the message, otherwise users cannot set their password."
        },
        {
            "id": "email-custom-password-reset",
            "version": 1,
            "title": "Friendlier Password Reset Email",
            "description": "Replaces the wording of the password reset email with a clearer message and subject, keeping the reset link intact.",
            "category": "email",
            "tags": [
                "email",
                "password",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'retrieve_password_title', function () {\n    return sprintf( 'Reset your %s password', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) );\n} );\n\nadd_filter( 'retrieve_password_message', function ( $message, $key, $user_login, $user ) {\n    $url = network_site_url( 'wp-login.php?action=rp&key=' . $key . '&login=' . rawurlencode( $user_login ), 'login' );\n\n    return sprintf( \"Hi %s,\\n\\n\", $user->display_name ? $user->display_name : $user_login )\n        . \"We received a request to reset the password for your account.\\n\\n\"\n        . \"Choose a new password here:\\n\" . $url . \"\\n\\n\"\n        . \"If you did not ask for this, you can ignore this email and your password will stay the same.\\n\";\n}, 10, 4 );",
            "requires": [],
            "notes": "The reset link expires after 24 hours by default."
        },
        {
            "id": "email-disable-admin-email-change-notice",
            "version": 1,
            "title": "Stop \"Site Admin Email Changed\" Notifications",
            "description": "Prevents the email sent to the old address when the site's administration email address is changed in Settings > General.",
            "category": "email",
            "tags": [
                "email",
                "admin",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'send_site_admin_email_change_email', '__return_false' );",
            "requires": [],
            "notes": "The confirmation email to the new address is still sent, as WordPress requires it."
        },
        {
            "id": "email-notify-pending-review",
            "version": 1,
            "title": "Email Editors When a Post Is Submitted for Review",
            "description": "Notifies editors and administrators by email when a contributor submits a post for review, with a link to edit it.",
            "category": "email",
            "tags": [
                "email",
                "editorial",
                "notifications",
                "workflow"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    $roles = array( 'editor', 'administrator' ); // Roles that receive the email.\n\n    if ( 'pending' !== $new_status || 'pending' === $old_status || wp_is_post_revision( $post ) ) {\n        return;\n    }\n\n    $emails = get_users( array( 'role__in' => $roles, 'fields' => array( 'user_email' ) ) );\n    $to     = wp_list_pluck( $emails, 'user_email' );\n\n    if ( ! $to ) {\n        return;\n    }\n\n    $author = get_the_author_meta( 'display_name', $post->post_author );\n\n    wp_mail(\n        $to,\n        sprintf( 'Pending review: %s', wp_strip_all_tags( $post->post_title ) ),\n        sprintf( \"%s submitted \\\"%s\\\" for review.\\n\\nReview it here: %s\\n\", $author, wp_strip_all_tags( $post->post_title ), admin_url( 'post.php?post=' . $post->ID . '&action=edit' ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Change $roles to choose who is notified."
        },
        {
            "id": "email-notify-author-published",
            "version": 1,
            "title": "Email Authors When Their Post Is Published",
            "description": "Sends the author a short email with the link when one of their posts goes live, whether published by an editor or on schedule.",
            "category": "email",
            "tags": [
                "email",
                "authors",
                "notifications",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    if ( 'publish' !== $new_status || 'publish' === $old_status || 'post' !== $post->post_type ) {\n        return;\n    }\n\n    if ( (int) $post->post_author === get_current_user_id() ) {\n        return;\n    }\n\n    $author = get_userdata( $post->post_author );\n\n    if ( ! $author || ! is_email( $author->user_email ) ) {\n        return;\n    }\n\n    wp_mail(\n        $author->user_email,\n        sprintf( 'Your post is live: %s', wp_strip_all_tags( $post->post_title ) ),\n        sprintf( \"Hi %s,\\n\\nGood news: \\\"%s\\\" is now published.\\n\\n%s\\n\", $author->display_name, wp_strip_all_tags( $post->post_title ), get_permalink( $post ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Authors are not emailed when they publish their own post."
        },
        {
            "id": "email-notify-role-change",
            "version": 1,
            "title": "Email Alert When a User's Role Changes",
            "description": "Emails the site administrator whenever a user's role changes, which helps spot unexpected privilege changes.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "roles",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'set_user_role', function ( $user_id, $role, $old_roles ) {\n    if ( empty( $old_roles ) ) {\n        return; // New user, not a change.\n    }\n\n    $user  = get_userdata( $user_id );\n    $actor = wp_get_current_user();\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'Role changed for %s', $user->user_login ),\n        sprintf(\n            \"The role of %1\\$s changed from %2\\$s to %3\\$s.\\nChanged by: %4\\$s\\nTime: %5\\$s\\n\",\n            $user->user_login,\n            implode( ', ', (array) $old_roles ),\n            $role,\n            $actor->exists() ? $actor->user_login : 'system',\n            wp_date( 'Y-m-d H:i:s' )\n        )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Sent to the address in Settings > General. Changes made during user registration are ignored."
        },
        {
            "id": "email-extra-admin-recipients",
            "version": 1,
            "title": "Send Admin Notifications to Several Addresses",
            "description": "Delivers emails addressed to the site admin email (new users, comments, updates) to additional team members as well.",
            "category": "email",
            "tags": [
                "email",
                "admin",
                "notifications",
                "team"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $extra = array(); // For example array( 'ops@yourdomain.com', 'owner@yourdomain.com' ).\n\n    $admin = strtolower( (string) get_option( 'admin_email' ) );\n    $to    = array_map( 'trim', is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] ) );\n\n    if ( ! $extra || ! in_array( $admin, array_map( 'strtolower', $to ), true ) ) {\n        return $atts;\n    }\n\n    $atts['to'] = array_values( array_unique( array_merge( $to, array_filter( $extra, 'is_email' ) ) ) );\n\n    return $atts;\n} );",
            "requires": [],
            "notes": "List the extra addresses in $extra. Emails to other recipients are not changed."
        },
        {
            "id": "email-envelope-sender",
            "version": 1,
            "title": "Match the Return-Path to the From Address",
            "description": "Sets the envelope sender (Return-Path) of outgoing emails to the From address, which improves SPF alignment and deliverability on many hosts.",
            "category": "email",
            "tags": [
                "email",
                "deliverability",
                "spf"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    if ( empty( $phpmailer->Sender ) && is_email( $phpmailer->From ) ) {\n        $phpmailer->Sender = $phpmailer->From;\n    }\n} );",
            "requires": [],
            "notes": "Your From address domain must be allowed to send from the server (SPF record)."
        },
        {
            "id": "email-plain-text-alternative",
            "version": 1,
            "title": "Add a Plain-Text Version to HTML Emails",
            "description": "Generates a plain-text alternative for HTML emails that do not include one, which spam filters like and text-only email clients need.",
            "category": "email",
            "tags": [
                "email",
                "deliverability",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    if ( 'text/html' !== $phpmailer->ContentType || '' !== $phpmailer->AltBody ) {\n        return;\n    }\n\n    $text = preg_replace( '#<a[^>]+href=[\"\\']([^\"\\']+)[\"\\'][^>]*>(.*?)</a>#is', '$2 ($1)', $phpmailer->Body );\n    $text = preg_replace( '#<(br|/p|/div|/h[1-6]|/tr|/li)[^>]*>#i', \"\\n\", $text );\n    $text = wp_strip_all_tags( $text );\n    $text = html_entity_decode( $text, ENT_QUOTES, 'UTF-8' );\n    $text = preg_replace( \"/\\n{3,}/\", \"\\n\\n\", $text );\n\n    $phpmailer->AltBody = trim( $text );\n} );",
            "requires": [],
            "notes": "Links are kept as \"text (url)\" in the plain-text version."
        },
        {
            "id": "email-dkim-signing",
            "version": 1,
            "title": "Sign Outgoing Emails With DKIM",
            "description": "Adds a DKIM signature to every email using a private key file and settings stored in wp-config.php, so receiving servers can verify your messages.",
            "category": "email",
            "tags": [
                "email",
                "dkim",
                "deliverability",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    if ( ! defined( 'WPIDE_DKIM_DOMAIN' ) || ! defined( 'WPIDE_DKIM_SELECTOR' ) || ! defined( 'WPIDE_DKIM_PRIVATE_KEY' ) ) {\n        return;\n    }\n\n    if ( ! is_readable( WPIDE_DKIM_PRIVATE_KEY ) ) {\n        return;\n    }\n\n    $phpmailer->DKIM_domain   = WPIDE_DKIM_DOMAIN;\n    $phpmailer->DKIM_selector = WPIDE_DKIM_SELECTOR;\n    $phpmailer->DKIM_private  = WPIDE_DKIM_PRIVATE_KEY;\n    $phpmailer->DKIM_identity = $phpmailer->From;\n\n    if ( defined( 'WPIDE_DKIM_PASSPHRASE' ) ) {\n        $phpmailer->DKIM_passphrase = WPIDE_DKIM_PASSPHRASE;\n    }\n} );",
            "requires": [],
            "notes": "Define WPIDE_DKIM_DOMAIN, WPIDE_DKIM_SELECTOR and WPIDE_DKIM_PRIVATE_KEY (absolute path outside the web root) in wp-config.php and publish the public key in DNS. Does nothing until they are defined."
        },
        {
            "id": "email-comment-moderation-editors",
            "version": 1,
            "title": "Send Comment Moderation Emails to Editors",
            "description": "Sends \"please moderate\" comment emails to every editor as well as the site admin, so comments are approved faster.",
            "category": "email",
            "tags": [
                "email",
                "comments",
                "moderation",
                "editors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_moderation_recipients', function ( $emails ) {\n    $editors = get_users( array( 'role' => 'editor', 'fields' => array( 'user_email' ) ) );\n\n    return array_values( array_unique( array_merge( (array) $emails, wp_list_pluck( $editors, 'user_email' ) ) ) );\n} );",
            "requires": [],
            "notes": "Editors must have an email address on their profile."
        },
        {
            "id": "email-disable-new-user-welcome",
            "version": 1,
            "title": "Stop Login Details Emails to New Users",
            "description": "Prevents WordPress from emailing new users their login details when an account is created, for sites that onboard users another way.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_send_new_user_notification_to_user', '__return_false' );",
            "requires": [],
            "notes": "Users created this way need another route to set a password, such as \"Lost your password?\"."
        },
        {
            "id": "email-failure-admin-notice",
            "version": 1,
            "title": "Admin Notice When an Email Fails to Send",
            "description": "Shows administrators a warning in the dashboard when WordPress could not send an email, with the error message, so delivery problems are noticed quickly.",
            "category": "email",
            "tags": [
                "email",
                "deliverability",
                "admin",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_mail_failed', function ( $error ) {\n    set_transient( 'wpide_last_mail_error', array(\n        'message' => $error->get_error_message(),\n        'time'    => time(),\n    ), DAY_IN_SECONDS );\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $error = get_transient( 'wpide_last_mail_error' );\n\n    if ( ! $error ) {\n        return;\n    }\n\n    delete_transient( 'wpide_last_mail_error' );\n\n    printf(\n        '<div class=\"notice notice-error\"><p><strong>An email could not be sent</strong> (%1$s): %2$s</p></div>',\n        esc_html( human_time_diff( (int) $error['time'] ) . ' ago' ),\n        esc_html( $error['message'] )\n    );\n} );",
            "requires": [],
            "notes": "The notice appears once per failure and can be dismissed by reloading. Pair it with an SMTP snippet to fix delivery."
        },
        {
            "id": "email-woo-new-order-extra-recipient",
            "version": 1,
            "title": "Send New Order Emails to an Extra Address",
            "description": "Adds another recipient, such as a warehouse or fulfillment partner, to the WooCommerce \"New order\" email.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "orders",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_email_recipient_new_order', function ( $recipient ) {\n    $extra = ''; // For example 'warehouse@yourdomain.com'.\n\n    if ( ! $extra ) {\n        return $recipient;\n    }\n\n    return $recipient ? $recipient . ',' . $extra : $extra;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $extra. You can list several addresses separated by commas."
        },
        {
            "id": "email-woo-completed-order-text",
            "version": 1,
            "title": "Add a Message to the Order Completed Email",
            "description": "Inserts your own paragraph, such as care instructions or a review request, above the order details in the WooCommerce \"Order completed\" email.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "orders",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_email_before_order_table', function ( $order, $sent_to_admin, $plain_text, $email ) {\n    $message = 'Thank you for your order! We would love to hear what you think, so keep an eye out for a short review request next week.';\n\n    if ( $sent_to_admin || ! $email || 'customer_completed_order' !== $email->id ) {\n        return;\n    }\n\n    if ( $plain_text ) {\n        echo esc_html( $message ) . \"\\n\\n\";\n    } else {\n        echo '<p>' . esc_html( $message ) . '</p>';\n    }\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $message. Plain-text emails get the message without HTML."
        },
        {
            "id": "email-woo-disable-processing-email",
            "version": 1,
            "title": "Skip the \"Processing Order\" Email to Customers",
            "description": "Stops WooCommerce from sending customers the \"Your order is being processed\" email, for stores that only want to send the completed email.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "orders",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_email_enabled_customer_processing_order', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Customers still get the order completed email. You can also turn this email off in WooCommerce > Settings > Emails."
        },
        {
            "id": "email-woo-product-images",
            "version": 1,
            "title": "Product Images and SKUs in Order Emails",
            "description": "Shows a small product image and the SKU next to each item in WooCommerce order emails, so customers and staff recognize products at a glance.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "images",
                "sku"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_email_order_items_args', function ( $args ) {\n    $show_sku   = true;        // Show the SKU under the product name.\n    $image_size = array( 48, 48 ); // Image size in pixels.\n\n    $args['show_image'] = true;\n    $args['image_size'] = $image_size;\n    $args['show_sku']   = $show_sku;\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $show_sku to false to show images only. Images may be blocked by some email clients until the reader allows them."
        },
        {
            "id": "email-woo-new-order-subject",
            "version": 1,
            "title": "Customer Name and Total in New Order Email Subjects",
            "description": "Changes the subject of the admin \"New order\" email to include the customer's name and order total, for example \"New order #1045 from Jane Doe (49.00)\".",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "orders",
                "subject"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_email_subject_new_order', function ( $subject, $order ) {\n    if ( ! $order instanceof WC_Order ) {\n        return $subject;\n    }\n\n    return sprintf(\n        'New order #%1$s from %2$s (%3$s)',\n        $order->get_order_number(),\n        trim( $order->get_formatted_billing_full_name() ) ? $order->get_formatted_billing_full_name() : $order->get_billing_email(),\n        wp_strip_all_tags( html_entity_decode( wc_price( $order->get_total(), array( 'currency' => $order->get_currency() ) ) ) )\n    );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "email-woo-stock-alert-recipient",
            "version": 1,
            "title": "Send Low and Out-of-Stock Alerts to Your Buyer",
            "description": "Sends WooCommerce low stock and out-of-stock notifications to a purchasing or inventory address instead of the store admin.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "stock",
                "inventory"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_stock_alert_to = ''; // For example 'purchasing@yourdomain.com'.\n\nadd_filter( 'woocommerce_email_recipient_low_stock', function ( $recipient ) use ( $wpide_stock_alert_to ) {\n    return is_email( $wpide_stock_alert_to ) ? $wpide_stock_alert_to : $recipient;\n} );\n\nadd_filter( 'woocommerce_email_recipient_no_stock', function ( $recipient ) use ( $wpide_stock_alert_to ) {\n    return is_email( $wpide_stock_alert_to ) ? $wpide_stock_alert_to : $recipient;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $address. Leave it empty to keep the default recipient."
        },
        {
            "id": "email-woo-attach-terms",
            "version": 1,
            "title": "Attach a PDF to Order Completed Emails",
            "description": "Attaches a file, such as your terms and conditions, a user guide or a returns form, to the WooCommerce \"Order completed\" email.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "attachments",
                "pdf"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_email_attachments', function ( $attachments, $email_id ) {\n    $attachment_id = 0;  // Media Library attachment ID.\n    $file_path     = ''; // Or an absolute path on the server.\n    $email_ids     = array( 'customer_completed_order' ); // Emails that get the file.\n\n    if ( ! in_array( $email_id, $email_ids, true ) ) {\n        return $attachments;\n    }\n\n    $path = $attachment_id ? get_attached_file( $attachment_id ) : $file_path;\n\n    if ( $path && is_readable( $path ) ) {\n        $attachments[] = $path;\n    }\n\n    return $attachments;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Upload the PDF to the Media Library and set $attachment_id, or set $file_path to an absolute server path."
        },
        {
            "id": "email-woo-customer-note-in-admin-email",
            "version": 1,
            "title": "Highlight the Customer's Order Note in Admin Emails",
            "description": "Shows the note the customer wrote at checkout in a highlighted box at the top of the admin \"New order\" email, so special requests are not missed.",
            "category": "email",
            "tags": [
                "email",
                "woocommerce",
                "orders",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_email_before_order_table', function ( $order, $sent_to_admin, $plain_text ) {\n    if ( ! $sent_to_admin || ! $order instanceof WC_Order ) {\n        return;\n    }\n\n    $note = $order->get_customer_note();\n\n    if ( '' === trim( $note ) ) {\n        return;\n    }\n\n    if ( $plain_text ) {\n        echo \"CUSTOMER NOTE:\\n\" . esc_html( $note ) . \"\\n\\n\";\n        return;\n    }\n\n    echo '<div style=\"margin:0 0 20px;padding:12px 16px;border-left:4px solid #dba617;background:#fcf9e8;\"><strong>Customer note:</strong><br>' . nl2br( esc_html( $note ) ) . '</div>';\n}, 5, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only appears when the customer left a note."
        },
        {
            "id": "frontend-sticky-header-shadow",
            "version": 1,
            "title": "Sticky Header That Shrinks on Scroll",
            "description": "Keeps your theme's header pinned to the top of the screen and adds a shadow and smaller padding once the visitor scrolls down.",
            "category": "frontend",
            "tags": [
                "header",
                "sticky",
                "javascript",
                "css"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-sticky-header{position:sticky;top:0;z-index:999;transition:box-shadow .2s ease,padding .2s ease;}\n.admin-bar .wpide-sticky-header{top:32px;}\n@media (max-width:782px){.admin-bar .wpide-sticky-header{top:46px;}}\n.wpide-sticky-header.is-scrolled{box-shadow:0 2px 12px rgba(0,0,0,.12);padding-top:6px !important;padding-bottom:6px !important;}\n</style>\n<script>\n(function () {\n    var header = document.querySelector('.site-header, header.wp-block-template-part, body > header, #masthead');\n    if (!header) { return; }\n    header.classList.add('wpide-sticky-header');\n    var toggle = function () { header.classList.toggle('is-scrolled', window.pageYOffset > 40); };\n    window.addEventListener('scroll', toggle, { passive: true });\n    toggle();\n})();\n</script>",
            "requires": [],
            "notes": "Change the selector in the script if your theme's header is not a <header> element or .site-header."
        },
        {
            "id": "frontend-dark-mode-toggle",
            "version": 1,
            "title": "Dark Mode Toggle Button",
            "description": "Adds a floating moon button that switches the site to a dark color scheme, remembers the visitor's choice and follows their system setting on the first visit.",
            "category": "frontend",
            "tags": [
                "dark-mode",
                "accessibility",
                "css",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<button type=\"button\" id=\"wpide-dark-toggle\" aria-label=\"Toggle dark mode\" aria-pressed=\"false\">&#9790;</button>\n<style>\n#wpide-dark-toggle{position:fixed;left:20px;bottom:20px;z-index:9999;width:42px;height:42px;border:0;border-radius:50%;background:#1e1e1e;color:#fff;font-size:20px;line-height:42px;cursor:pointer;box-shadow:0 2px 8px rgba(0,0,0,.25);}\nhtml.wpide-dark{filter:invert(1) hue-rotate(180deg);background:#fff;}\nhtml.wpide-dark img,html.wpide-dark video,html.wpide-dark iframe,html.wpide-dark picture,html.wpide-dark [style*=\"background-image\"],html.wpide-dark #wpide-dark-toggle{filter:invert(1) hue-rotate(180deg);}\n</style>\n<script>\n(function () {\n    var root = document.documentElement, button = document.getElementById('wpide-dark-toggle');\n    var stored = null;\n    try { stored = localStorage.getItem('wpide-dark'); } catch (e) {}\n    var dark = stored !== null ? stored === '1' : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);\n    function apply() {\n        root.classList.toggle('wpide-dark', dark);\n        button.setAttribute('aria-pressed', dark ? 'true' : 'false');\n        button.innerHTML = dark ? '&#9728;' : '&#9790;';\n    }\n    button.addEventListener('click', function () {\n        dark = !dark;\n        try { localStorage.setItem('wpide-dark', dark ? '1' : '0'); } catch (e) {}\n        apply();\n    });\n    apply();\n})();\n</script>",
            "requires": [],
            "notes": "This uses a color inversion that works on most themes; photos and videos are re-inverted so they look normal. Tune the colors if your theme has its own dark palette."
        },
        {
            "id": "frontend-image-lightbox",
            "version": 1,
            "title": "Lightweight Image Lightbox",
            "description": "Opens linked images in post content in a full-screen overlay instead of a new page, with keyboard and click-to-close support and no external library.",
            "category": "frontend",
            "tags": [
                "images",
                "lightbox",
                "javascript",
                "gallery"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div id=\"wpide-lightbox\" hidden role=\"dialog\" aria-modal=\"true\" aria-label=\"Image viewer\" style=\"position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(0,0,0,.88);cursor:zoom-out;\">\n    <img alt=\"\" style=\"max-width:100%;max-height:100%;box-shadow:0 8px 40px rgba(0,0,0,.5);\">\n</div>\n<script>\n(function () {\n    var box = document.getElementById('wpide-lightbox');\n    if (!box) { return; }\n    var img = box.querySelector('img'), last = null;\n    function close() { box.hidden = true; img.removeAttribute('src'); if (last) { last.focus(); } }\n    document.addEventListener('click', function (e) {\n        var link = e.target.closest ? e.target.closest('a[href]') : null;\n        if (!link || !link.querySelector('img') || !/\\.(jpe?g|png|gif|webp|avif)(\\?.*)?$/i.test(link.getAttribute('href'))) { return; }\n        e.preventDefault();\n        last = link;\n        img.src = link.href;\n        img.alt = (link.querySelector('img').alt || '');\n        box.hidden = false;\n        box.focus();\n    });\n    box.setAttribute('tabindex', '-1');\n    box.addEventListener('click', close);\n    document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !box.hidden) { close(); } });\n})();\n</script>",
            "requires": [],
            "notes": "Works on images that link to their media file (set \"Link to: Media File\" in the image block). Press Escape or click anywhere to close."
        },
        {
            "id": "frontend-table-of-contents",
            "version": 1,
            "title": "Automatic Table of Contents for Long Posts",
            "description": "Builds a clickable table of contents from the H2 and H3 headings of long posts and places it above the content, adding anchor IDs to the headings.",
            "category": "frontend",
            "tags": [
                "table-of-contents",
                "headings",
                "navigation",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    $min_headings = 3;            // Show the table only with this many headings or more.\n    $post_types   = array( 'post' );\n    $title        = 'Contents';\n\n    if ( ! is_singular( $post_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    if ( ! preg_match_all( '#<h([23])([^>]*)>(.*?)</h\\1>#is', $content, $matches, PREG_SET_ORDER ) || count( $matches ) < $min_headings ) {\n        return $content;\n    }\n\n    $items = '';\n    $used  = array();\n\n    foreach ( $matches as $match ) {\n        $text = wp_strip_all_tags( $match[3] );\n\n        if ( preg_match( '#\\sid=[\"\\']([^\"\\']+)[\"\\']#', $match[2], $id_match ) ) {\n            $id = $id_match[1];\n        } else {\n            $id   = sanitize_title( $text );\n            $id   = $id ? $id : 'section';\n            $base = $id;\n            $n    = 2;\n\n            while ( isset( $used[ $id ] ) ) {\n                $id = $base . '-' . $n++;\n            }\n\n            $content = preg_replace( '#' . preg_quote( $match[0], '#' ) . '#', '<h' . $match[1] . $match[2] . ' id=\"' . esc_attr( $id ) . '\">' . $match[3] . '</h' . $match[1] . '>', $content, 1 );\n        }\n\n        $used[ $id ] = true;\n        $items      .= sprintf( '<li class=\"wpide-toc-h%1$s\"><a href=\"#%2$s\">%3$s</a></li>', esc_attr( $match[1] ), esc_attr( $id ), esc_html( $text ) );\n    }\n\n    $toc = '<nav class=\"wpide-toc\" aria-label=\"' . esc_attr( $title ) . '\" style=\"margin:0 0 2em;padding:16px 20px;border:1px solid rgba(127,127,127,.3);border-radius:6px;\">'\n        . '<p style=\"margin:0 0 .5em;font-weight:600;\">' . esc_html( $title ) . '</p><ul style=\"margin:0;padding-left:1.2em;\">' . $items . '</ul></nav>'\n        . '<style>.wpide-toc-h3{margin-left:1em;list-style:circle;}</style>';\n\n    return $toc . $content;\n}, 20 );",
            "requires": [],
            "notes": "Appears on posts with at least $min_headings headings. Change $post_types to use it on pages too."
        },
        {
            "id": "frontend-external-link-icon",
            "version": 1,
            "title": "Icon After External Links",
            "description": "Adds a small arrow icon after links in post content that point to other websites, so readers know they will leave your site.",
            "category": "frontend",
            "tags": [
                "links",
                "css",
                "accessibility",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $host = wp_parse_url( home_url(), PHP_URL_HOST );\n\n    if ( ! $host ) {\n        return;\n    }\n\n    $own  = esc_attr( $host );\n    $not  = ':not([href*=\"//' . $own . '\"]):not(.no-external-icon):not(.wp-block-button__link):not(:has(img))';\n    $sels = array();\n\n    foreach ( array( '.entry-content', '.wp-block-post-content' ) as $scope ) {\n        $sels[] = $scope . ' a[href^=\"http\"]' . $not . '::after';\n    }\n\n    echo '<style id=\"wpide-external-icon\">' . implode( ',', $sels ) . '{content:\"\\\\2197\";display:inline-block;margin-left:.2em;font-size:.8em;text-decoration:none;}</style>' . \"\\n\"; // phpcs:ignore WordPress.Security.EscapeOutput -- built from escaped parts.\n} );",
            "requires": [],
            "notes": "Your own domain is detected automatically. Exclude a link by adding the class no-external-icon to it."
        },
        {
            "id": "frontend-responsive-tables",
            "version": 1,
            "title": "Scrollable Tables on Mobile",
            "description": "Lets wide tables in your content scroll sideways on small screens instead of breaking the layout or overflowing the page.",
            "category": "frontend",
            "tags": [
                "tables",
                "mobile",
                "responsive",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".entry-content table,\n.wp-block-post-content table:not(.wp-block-table table) {\n    display: block;\n    max-width: 100%;\n    overflow-x: auto;\n    -webkit-overflow-scrolling: touch;\n}\n\n.entry-content table th,\n.entry-content table td {\n    white-space: nowrap;\n}\n\n@media (min-width: 782px) {\n    .entry-content table th,\n    .entry-content table td {\n        white-space: normal;\n    }\n}",
            "requires": [],
            "notes": "The Table block already scrolls in most block themes; this mainly helps classic themes and tables pasted from other sources."
        },
        {
            "id": "frontend-selection-color",
            "version": 1,
            "title": "Brand Color for Text Selection",
            "description": "Changes the highlight color when visitors select text on your site to match your brand.",
            "category": "frontend",
            "tags": [
                "css",
                "branding",
                "design"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "::selection {\n    background: #2271b1;\n    color: #ffffff;\n}",
            "requires": [],
            "notes": "Change the two colors. Keep enough contrast between background and text."
        },
        {
            "id": "frontend-custom-scrollbar",
            "version": 1,
            "title": "Slim Custom Scrollbar",
            "description": "Replaces the default browser scrollbar with a thinner one in your brand colors on desktop browsers.",
            "category": "frontend",
            "tags": [
                "css",
                "scrollbar",
                "design"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "html {\n    scrollbar-width: thin;\n    scrollbar-color: #2271b1 #f0f0f1;\n}\n\n::-webkit-scrollbar {\n    width: 10px;\n    height: 10px;\n}\n\n::-webkit-scrollbar-track {\n    background: #f0f0f1;\n}\n\n::-webkit-scrollbar-thumb {\n    background: #2271b1;\n    border-radius: 10px;\n    border: 2px solid #f0f0f1;\n}",
            "requires": [],
            "notes": "Mobile browsers keep their native overlay scrollbars."
        },
        {
            "id": "frontend-whatsapp-button",
            "version": 1,
            "title": "Floating WhatsApp Chat Button",
            "description": "Adds a round WhatsApp button in the bottom corner of every page that opens a chat with your number and a pre-filled message.",
            "category": "frontend",
            "tags": [
                "whatsapp",
                "chat",
                "contact",
                "button"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $phone   = '';                                    // International format, digits only.\n    $message = 'Hi! I have a question about your website.';\n    $side    = 'right';                               // 'right' or 'left'.\n\n    $phone = preg_replace( '/\\D/', '', $phone );\n\n    if ( '' === $phone ) {\n        return;\n    }\n\n    $url = 'https://wa.me/' . $phone . '?text=' . rawurlencode( $message );\n\n    printf(\n        '<a href=\"%1$s\" class=\"wpide-whatsapp\" target=\"_blank\" rel=\"noopener\" aria-label=\"Chat with us on WhatsApp\" style=\"position:fixed;%2$s:20px;bottom:20px;z-index:9999;width:56px;height:56px;border-radius:50%%;background:#25d366;display:flex;align-items:center;justify-content:center;box-shadow:0 4px 12px rgba(0,0,0,.25);\"><svg width=\"30\" height=\"30\" viewBox=\"0 0 24 24\" fill=\"#fff\" aria-hidden=\"true\"><path d=\"M12 2a10 10 0 0 0-8.6 15.1L2 22l5-1.3A10 10 0 1 0 12 2zm0 18.2a8.2 8.2 0 0 1-4.2-1.1l-.3-.2-3 .8.8-2.9-.2-.3A8.2 8.2 0 1 1 12 20.2zm4.5-6.1c-.2-.1-1.5-.7-1.7-.8s-.4-.1-.6.1-.6.8-.8 1-.3.2-.5.1a6.7 6.7 0 0 1-3.3-2.9c-.3-.4.3-.4.7-1.3a.5.5 0 0 0 0-.5l-.8-1.8c-.2-.5-.4-.4-.6-.4h-.5a1 1 0 0 0-.7.3 3 3 0 0 0-.9 2.2 5.2 5.2 0 0 0 1.1 2.7 11.8 11.8 0 0 0 4.5 4c1.7.7 2.3.8 3.2.6a2.7 2.7 0 0 0 1.8-1.3 2.2 2.2 0 0 0 .1-1.3c0-.1-.2-.2-.5-.3z\"/></svg></a>',\n        esc_url( $url ),\n        'left' === $side ? 'left' : 'right'\n    );\n} );",
            "requires": [],
            "notes": "Set $phone in international format without + or spaces (for example 15550100000). Nothing is shown while it is empty."
        },
        {
            "id": "frontend-mobile-call-bar",
            "version": 1,
            "title": "Click-to-Call Bar on Mobile",
            "description": "Shows a full-width \"Call us now\" bar fixed to the bottom of the screen on phones, so mobile visitors can call your business with one tap.",
            "category": "frontend",
            "tags": [
                "mobile",
                "phone",
                "contact",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $phone = '';             // For example '+1 555 010 0000'.\n    $label = 'Call us now';\n\n    if ( '' === trim( $phone ) ) {\n        return;\n    }\n\n    printf(\n        '<a class=\"wpide-call-bar\" href=\"%1$s\">&#128222; %2$s</a><style>.wpide-call-bar{display:none;}@media (max-width:767px){.wpide-call-bar{display:block;position:fixed;left:0;right:0;bottom:0;z-index:9999;padding:14px;background:#00a32a;color:#fff !important;text-align:center;font-weight:600;text-decoration:none;}body{padding-bottom:52px;}}</style>',\n        esc_url( 'tel:' . preg_replace( '/[^0-9+]/', '', $phone ) ),\n        esc_html( $label )\n    );\n} );",
            "requires": [],
            "notes": "Set $phone and $label. The bar only appears on screens narrower than 768px."
        },
        {
            "id": "frontend-share-buttons",
            "version": 1,
            "title": "Simple Share Buttons After Posts",
            "description": "Adds lightweight share links for X, Facebook, LinkedIn, WhatsApp and email at the end of each post, with no third-party scripts or tracking.",
            "category": "frontend",
            "tags": [
                "social",
                "sharing",
                "content",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $url   = rawurlencode( get_permalink() );\n    $title = rawurlencode( wp_strip_all_tags( get_the_title() ) );\n\n    // Network => share URL.\n    $networks = array(\n        'X'        => \"https://x.com/intent/post?url={$url}&text={$title}\",\n        'Facebook' => \"https://www.facebook.com/sharer/sharer.php?u={$url}\",\n        'LinkedIn' => \"https://www.linkedin.com/sharing/share-offsite/?url={$url}\",\n        'WhatsApp' => \"https://wa.me/?text={$title}%20{$url}\",\n        'Email'    => \"mailto:?subject={$title}&body={$url}\",\n    );\n\n    $links = '';\n\n    foreach ( $networks as $name => $share ) {\n        $links .= sprintf(\n            '<a href=\"%1$s\" %2$s style=\"display:inline-block;margin:0 8px 8px 0;padding:6px 14px;border:1px solid currentColor;border-radius:20px;font-size:14px;text-decoration:none;\">%3$s</a>',\n            esc_url( $share ),\n            'Email' === $name ? '' : 'target=\"_blank\" rel=\"noopener nofollow\"',\n            esc_html( $name )\n        );\n    }\n\n    return $content . '<div class=\"wpide-share\" style=\"margin-top:2em;\"><p style=\"margin-bottom:.5em;font-weight:600;\">Share this post</p>' . $links . '</div>';\n}, 30 );",
            "requires": [],
            "notes": "Edit $networks to remove networks you do not need. Uses plain links, so nothing loads until a visitor clicks."
        },
        {
            "id": "frontend-fade-in-images",
            "version": 1,
            "title": "Fade In Images as They Load",
            "description": "Makes content images fade in smoothly when they finish loading instead of popping in, for a more polished feel.",
            "category": "frontend",
            "tags": [
                "images",
                "animation",
                "css",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n@media (prefers-reduced-motion: no-preference) {\n    .wpide-fade{opacity:0;transition:opacity .5s ease;}\n    .wpide-fade.is-loaded{opacity:1;}\n}\n</style>\n<script>\n(function () {\n    document.querySelectorAll('.entry-content img, .wp-block-post-content img, .wp-block-image img').forEach(function (img) {\n        if (img.complete && img.naturalWidth) { return; }\n        img.classList.add('wpide-fade');\n        var done = function () { img.classList.add('is-loaded'); };\n        img.addEventListener('load', done, { once: true });\n        img.addEventListener('error', done, { once: true });\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Respects the reduced-motion accessibility setting. Images that are already loaded are shown immediately."
        },
        {
            "id": "frontend-content-protection",
            "version": 1,
            "title": "Discourage Copying Images and Text",
            "description": "Disables right-click and dragging on images, and optionally text selection in post content, to discourage casual copying of your work.",
            "category": "frontend",
            "tags": [
                "protection",
                "images",
                "copy",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    var blockTextSelection = false; // true: also prevent selecting text in post content.\n    document.addEventListener('contextmenu', function (e) {\n        if (e.target.tagName === 'IMG') { e.preventDefault(); }\n    });\n    document.addEventListener('dragstart', function (e) {\n        if (e.target.tagName === 'IMG') { e.preventDefault(); }\n    });\n    if (blockTextSelection) {\n        var style = document.createElement('style');\n        style.textContent = '.entry-content,.wp-block-post-content{-webkit-user-select:none;user-select:none;}';\n        document.head.appendChild(style);\n    }\n})();\n</script>",
            "requires": [],
            "notes": "This only deters casual copying; anything shown in a browser can still be saved. Text selection is left on by default for accessibility."
        },
        {
            "id": "frontend-exit-intent-popup",
            "version": 1,
            "title": "Exit-Intent Popup",
            "description": "Shows a popup with your offer or newsletter link when a desktop visitor moves the mouse toward closing the tab, at most once every 30 days.",
            "category": "frontend",
            "tags": [
                "popup",
                "exit-intent",
                "newsletter",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div id=\"wpide-exit\" hidden role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"wpide-exit-title\" style=\"position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:16px;background:rgba(0,0,0,.6);\">\n    <div style=\"position:relative;max-width:440px;width:100%;padding:32px 28px;border-radius:10px;background:#fff;color:#1e1e1e;text-align:center;\">\n        <button type=\"button\" data-close aria-label=\"Close\" style=\"position:absolute;top:8px;right:12px;border:0;background:none;font-size:26px;line-height:1;cursor:pointer;\">&times;</button>\n        <h2 id=\"wpide-exit-title\" style=\"margin-top:0;\">Before you go...</h2>\n        <p>Get 10% off your first order when you join our newsletter.</p>\n        <p style=\"margin-bottom:0;\"><a class=\"wp-element-button button\" href=\"/newsletter/\">Get my discount</a></p>\n    </div>\n</div>\n<script>\n(function () {\n    var box = document.getElementById('wpide-exit'), days = 30, key = 'wpide-exit-shown';\n    if (!box || window.matchMedia('(pointer: coarse)').matches) { return; }\n    try { var last = parseInt(localStorage.getItem(key), 10); if (last && Date.now() - last < days * 86400000) { return; } } catch (e) {}\n    function show() {\n        document.removeEventListener('mouseout', onOut);\n        box.hidden = false;\n        try { localStorage.setItem(key, String(Date.now())); } catch (e) {}\n    }\n    function onOut(e) { if (!e.relatedTarget && e.clientY <= 0) { show(); } }\n    setTimeout(function () { document.addEventListener('mouseout', onOut); }, 5000);\n    box.addEventListener('click', function (e) { if (e.target === box || e.target.hasAttribute('data-close')) { box.hidden = true; } });\n    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { box.hidden = true; } });\n})();\n</script>",
            "requires": [],
            "notes": "Edit the heading, text and link. Change the 30 in the script to show it more or less often."
        },
        {
            "id": "frontend-scroll-reveal",
            "version": 1,
            "title": "Reveal Blocks on Scroll",
            "description": "Fades and slides content blocks into view as the visitor scrolls. Add the class \"reveal\" to any block in the editor (Advanced > Additional CSS class) to animate it.",
            "category": "frontend",
            "tags": [
                "animation",
                "scroll",
                "blocks",
                "css"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n@media (prefers-reduced-motion: no-preference) {\n    .reveal{opacity:0;transform:translateY(24px);transition:opacity .6s ease,transform .6s ease;}\n    .reveal.is-visible{opacity:1;transform:none;}\n}\n</style>\n<script>\n(function () {\n    var items = document.querySelectorAll('.reveal');\n    if (!items.length) { return; }\n    if (!('IntersectionObserver' in window)) { items.forEach(function (el) { el.classList.add('is-visible'); }); return; }\n    var observer = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (entry.isIntersecting) { entry.target.classList.add('is-visible'); observer.unobserve(entry.target); }\n        });\n    }, { rootMargin: '0px 0px -10% 0px' });\n    items.forEach(function (el) { observer.observe(el); });\n})();\n</script>",
            "requires": [],
            "notes": "Respects the reduced-motion accessibility setting. Use \"reveal\" on groups, columns, images or headings."
        },
        {
            "id": "frontend-visibility-utility-classes",
            "version": 1,
            "title": "Hide on Mobile / Hide on Desktop Classes",
            "description": "Adds utility classes you can put on any block (hide-on-mobile, hide-on-tablet, hide-on-desktop) to show different content on different screen sizes.",
            "category": "frontend",
            "tags": [
                "responsive",
                "mobile",
                "css",
                "blocks"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-width: 767px) {\n    .hide-on-mobile {\n        display: none !important;\n    }\n}\n\n@media (min-width: 768px) and (max-width: 1023px) {\n    .hide-on-tablet {\n        display: none !important;\n    }\n}\n\n@media (min-width: 1024px) {\n    .hide-on-desktop {\n        display: none !important;\n    }\n}",
            "requires": [],
            "notes": "Add the class under Advanced > Additional CSS class(es) in the block settings. Breakpoints are 768px and 1024px."
        },
        {
            "id": "frontend-sticky-sidebar",
            "version": 1,
            "title": "Sticky Sidebar",
            "description": "Keeps the sidebar visible while visitors scroll through long posts on desktop screens, so your widgets and calls to action stay in view.",
            "category": "frontend",
            "tags": [
                "sidebar",
                "sticky",
                "css",
                "layout"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (min-width: 1024px) {\n    #secondary,\n    .widget-area,\n    .sidebar {\n        position: sticky;\n        top: 2rem;\n        align-self: flex-start;\n    }\n\n    .admin-bar #secondary,\n    .admin-bar .widget-area,\n    .admin-bar .sidebar {\n        top: calc(2rem + 32px);\n    }\n}",
            "requires": [],
            "notes": "Works when the sidebar is shorter than the screen. Change the selector if your theme uses a different sidebar class."
        },
        {
            "id": "frontend-focus-visible-outline",
            "version": 1,
            "title": "Clear Keyboard Focus Outlines",
            "description": "Shows a strong, visible outline around links, buttons and form fields when visitors navigate with the keyboard, which many themes hide. Mouse clicks are not affected.",
            "category": "frontend",
            "tags": [
                "accessibility",
                "keyboard",
                "focus",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "a:focus-visible,\nbutton:focus-visible,\ninput:focus-visible,\nselect:focus-visible,\ntextarea:focus-visible,\nsummary:focus-visible,\n[tabindex]:focus-visible {\n    outline: 3px solid #2271b1 !important;\n    outline-offset: 2px !important;\n    box-shadow: none !important;\n}",
            "requires": [],
            "notes": "Change the outline color to one with good contrast against your backgrounds."
        },
        {
            "id": "frontend-skip-to-content",
            "version": 1,
            "title": "\"Skip to Content\" Link",
            "description": "Adds a hidden \"Skip to content\" link that appears when keyboard users press Tab, letting them jump past the header and menu, for themes that lack one.",
            "category": "frontend",
            "tags": [
                "accessibility",
                "keyboard",
                "navigation"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_body",
            "insert_number": 1,
            "code": "<a class=\"wpide-skip-link\" href=\"#main\">Skip to content</a>\n<style>\n.wpide-skip-link{position:absolute;left:-9999px;top:0;z-index:100001;padding:10px 16px;background:#1e1e1e;color:#fff;text-decoration:none;}\n.wpide-skip-link:focus{left:10px;top:10px;}\n</style>\n<script>\n(function () {\n    var link = document.querySelector('.wpide-skip-link');\n    var target = document.querySelector('#main, main, .site-main, #content');\n    if (!link || !target) { return; }\n    if (!target.id) { target.id = 'wpide-main'; }\n    if (!target.hasAttribute('tabindex')) { target.setAttribute('tabindex', '-1'); }\n    link.setAttribute('href', '#' + target.id);\n})();\n</script>",
            "requires": [],
            "notes": "The link targets #main, main or .site-main, whichever exists first. Your theme must call wp_body_open."
        },
        {
            "id": "frontend-system-font-stack",
            "version": 1,
            "title": "Use the System Font Stack",
            "description": "Switches the site's text to the fonts already installed on each visitor's device (San Francisco, Segoe UI, Roboto), which look native and load instantly.",
            "category": "frontend",
            "tags": [
                "fonts",
                "typography",
                "performance",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "body,\nbutton,\ninput,\nselect,\ntextarea,\nh1, h2, h3, h4, h5, h6 {\n    font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, \"Noto Sans\", sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\";\n}\n\ncode,\nkbd,\npre,\nsamp {\n    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n}",
            "requires": [],
            "notes": "Code blocks keep a monospace font. Remove \"h1-h6\" from the selector to keep your theme's heading font."
        },
        {
            "id": "frontend-details-accordion-style",
            "version": 1,
            "title": "Nicer Styling for Details (Accordion) Blocks",
            "description": "Gives the Details block and other summary/details elements a clean accordion look with a border, padding and a rotating arrow.",
            "category": "frontend",
            "tags": [
                "accordion",
                "details",
                "blocks",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "details {\n    margin: 0 0 12px;\n    border: 1px solid rgba(127, 127, 127, .3);\n    border-radius: 6px;\n    padding: 0 16px;\n}\n\ndetails > summary {\n    position: relative;\n    padding: 14px 28px 14px 0;\n    font-weight: 600;\n    cursor: pointer;\n    list-style: none;\n}\n\ndetails > summary::-webkit-details-marker {\n    display: none;\n}\n\ndetails > summary::after {\n    content: \"\";\n    position: absolute;\n    right: 4px;\n    top: 50%;\n    width: 8px;\n    height: 8px;\n    border-right: 2px solid currentColor;\n    border-bottom: 2px solid currentColor;\n    transform: translateY(-70%) rotate(45deg);\n    transition: transform .2s ease;\n}\n\ndetails[open] > summary::after {\n    transform: translateY(-30%) rotate(-135deg);\n}\n\ndetails[open] {\n    padding-bottom: 14px;\n}",
            "requires": [],
            "notes": "Applies to every details element on the front end."
        },
        {
            "id": "frontend-breadcrumbs-shortcode",
            "version": 1,
            "title": "Breadcrumbs Shortcode",
            "description": "Adds a [wpide_breadcrumbs] shortcode that prints Home > Category > Post style breadcrumbs with schema.org markup, for themes that do not have them.",
            "category": "frontend",
            "tags": [
                "breadcrumbs",
                "navigation",
                "seo",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_breadcrumbs', function () {\n    $crumbs = array( array( 'Home', home_url( '/' ) ) );\n\n    if ( is_singular() ) {\n        $post = get_queried_object();\n\n        if ( 'post' === $post->post_type ) {\n            $cats = get_the_category( $post->ID );\n\n            if ( $cats ) {\n                foreach ( array_reverse( get_ancestors( $cats[0]->term_id, 'category' ) ) as $ancestor ) {\n                    $crumbs[] = array( get_cat_name( $ancestor ), get_category_link( $ancestor ) );\n                }\n                $crumbs[] = array( $cats[0]->name, get_category_link( $cats[0] ) );\n            }\n        } elseif ( is_post_type_hierarchical( $post->post_type ) ) {\n            foreach ( array_reverse( get_post_ancestors( $post ) ) as $ancestor ) {\n                $crumbs[] = array( get_the_title( $ancestor ), get_permalink( $ancestor ) );\n            }\n        } elseif ( get_post_type_archive_link( $post->post_type ) ) {\n            $crumbs[] = array( get_post_type_object( $post->post_type )->labels->name, get_post_type_archive_link( $post->post_type ) );\n        }\n\n        $crumbs[] = array( get_the_title( $post ), '' );\n    } elseif ( is_category() || is_tag() || is_tax() ) {\n        $crumbs[] = array( single_term_title( '', false ), '' );\n    } elseif ( is_search() ) {\n        $crumbs[] = array( 'Search results', '' );\n    } elseif ( is_404() ) {\n        $crumbs[] = array( 'Page not found', '' );\n    } elseif ( is_archive() ) {\n        $crumbs[] = array( wp_strip_all_tags( get_the_archive_title() ), '' );\n    }\n\n    $html = '<nav class=\"wpide-breadcrumbs\" aria-label=\"Breadcrumb\"><ol itemscope itemtype=\"https://schema.org/BreadcrumbList\" style=\"display:flex;flex-wrap:wrap;gap:.4em;list-style:none;margin:0 0 1em;padding:0;font-size:.9em;\">';\n\n    foreach ( $crumbs as $i => $crumb ) {\n        $html .= '<li itemprop=\"itemListElement\" itemscope itemtype=\"https://schema.org/ListItem\">' . ( $i ? '<span aria-hidden=\"true\">&rsaquo; </span>' : '' );\n        $html .= $crumb[1]\n            ? '<a itemprop=\"item\" href=\"' . esc_url( $crumb[1] ) . '\"><span itemprop=\"name\">' . esc_html( $crumb[0] ) . '</span></a>'\n            : '<span itemprop=\"name\" aria-current=\"page\">' . esc_html( $crumb[0] ) . '</span>';\n        $html .= '<meta itemprop=\"position\" content=\"' . ( $i + 1 ) . '\"></li>';\n    }\n\n    return $html . '</ol></nav>';\n} );",
            "requires": [],
            "notes": "Place it in a Shortcode block in your single post or page template. If an SEO plugin already outputs breadcrumb schema, use only one of them."
        },
        {
            "id": "frontend-login-logout-menu-item",
            "version": 1,
            "title": "Log In / Log Out Item in the Menu",
            "description": "Appends a \"Log in\" link to your main menu for visitors and a \"Log out\" link for logged-in users, returning them to the same page.",
            "category": "frontend",
            "tags": [
                "menu",
                "login",
                "logout",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_nav_menu_items', function ( $items, $args ) {\n    $location = 'primary'; // Theme menu location.\n\n    if ( ! isset( $args->theme_location ) || $args->theme_location !== $location ) {\n        return $items;\n    }\n\n    $current = home_url( add_query_arg( array() ) );\n\n    if ( is_user_logged_in() ) {\n        $items .= '<li class=\"menu-item wpide-menu-logout\"><a href=\"' . esc_url( wp_logout_url( $current ) ) . '\">Log out</a></li>';\n    } else {\n        $items .= '<li class=\"menu-item wpide-menu-login\"><a href=\"' . esc_url( wp_login_url( $current ) ) . '\">Log in</a></li>';\n    }\n\n    return $items;\n}, 10, 2 );",
            "requires": [],
            "notes": "Set $location to your theme's menu location slug. Works with classic menus."
        },
        {
            "id": "frontend-listen-to-article",
            "version": 1,
            "title": "\"Listen to This Article\" Button",
            "description": "Adds a play button above posts that reads the article aloud with the visitor's browser text-to-speech, with no external service or cost.",
            "category": "frontend",
            "tags": [
                "accessibility",
                "audio",
                "text-to-speech",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $button = '<p><button type=\"button\" class=\"wpide-listen\" hidden style=\"padding:6px 14px;border:1px solid currentColor;border-radius:20px;background:transparent;cursor:pointer;\">&#9654; Listen to this article</button></p>';\n\n    $script = \"<script>(function(){var b=document.querySelector('.wpide-listen');if(!b||!('speechSynthesis' in window)){return;}b.hidden=false;var playing=false;b.addEventListener('click',function(){if(playing){speechSynthesis.cancel();playing=false;b.innerHTML='&#9654; Listen to this article';return;}var c=b.closest('.entry-content,.wp-block-post-content')||document.querySelector('article');var u=new SpeechSynthesisUtterance((c?c.innerText:'').replace(b.innerText,''));u.lang=document.documentElement.lang||'en';u.onend=function(){playing=false;b.innerHTML='&#9654; Listen to this article';};speechSynthesis.cancel();speechSynthesis.speak(u);playing=true;b.innerHTML='&#9632; Stop';});})();</script>\";\n\n    return $button . $content . $script;\n}, 25 );",
            "requires": [],
            "notes": "Uses the browser's built-in speech voices, which vary by device. The button is hidden in browsers without speech support."
        },
        {
            "id": "frontend-mid-article-cta",
            "version": 1,
            "title": "Call to Action in the Middle of Articles",
            "description": "Inserts a highlighted call-to-action box halfway through post content, where engaged readers are most likely to see it.",
            "category": "frontend",
            "tags": [
                "cta",
                "content",
                "newsletter",
                "html"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "content_half",
            "insert_number": 1,
            "code": "<aside class=\"wpide-mid-cta\" style=\"margin:2em 0;padding:20px 24px;border-left:4px solid #2271b1;border-radius:4px;background:#f0f6fc;\">\n    <p style=\"margin:0 0 8px;font-weight:600;\">Enjoying this article?</p>\n    <p style=\"margin:0;\">Get our best guides in your inbox every month. <a href=\"/newsletter/\">Subscribe for free</a>.</p>\n</aside>",
            "requires": [],
            "notes": "Edit the text and link. Use WPIDE conditions to limit it to posts or specific categories."
        },
        {
            "id": "frontend-brand-buttons-scss",
            "version": 1,
            "title": "Brand Button Styles (SCSS)",
            "description": "Restyles theme and block buttons with your brand color, hover and focus states, generated from a few SCSS variables.",
            "category": "frontend",
            "tags": [
                "buttons",
                "scss",
                "branding",
                "design"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$brand: #2271b1;\n$brand-text: #ffffff;\n$radius: 6px;\n$padding: .7em 1.4em;\n\n%wpide-button {\n    background: $brand;\n    color: $brand-text;\n    border: 2px solid $brand;\n    border-radius: $radius;\n    padding: $padding;\n    font-weight: 600;\n    text-decoration: none;\n    transition: background-color .15s ease, color .15s ease;\n\n    &:hover,\n    &:focus {\n        background: darken($brand, 8%);\n        border-color: darken($brand, 8%);\n        color: $brand-text;\n    }\n\n    &:focus-visible {\n        outline: 3px solid rgba($brand, .4);\n        outline-offset: 2px;\n    }\n}\n\n.wp-element-button,\n.wp-block-button__link,\n.button,\nbutton[type=\"submit\"],\ninput[type=\"submit\"] {\n    @extend %wpide-button;\n}\n\n.wp-block-button.is-style-outline .wp-block-button__link {\n    background: transparent;\n    color: $brand;\n\n    &:hover,\n    &:focus {\n        background: $brand;\n        color: $brand-text;\n    }\n}",
            "requires": [],
            "notes": "Change the variables at the top. WPIDE compiles the SCSS to CSS for you."
        },
        {
            "id": "frontend-spacing-utilities-scss",
            "version": 1,
            "title": "Spacing Utility Classes (SCSS)",
            "description": "Generates margin and padding helper classes such as mt-2, mb-4 and px-3 from a spacing scale, to fine-tune block spacing with an extra CSS class.",
            "category": "frontend",
            "tags": [
                "utilities",
                "spacing",
                "scss",
                "blocks"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$spacers: (\n    0: 0,\n    1: .25rem,\n    2: .5rem,\n    3: 1rem,\n    4: 1.5rem,\n    5: 3rem\n);\n\n$properties: (\n    m: margin,\n    p: padding\n);\n\n$sides: (\n    t: top,\n    b: bottom,\n    l: left,\n    r: right\n);\n\n@each $abbr, $property in $properties {\n    @each $key, $size in $spacers {\n        .#{$abbr}-#{$key} {\n            #{$property}: $size !important;\n        }\n\n        .#{$abbr}x-#{$key} {\n            #{$property}-left: $size !important;\n            #{$property}-right: $size !important;\n        }\n\n        .#{$abbr}y-#{$key} {\n            #{$property}-top: $size !important;\n            #{$property}-bottom: $size !important;\n        }\n\n        @each $side-abbr, $side in $sides {\n            .#{$abbr}#{$side-abbr}-#{$key} {\n                #{$property}-#{$side}: $size !important;\n            }\n        }\n    }\n}",
            "requires": [],
            "notes": "Edit $spacers to change the scale. Add a class like mb-4 under Advanced > Additional CSS class(es)."
        },
        {
            "id": "frontend-card-grid-scss",
            "version": 1,
            "title": "Equal-Height Card Grid Style (SCSS)",
            "description": "Turns any Columns or Group block with the class \"card-grid\" into a responsive grid of equal-height cards with shadows and hover lift.",
            "category": "frontend",
            "tags": [
                "cards",
                "grid",
                "scss",
                "blocks"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$card-min: 240px;\n$card-gap: 1.5rem;\n$card-radius: 10px;\n$card-shadow: 0 2px 10px rgba(0, 0, 0, .08);\n\n.card-grid {\n    display: grid !important;\n    grid-template-columns: repeat(auto-fit, minmax($card-min, 1fr));\n    gap: $card-gap;\n\n    > * {\n        display: flex;\n        flex-direction: column;\n        margin: 0 !important;\n        padding: 1.5rem;\n        border-radius: $card-radius;\n        background: #fff;\n        box-shadow: $card-shadow;\n        transition: transform .2s ease, box-shadow .2s ease;\n\n        > :last-child {\n            margin-top: auto;\n        }\n\n        &:hover {\n            transform: translateY(-3px);\n            box-shadow: 0 6px 20px rgba(0, 0, 0, .12);\n        }\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        > *:hover {\n            transform: none;\n        }\n    }\n}",
            "requires": [],
            "notes": "Add the class card-grid to a Columns block. Adjust $card-min to control how narrow cards get before wrapping."
        },
        {
            "id": "frontend-fluid-typography-scss",
            "version": 1,
            "title": "Fluid Heading Sizes (SCSS)",
            "description": "Makes headings scale smoothly between mobile and desktop sizes with CSS clamp(), generated from a simple size table, so titles never look too big on phones.",
            "category": "frontend",
            "tags": [
                "typography",
                "headings",
                "scss",
                "responsive"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$min-viewport: 360px;\n$max-viewport: 1200px;\n\n// Heading => (min size, max size) in rem.\n$headings: (\n    h1: (2rem, 3.25rem),\n    h2: (1.6rem, 2.4rem),\n    h3: (1.3rem, 1.75rem),\n    h4: (1.1rem, 1.35rem)\n);\n\n@function strip-unit($value) {\n    @return $value / ($value * 0 + 1);\n}\n\n@mixin fluid-size($min, $max) {\n    $slope: (strip-unit($max) - strip-unit($min)) * 16 / (strip-unit($max-viewport) - strip-unit($min-viewport));\n    $base: strip-unit($min) - $slope * strip-unit($min-viewport) / 16;\n    $base-rem: round($base * 1000) / 1000 * 1rem;\n    $slope-vw: round($slope * 100000) / 1000 * 1vw;\n    font-size: unquote(\"clamp(#{$min}, #{$base-rem} + #{$slope-vw}, #{$max})\");\n}\n\n@each $tag, $sizes in $headings {\n    #{$tag} {\n        @include fluid-size(nth($sizes, 1), nth($sizes, 2));\n        line-height: 1.2;\n    }\n}",
            "requires": [],
            "notes": "Edit the min and max sizes (in rem) in $headings. Block themes may set heading sizes in theme.json; this overrides them."
        },
        {
            "id": "frontend-form-fields-scss",
            "version": 1,
            "title": "Consistent Form Field Styling (SCSS)",
            "description": "Gives text inputs, selects, text areas and checkboxes across the site (comments, contact forms, checkout) one consistent, accessible look with clear focus states.",
            "category": "frontend",
            "tags": [
                "forms",
                "inputs",
                "scss",
                "accessibility"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$field-border: #8c8f94;\n$field-radius: 6px;\n$field-focus: #2271b1;\n$field-bg: #ffffff;\n$field-padding: .6em .8em;\n\ninput[type=\"text\"],\ninput[type=\"email\"],\ninput[type=\"url\"],\ninput[type=\"tel\"],\ninput[type=\"number\"],\ninput[type=\"password\"],\ninput[type=\"search\"],\ninput[type=\"date\"],\nselect,\ntextarea {\n    width: 100%;\n    max-width: 100%;\n    padding: $field-padding;\n    border: 1px solid $field-border;\n    border-radius: $field-radius;\n    background: $field-bg;\n    font: inherit;\n    line-height: 1.4;\n    transition: border-color .15s ease, box-shadow .15s ease;\n\n    &:focus {\n        border-color: $field-focus;\n        box-shadow: 0 0 0 3px rgba($field-focus, .2);\n        outline: none;\n    }\n\n    &:disabled {\n        background: darken($field-bg, 4%);\n        cursor: not-allowed;\n    }\n}\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n    width: 1.1em;\n    height: 1.1em;\n    accent-color: $field-focus;\n    vertical-align: middle;\n}\n\nlabel {\n    font-weight: 600;\n}",
            "requires": [],
            "notes": "Change the variables at the top. Some form plugins add their own styles with higher specificity."
        },
        {
            "id": "frontend-late-content-follow-prompt",
            "version": 1,
            "title": "Follow Prompt Near the End of Articles",
            "description": "Inserts a short \"Follow us\" box with your social links three quarters of the way through post content, reaching readers who are nearly done.",
            "category": "frontend",
            "tags": [
                "social",
                "content",
                "engagement",
                "html"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "content_quarter_third",
            "insert_number": 1,
            "code": "<aside class=\"wpide-follow-prompt\" style=\"margin:2em 0;padding:18px 22px;border-radius:8px;background:#f6f7f7;text-align:center;\">\n    <p style=\"margin:0 0 10px;font-weight:600;\">Like what you are reading? Follow us for more.</p>\n    <p style=\"margin:0;\">\n        <a href=\"https://www.linkedin.com/company/example\" rel=\"noopener\" target=\"_blank\" style=\"margin:0 8px;\">LinkedIn</a>\n        <a href=\"https://x.com/example\" rel=\"noopener\" target=\"_blank\" style=\"margin:0 8px;\">X</a>\n        <a href=\"https://www.youtube.com/@example\" rel=\"noopener\" target=\"_blank\" style=\"margin:0 8px;\">YouTube</a>\n    </p>\n</aside>",
            "requires": [],
            "notes": "Replace the links with your own profiles. Use WPIDE conditions to show it only on posts."
        },
        {
            "id": "woo-hide-out-of-stock-from-search",
            "version": 1,
            "title": "Hide Out-of-Stock Products From Search Results",
            "description": "Leaves sold-out products out of the front-end search results while keeping them visible on the shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "stock"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array(\n        'relation' => 'OR',\n        array(\n            'key'     => '_stock_status',\n            'value'   => 'outofstock',\n            'compare' => '!=',\n        ),\n        array(\n            'key'     => '_stock_status',\n            'compare' => 'NOT EXISTS',\n        ),\n    );\n\n    $query->set( 'meta_query', $meta_query );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the \"Hide out of stock items\" setting in WooCommerce > Settings > Products > Inventory if you want them hidden everywhere instead."
        },
        {
            "id": "woo-out-of-stock-last",
            "version": 1,
            "title": "Show Out-of-Stock Products Last",
            "description": "Keeps your shop and category pages sorted as usual but moves sold-out products to the end of the list, so shoppers see what they can buy first.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "stock",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_clauses', function ( $clauses, $query ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return $clauses;\n    }\n\n    if ( ! ( $query->is_post_type_archive( 'product' ) || $query->is_tax( get_object_taxonomies( 'product' ) ) ) ) {\n        return $clauses;\n    }\n\n    $lookup = $wpdb->prefix . 'wc_product_meta_lookup';\n\n    if ( false === strpos( $clauses['join'], 'wpide_stock_sort' ) ) {\n        $clauses['join'] .= \" LEFT JOIN {$lookup} AS wpide_stock_sort ON {$wpdb->posts}.ID = wpide_stock_sort.product_id \";\n    }\n\n    $order = \"CASE WHEN wpide_stock_sort.stock_status = 'outofstock' THEN 1 ELSE 0 END ASC\";\n    $clauses['orderby'] = $clauses['orderby'] ? $order . ', ' . $clauses['orderby'] : $order;\n\n    return $clauses;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with every sorting option because it only adds a first sort key on stock status."
        },
        {
            "id": "woo-custom-availability-text",
            "version": 1,
            "title": "Custom Stock Availability Messages",
            "description": "Replaces the standard \"Out of stock\" and \"Available on backorder\" labels on product pages with your own wording.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "stock",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_availability_text', function ( $text, $product ) {\n    // Your wording for each stock status. Empty keeps the default.\n    $messages = array(\n        'outofstock'  => 'Sold out, back soon',\n        'onbackorder' => 'Ships in 2-3 weeks',\n    );\n\n    if ( ! $product instanceof WC_Product ) {\n        return $text;\n    }\n\n    $status = $product->get_stock_status();\n\n    return ! empty( $messages[ $status ] ) ? $messages[ $status ] : $text;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Leave a message empty to keep the WooCommerce default for that status."
        },
        {
            "id": "woo-low-stock-urgency",
            "version": 1,
            "title": "\"Only a Few Left\" Low Stock Message",
            "description": "When a product with managed stock drops to a few units, shows a highlighted \"Only 3 left, order soon\" message instead of the plain stock count.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "stock",
                "urgency",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_availability_text', function ( $text, $product ) {\n    $threshold = 5; // Show the message at this many units or fewer.\n\n    if ( ! $product instanceof WC_Product || ! $product->managing_stock() || ! $product->is_in_stock() ) {\n        return $text;\n    }\n\n    $qty = (int) $product->get_stock_quantity();\n\n    if ( $qty > 0 && $qty <= $threshold ) {\n        return sprintf( 'Only %d left, order soon', $qty );\n    }\n\n    return $text;\n}, 20, 2 );\n\nadd_filter( 'woocommerce_get_stock_html', function ( $html, $product ) {\n    if ( $product instanceof WC_Product && $product->managing_stock() && $product->is_in_stock() && (int) $product->get_stock_quantity() <= 5 ) {\n        $html = str_replace( 'class=\"stock ', 'style=\"color:#b32d2e;font-weight:600;\" class=\"stock ', $html );\n    }\n\n    return $html;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $threshold to the quantity at which the message appears. Requires stock management on the product."
        },
        {
            "id": "woo-sold-out-badge",
            "version": 1,
            "title": "\"Sold Out\" Badge on Shop Images",
            "description": "Adds a \"Sold out\" label over the image of out-of-stock products in shop and category listings so shoppers can tell at a glance.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badge",
                "stock",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop_item_title', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || $product->is_in_stock() ) {\n        return;\n    }\n\n    echo '<span class=\"wpide-sold-out\" style=\"position:absolute;top:10px;left:10px;z-index:9;padding:4px 10px;border-radius:3px;background:#1e1e1e;color:#fff;font-size:12px;font-weight:600;text-transform:uppercase;\">' . esc_html( 'Sold out' ) . '</span>';\n}, 9 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.woocommerce ul.products li.product{position:relative;}</style>' . \"\\n\";\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Adjust the colors in the inline style or override the .wpide-sold-out class in your theme CSS."
        },
        {
            "id": "woo-new-product-badge",
            "version": 1,
            "title": "\"New\" Badge for Recent Products",
            "description": "Shows a \"New\" label on products in the shop that were published in the last 30 days.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badge",
                "shop",
                "new"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop_item_title', function () {\n    global $product;\n\n    $days = 30; // Products newer than this many days get the badge.\n\n    if ( ! $product instanceof WC_Product || ! $product->get_date_created() ) {\n        return;\n    }\n\n    $age = time() - $product->get_date_created()->getTimestamp();\n\n    if ( $age > $days * DAY_IN_SECONDS ) {\n        return;\n    }\n\n    echo '<span class=\"wpide-new-badge\" style=\"position:absolute;top:10px;right:10px;z-index:9;padding:4px 10px;border-radius:3px;background:#2271b1;color:#fff;font-size:12px;font-weight:600;text-transform:uppercase;\">' . esc_html( 'New' ) . '</span>';\n}, 9 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.woocommerce ul.products li.product{position:relative;}</style>' . \"\\n\";\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $days to control how long a product counts as new."
        },
        {
            "id": "woo-bestseller-badge",
            "version": 1,
            "title": "\"Bestseller\" Badge for Popular Products",
            "description": "Labels products that have sold at least a set number of units with a \"Bestseller\" badge in shop listings and on the product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badge",
                "sales",
                "social-proof"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_bestseller_badge = function () {\n    global $product;\n\n    $min_sales = 50; // Units sold before the badge appears.\n\n    if ( ! $product instanceof WC_Product || (int) $product->get_total_sales() < $min_sales ) {\n        return;\n    }\n\n    echo '<span class=\"wpide-bestseller-badge\" style=\"display:inline-block;margin:0 0 8px;padding:3px 10px;border-radius:3px;background:#dba617;color:#1e1e1e;font-size:12px;font-weight:600;text-transform:uppercase;\">' . esc_html( 'Bestseller' ) . '</span>';\n};\n\nadd_action( 'woocommerce_shop_loop_item_title', $wpide_bestseller_badge, 5 );\nadd_action( 'woocommerce_single_product_summary', $wpide_bestseller_badge, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $min_sales to the number of units a product must sell to earn the badge."
        },
        {
            "id": "woo-you-save-amount",
            "version": 1,
            "title": "Show \"You Save\" Amount on Sale Products",
            "description": "Adds a line under the price of products on sale that tells shoppers how much they save, for example \"You save $15.00 (25%)\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "price"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {\n    if ( is_admin() || ! $product instanceof WC_Product || ! $product->is_on_sale() || $product->is_type( array( 'variable', 'grouped' ) ) ) {\n        return $html;\n    }\n\n    $regular = (float) wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price() ) );\n    $sale    = (float) wc_get_price_to_display( $product, array( 'price' => $product->get_sale_price() ) );\n\n    if ( $regular <= 0 || $sale >= $regular ) {\n        return $html;\n    }\n\n    $saving  = $regular - $sale;\n    $percent = round( $saving / $regular * 100 );\n\n    return $html . sprintf( '<span class=\"wpide-you-save\" style=\"display:block;font-size:.85em;color:#008a20;\">You save %s (%d%%)</span>', wc_price( $saving ), $percent );\n}, 20, 2 );\n\nadd_filter( 'woocommerce_available_variation', function ( $data, $parent, $variation ) {\n    if ( $variation->is_on_sale() && (float) $data['display_regular_price'] > (float) $data['display_price'] ) {\n        $saving             = (float) $data['display_regular_price'] - (float) $data['display_price'];\n        $data['price_html'] .= sprintf( '<span class=\"wpide-you-save\" style=\"display:block;font-size:.85em;color:#008a20;\">You save %s</span>', wc_price( $saving ) );\n    }\n\n    return $data;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works for simple products; variable products show the saving once a variation is selected."
        },
        {
            "id": "woo-variable-price-from",
            "version": 1,
            "title": "Show \"From\" Price Instead of a Price Range",
            "description": "Replaces price ranges like \"$10 - $40\" on variable products with a shorter \"From $10\", which reads better on shop pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "price",
                "variable-products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_variable_price_html', function ( $html, $product ) {\n    $prefix = 'From'; // Text shown before the lowest price.\n\n    $min = $product->get_variation_price( 'min', true );\n    $max = $product->get_variation_price( 'max', true );\n\n    if ( '' === $min || $min === $max ) {\n        return $html;\n    }\n\n    return sprintf( '<span class=\"wpide-from\">%s</span> %s', esc_html( $prefix ), wc_price( $min ) . $product->get_price_suffix() );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "When all variations cost the same, the normal single price is shown."
        },
        {
            "id": "woo-free-price-label",
            "version": 1,
            "title": "Show \"Free\" Instead of a Zero Price",
            "description": "Displays the word \"Free\" for products priced at 0 instead of showing $0.00.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "price",
                "free"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {\n    $label = 'Free'; // Shown instead of a zero price.\n\n    if ( ! $product instanceof WC_Product || $product->is_type( array( 'variable', 'grouped' ) ) ) {\n        return $html;\n    }\n\n    if ( '' !== $product->get_price() && 0.0 === (float) $product->get_price() ) {\n        return '<span class=\"amount wpide-free-price\">' . esc_html( $label ) . '</span>';\n    }\n\n    return $html;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $label to any wording you like, for example \"Free download\"."
        },
        {
            "id": "woo-call-for-price",
            "version": 1,
            "title": "\"Call for Price\" on Products Without a Price",
            "description": "Shows a \"Call for price\" link instead of a blank space when a product has no price set, so shoppers know how to get a quote.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "price",
                "quote",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_empty_price_html', function ( $html, $product ) {\n    $label = 'Call for price';   // Link text.\n    $phone = '+1 555 010 0000';  // Your phone number.\n\n    return sprintf( '<a class=\"wpide-call-for-price\" href=\"%s\">%s</a>', esc_url( 'tel:' . preg_replace( '/[^0-9+]/', '', $phone ) ), esc_html( $label ) );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $phone to your number, or change the link to a contact page URL."
        },
        {
            "id": "woo-catalog-mode",
            "version": 1,
            "title": "Catalog Mode (Prices Without Purchasing)",
            "description": "Turns the shop into a catalog: products and prices stay visible, but add to cart buttons, the cart and checkout are disabled for everyone.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_is_purchasable', '__return_false', 99 );\nadd_filter( 'woocommerce_variation_is_purchasable', '__return_false', 99 );\n\nadd_action( 'template_redirect', function () {\n    if ( function_exists( 'is_cart' ) && ( is_cart() || is_checkout() ) ) {\n        wp_safe_redirect( wc_get_page_permalink( 'shop' ) );\n        exit;\n    }\n} );\n\nadd_action( 'woocommerce_single_product_summary', function () {\n    echo '<p class=\"wpide-catalog-note\">' . esc_html( 'Contact us to order this product.' ) . '</p>';\n}, 31 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Visitors who open the cart or checkout URL are sent back to the shop. Deactivate the snippet to start selling again."
        },
        {
            "id": "woo-buy-now-button",
            "version": 1,
            "title": "Add a \"Buy Now\" Button Next to Add to Cart",
            "description": "Adds a second button on product pages that adds the product to the cart and takes the shopper straight to checkout, while the normal add to cart button keeps working.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "buttons",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_add_to_cart_button', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->is_purchasable() || ! $product->is_in_stock() || $product->is_type( array( 'external', 'grouped' ) ) ) {\n        return;\n    }\n\n    echo '<button type=\"submit\" name=\"wpide_buy_now\" value=\"1\" class=\"button alt wpide-buy-now\" style=\"margin-left:8px;\">' . esc_html( 'Buy now' ) . '</button>';\n\n    if ( $product->is_type( 'simple' ) ) {\n        printf( '<input type=\"hidden\" name=\"add-to-cart\" value=\"%d\">', (int) $product->get_id() );\n    }\n} );\n\nadd_filter( 'woocommerce_add_to_cart_redirect', function ( $url ) {\n    if ( ! empty( $_REQUEST['wpide_buy_now'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return wc_get_checkout_url();\n    }\n\n    return $url;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works for simple and variable products on the classic product template."
        },
        {
            "id": "woo-quantity-plus-minus",
            "version": 1,
            "title": "Plus and Minus Buttons on Quantity Fields",
            "description": "Adds \"-\" and \"+\" buttons around the quantity box on product and cart pages so shoppers can change quantities with a tap.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "mobile",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_woocommerce' ) || ! ( is_product() || is_cart() ) ) {\n        return;\n    }\n    ?>\n<style>\n.wpide-qty-wrap{display:inline-flex;align-items:stretch;}\n.wpide-qty-wrap .qty{text-align:center;-moz-appearance:textfield;}\n.wpide-qty-wrap .qty::-webkit-inner-spin-button,.wpide-qty-wrap .qty::-webkit-outer-spin-button{-webkit-appearance:none;margin:0;}\n.wpide-qty-btn{min-width:36px;border:1px solid #ccc;background:#f6f7f7;cursor:pointer;font-size:18px;line-height:1;}\n</style>\n<script>\n(function () {\n    function enhance(root) {\n        root.querySelectorAll('.quantity input.qty:not([data-wpide-qty]):not([type=\"hidden\"])').forEach(function (input) {\n            input.setAttribute('data-wpide-qty', '1');\n            var wrap = document.createElement('span');\n            wrap.className = 'wpide-qty-wrap';\n            input.parentNode.insertBefore(wrap, input);\n            ['-', '+'].forEach(function (sign) {\n                var b = document.createElement('button');\n                b.type = 'button';\n                b.className = 'wpide-qty-btn';\n                b.textContent = sign;\n                b.setAttribute('aria-label', sign === '+' ? 'Increase quantity' : 'Decrease quantity');\n                b.addEventListener('click', function () {\n                    var step = parseFloat(input.step) || 1, min = parseFloat(input.min) || 0, max = parseFloat(input.max), val = parseFloat(input.value) || 0;\n                    val = sign === '+' ? val + step : val - step;\n                    if (val < min) { val = min; }\n                    if (!isNaN(max) && max > 0 && val > max) { val = max; }\n                    input.value = val;\n                    input.dispatchEvent(new Event('change', { bubbles: true }));\n                });\n                if (sign === '-') { wrap.appendChild(b); wrap.appendChild(input); } else { wrap.appendChild(b); }\n            });\n        });\n    }\n    enhance(document);\n    if (window.jQuery) { jQuery(document.body).on('updated_cart_totals', function () { enhance(document); }); }\n})();\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The buttons respect the minimum, maximum and step values of the quantity field."
        },
        {
            "id": "woo-minimum-quantity",
            "version": 1,
            "title": "Minimum Purchase Quantity per Product",
            "description": "Requires shoppers to buy at least a set number of each product, for example 2, and sets the quantity box to start at that number.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "cart",
                "wholesale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_min_qty = array(\n    'minimum'     => 2,       // Minimum units per product.\n    'product_ids' => array(), // Limit to these product IDs, empty for all.\n);\n\n$wpide_min_qty_applies = function ( $product_id ) use ( $wpide_min_qty ) {\n    return empty( $wpide_min_qty['product_ids'] ) || in_array( (int) $product_id, array_map( 'intval', $wpide_min_qty['product_ids'] ), true );\n};\n\nadd_filter( 'woocommerce_quantity_input_args', function ( $args, $product ) use ( $wpide_min_qty, $wpide_min_qty_applies ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    if ( $product->is_sold_individually() || ! $wpide_min_qty_applies( $id ) ) {\n        return $args;\n    }\n\n    $args['min_value'] = $wpide_min_qty['minimum'];\n\n    if ( ! is_cart() && (int) $args['input_value'] < $wpide_min_qty['minimum'] ) {\n        $args['input_value'] = $wpide_min_qty['minimum'];\n    }\n\n    return $args;\n}, 10, 2 );\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_min_qty, $wpide_min_qty_applies ) {\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $product = $item['data'];\n\n        if ( $product->is_sold_individually() || ! $wpide_min_qty_applies( $item['product_id'] ) ) {\n            continue;\n        }\n\n        if ( (int) $item['quantity'] < $wpide_min_qty['minimum'] ) {\n            wc_add_notice( sprintf( 'Please order at least %1$d of \"%2$s\".', $wpide_min_qty['minimum'], $product->get_name() ), 'error' );\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $minimum. Add product IDs to $product_ids to apply the rule only to those products; leave it empty for all products."
        },
        {
            "id": "woo-quantity-step",
            "version": 1,
            "title": "Sell in Packs (Quantity Steps)",
            "description": "Makes products sell only in multiples, such as packs of 6, by setting the quantity step and checking the cart.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "packs",
                "wholesale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_qty_step = 6; // Products can only be bought in multiples of this number.\n\nadd_filter( 'woocommerce_quantity_input_args', function ( $args, $product ) use ( $wpide_qty_step ) {\n    if ( $product->is_sold_individually() ) {\n        return $args;\n    }\n\n    $args['step']      = $wpide_qty_step;\n    $args['min_value'] = max( $wpide_qty_step, (int) $args['min_value'] );\n\n    if ( (int) $args['input_value'] < $wpide_qty_step ) {\n        $args['input_value'] = $wpide_qty_step;\n    }\n\n    return $args;\n}, 10, 2 );\n\nadd_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) use ( $wpide_qty_step ) {\n    if ( $quantity % $wpide_qty_step ) {\n        wc_add_notice( sprintf( 'This product is sold in packs of %d.', $wpide_qty_step ), 'error' );\n        return false;\n    }\n\n    return $passed;\n}, 10, 3 );\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_qty_step ) {\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( ! $item['data']->is_sold_individually() && $item['quantity'] % $wpide_qty_step ) {\n            wc_add_notice( sprintf( '\"%1$s\" is sold in packs of %2$d. Please adjust the quantity.', $item['data']->get_name(), $wpide_qty_step ), 'error' );\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $step. The minimum quantity becomes one pack."
        },
        {
            "id": "woo-max-cart-items",
            "version": 1,
            "title": "Limit the Number of Items in the Cart",
            "description": "Stops shoppers from adding more than a set total number of items to the cart, for limited releases or stock-controlled drops.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_max_cart_items = 10; // Largest total quantity allowed in the cart.\n\nadd_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id, $quantity ) use ( $wpide_max_cart_items ) {\n    $in_cart = WC()->cart ? WC()->cart->get_cart_contents_count() : 0;\n\n    if ( $in_cart + (int) $quantity > $wpide_max_cart_items ) {\n        wc_add_notice( sprintf( 'You can have up to %d items in your cart.', $wpide_max_cart_items ), 'error' );\n        return false;\n    }\n\n    return $passed;\n}, 10, 3 );\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_max_cart_items ) {\n    if ( WC()->cart->get_cart_contents_count() > $wpide_max_cart_items ) {\n        wc_add_notice( sprintf( 'Your cart can contain at most %d items. Please remove some before checking out.', $wpide_max_cart_items ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $max_items. The limit counts quantities, so 3 of one product counts as 3."
        },
        {
            "id": "woo-maximum-order-amount",
            "version": 1,
            "title": "Maximum Order Amount",
            "description": "Blocks checkout when the cart subtotal goes over a set amount and shows a notice asking the shopper to contact you for large orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "limits",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_check_cart_items', function () {\n    $maximum = 1000; // Highest subtotal allowed per order.\n\n    $subtotal = (float) WC()->cart->get_subtotal();\n\n    if ( $subtotal > $maximum ) {\n        wc_add_notice( sprintf( 'Orders above %s cannot be placed online. Please contact us for a quote.', wc_price( $maximum ) ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $maximum. The check uses the cart subtotal before shipping."
        },
        {
            "id": "woo-single-product-cart",
            "version": 1,
            "title": "Allow Only One Product in the Cart",
            "description": "Replaces the cart contents whenever a new product is added, so each order contains a single product. Useful for services, bookings and memberships.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_add_to_cart_validation', function ( $passed, $product_id ) {\n    if ( $passed && WC()->cart && ! WC()->cart->is_empty() ) {\n        foreach ( WC()->cart->get_cart() as $key => $item ) {\n            if ( (int) $item['product_id'] !== (int) $product_id ) {\n                WC()->cart->remove_cart_item( $key );\n            }\n        }\n    }\n\n    return $passed;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shoppers can still change the quantity of the product in their cart."
        },
        {
            "id": "woo-free-gift-over-amount",
            "version": 1,
            "title": "Free Gift When the Cart Reaches an Amount",
            "description": "Automatically adds a free gift product to the cart once the subtotal reaches a set amount, and removes it again if the cart drops below.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "gift",
                "promotion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_calculate_totals', function ( $cart ) {\n    $gift_id   = 0;  // Product ID of the free gift.\n    $threshold = 75; // Cart subtotal that unlocks the gift.\n\n    static $running = false;\n\n    if ( ! $gift_id || $running || ( is_admin() && ! wp_doing_ajax() ) ) {\n        return;\n    }\n\n    $running = true;\n\n    $subtotal = 0;\n    $gift_key = '';\n\n    foreach ( $cart->get_cart() as $key => $item ) {\n        if ( (int) $item['product_id'] === $gift_id ) {\n            $gift_key = $key;\n            continue;\n        }\n        $subtotal += (float) $item['data']->get_price() * (int) $item['quantity'];\n    }\n\n    if ( $subtotal >= $threshold && ! $gift_key ) {\n        $cart->add_to_cart( $gift_id, 1 );\n    } elseif ( $subtotal < $threshold && $gift_key ) {\n        $cart->remove_cart_item( $gift_key );\n    }\n\n    $running = false;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Create the gift as a product priced at 0 (it can be hidden from the catalog), then set $gift_id and $threshold."
        },
        {
            "id": "woo-coupon-from-url",
            "version": 1,
            "title": "Apply a Coupon From a Link",
            "description": "Lets you share links like yoursite.com/?coupon=SUMMER10 that apply the coupon automatically when the shopper adds to cart or visits the cart.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "marketing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_loaded', function () {\n    $param = 'coupon'; // Query parameter that carries the coupon code.\n\n    if ( is_admin() || empty( $_GET[ $param ] ) || ! function_exists( 'WC' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $code = wc_format_coupon_code( sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n\n    if ( '' === $code || ! WC()->session ) {\n        return;\n    }\n\n    if ( ! WC()->session->has_session() ) {\n        WC()->session->set_customer_session_cookie( true );\n    }\n\n    WC()->session->set( 'wpide_pending_coupon', $code );\n} );\n\nadd_action( 'woocommerce_cart_loaded_from_session', function ( $cart ) {\n    if ( ! WC()->session ) {\n        return;\n    }\n\n    $code = WC()->session->get( 'wpide_pending_coupon' );\n\n    if ( ! $code || $cart->is_empty() ) {\n        return;\n    }\n\n    if ( ! $cart->has_discount( $code ) ) {\n        $cart->apply_coupon( $code );\n    }\n\n    WC()->session->set( 'wpide_pending_coupon', null );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The coupon must exist in WooCommerce > Marketing > Coupons. Change $param to use a different query parameter."
        },
        {
            "id": "woo-coupon-prompt-text",
            "version": 1,
            "title": "Change the \"Have a Coupon?\" Prompt",
            "description": "Rewrites the coupon prompt shown at the top of the classic checkout with your own wording.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "checkout",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_checkout_coupon_message', function () {\n    $intro = 'Got a discount code?';   // Text before the link.\n    $link  = 'Enter it here';          // Link text.\n\n    return esc_html( $intro ) . ' <a href=\"#\" role=\"button\" aria-label=\"' . esc_attr( $link ) . '\" class=\"showcoupon\">' . esc_html( $link ) . '</a>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Keep the class=\"showcoupon\" link so the coupon form still opens when clicked."
        },
        {
            "id": "woo-small-order-fee",
            "version": 1,
            "title": "Small Order Fee",
            "description": "Adds a handling fee to orders below a set subtotal, which helps cover packing costs on very small orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "fees",
                "cart",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_cart_calculate_fees', function ( $cart ) {\n    $threshold = 20;                  // Orders below this subtotal pay the fee.\n    $fee       = 3.50;                // Fee amount.\n    $label     = 'Small order fee';   // Name shown in the totals.\n    $taxable   = false;               // Whether tax applies to the fee.\n\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    if ( $cart->get_subtotal() > 0 && $cart->get_subtotal() < $threshold ) {\n        $cart->add_fee( $label, $fee, $taxable );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $threshold, $fee and $taxable. The fee name appears in the cart totals and on the order."
        },
        {
            "id": "woo-payment-method-fee",
            "version": 1,
            "title": "Surcharge for a Payment Method",
            "description": "Adds a fee when the shopper chooses a specific payment method, for example cash on delivery, and updates the totals as soon as they switch methods.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "fees",
                "payments",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_cart_calculate_fees', function ( $cart ) {\n    $gateway    = 'cod';                  // Payment method ID.\n    $fixed      = 2.00;                   // Fixed fee, 0 for none.\n    $percentage = 0;                      // Percentage of the cart total, 0 for none.\n    $label      = 'Cash on delivery fee'; // Name shown in the totals.\n\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->session ) {\n        return;\n    }\n\n    if ( WC()->session->get( 'chosen_payment_method' ) !== $gateway ) {\n        return;\n    }\n\n    $base   = (float) $cart->get_cart_contents_total() + (float) $cart->get_shipping_total();\n    $amount = $fixed + ( $base * $percentage / 100 );\n\n    if ( $amount > 0 ) {\n        $cart->add_fee( $label, round( $amount, wc_get_price_decimals() ), true );\n    }\n} );\n\nadd_action( 'wp_footer', function () {\n    if ( function_exists( 'is_checkout' ) && is_checkout() && ! is_wc_endpoint_url() ) {\n        echo \"<script>jQuery(function($){ $('form.checkout').on('change', 'input[name=\\\"payment_method\\\"]', function(){ $(document.body).trigger('update_checkout'); }); });</script>\\n\";\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $gateway to the payment method ID (cod, bacs, cheque, or the ID of your gateway) and choose a fixed or percentage fee."
        },
        {
            "id": "woo-quantity-discount",
            "version": 1,
            "title": "Bulk Discount by Cart Quantity",
            "description": "Gives an automatic percentage discount when the cart contains enough items, for example 10% off 5 or more items, shown as a line in the totals.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "discounts",
                "cart",
                "bulk"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_cart_calculate_fees', function ( $cart ) {\n    // Items in cart => percentage off. Highest matching tier wins.\n    $tiers = array(\n        5  => 10,\n        10 => 15,\n    );\n\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    $count   = $cart->get_cart_contents_count();\n    $percent = 0;\n\n    foreach ( $tiers as $qty => $off ) {\n        if ( $count >= $qty ) {\n            $percent = $off;\n        }\n    }\n\n    if ( $percent > 0 ) {\n        $discount = $cart->get_subtotal() * $percent / 100;\n        $cart->add_fee( sprintf( 'Bulk discount (%d%%)', $percent ), -$discount );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the $tiers list. The highest tier the cart reaches is applied."
        },
        {
            "id": "woo-role-based-pricing",
            "version": 1,
            "title": "Percentage Discount for a User Role",
            "description": "Shows lower prices across the store for customers with a specific role, such as wholesale buyers, without editing each product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "pricing",
                "roles",
                "wholesale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_role_pricing = array(\n    'role'    => 'wholesale_customer', // Role that gets the discount.\n    'percent' => 20,                   // Discount in percent.\n);\n\n$wpide_role_price = function ( $price, $product ) use ( $wpide_role_pricing ) {\n    if ( '' === $price || ! is_user_logged_in() ) {\n        return $price;\n    }\n\n    $user = wp_get_current_user();\n\n    if ( ! in_array( $wpide_role_pricing['role'], (array) $user->roles, true ) ) {\n        return $price;\n    }\n\n    return (string) round( (float) $price * ( 100 - $wpide_role_pricing['percent'] ) / 100, wc_get_price_decimals() );\n};\n\nforeach ( array( 'woocommerce_product_get_price', 'woocommerce_product_get_regular_price', 'woocommerce_product_get_sale_price', 'woocommerce_product_variation_get_price', 'woocommerce_product_variation_get_regular_price', 'woocommerce_product_variation_get_sale_price', 'woocommerce_variation_prices_price', 'woocommerce_variation_prices_regular_price', 'woocommerce_variation_prices_sale_price' ) as $wpide_hook ) {\n    add_filter( $wpide_hook, $wpide_role_price, 99, 2 );\n}\n\nadd_filter( 'woocommerce_get_variation_prices_hash', function ( $hash ) use ( $wpide_role_pricing ) {\n    $hash[] = is_user_logged_in() && in_array( $wpide_role_pricing['role'], (array) wp_get_current_user()->roles, true ) ? 'wpide-role-' . $wpide_role_pricing['percent'] : 'wpide-role-none';\n\n    return $hash;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $role and $percent; the role must already exist (create it with a role editor plugin). Sale prices are discounted too."
        },
        {
            "id": "woo-hide-gateway-by-country",
            "version": 1,
            "title": "Hide a Payment Method for Certain Countries",
            "description": "Removes a payment method from checkout when the customer's billing country is on your list, for example to offer cash on delivery only at home.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "payments",
                "checkout",
                "countries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_available_payment_gateways', function ( $gateways ) {\n    $gateway   = 'cod';                // Payment method ID to hide.\n    $countries = array( 'CA', 'MX' );  // Billing countries where it is hidden.\n\n    if ( is_admin() || ! WC()->customer || ! isset( $gateways[ $gateway ] ) ) {\n        return $gateways;\n    }\n\n    if ( in_array( WC()->customer->get_billing_country(), $countries, true ) ) {\n        unset( $gateways[ $gateway ] );\n    }\n\n    return $gateways;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $gateway to the payment method ID and list two-letter country codes in $countries."
        },
        {
            "id": "woo-gateway-max-amount",
            "version": 1,
            "title": "Disable a Payment Method Above an Order Total",
            "description": "Hides a payment method such as cash on delivery or bank transfer when the order total goes above a limit you choose.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "payments",
                "checkout",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_available_payment_gateways', function ( $gateways ) {\n    $gateway   = 'cod'; // Payment method ID.\n    $max_total = 500;   // Hide it above this order total.\n\n    if ( is_admin() || ! WC()->cart || ! isset( $gateways[ $gateway ] ) ) {\n        return $gateways;\n    }\n\n    if ( (float) WC()->cart->get_total( 'edit' ) > $max_total ) {\n        unset( $gateways[ $gateway ] );\n    }\n\n    return $gateways;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $gateway and $max_total. The check uses the cart total including shipping and tax."
        },
        {
            "id": "woo-hide-shipping-for-class",
            "version": 1,
            "title": "Hide a Shipping Method for a Shipping Class",
            "description": "Removes a shipping method, such as local pickup or express delivery, when the cart contains a product from a specific shipping class (for example bulky items).",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "shipping-classes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_package_rates', function ( $rates, $package ) {\n    $shipping_class = 'bulky';       // Shipping class slug.\n    $method         = 'free_shipping'; // Method ID to hide when the class is in the cart.\n\n    $found = false;\n\n    foreach ( (array) $package['contents'] as $item ) {\n        if ( isset( $item['data'] ) && $item['data']->get_shipping_class() === $shipping_class ) {\n            $found = true;\n            break;\n        }\n    }\n\n    if ( ! $found ) {\n        return $rates;\n    }\n\n    foreach ( $rates as $id => $rate ) {\n        if ( $rate->get_method_id() === $method ) {\n            unset( $rates[ $id ] );\n        }\n    }\n\n    return $rates;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $shipping_class to the class slug from WooCommerce > Settings > Shipping > Classes and $method to the method ID (flat_rate, local_pickup, free_shipping)."
        },
        {
            "id": "woo-email-first-checkout",
            "version": 1,
            "title": "Move the Email Field to the Top of Checkout",
            "description": "Puts the billing email field first on the classic checkout so you capture the address early, which also helps abandoned cart tools.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    if ( isset( $fields['billing_email'] ) ) {\n        $fields['billing_email']['priority'] = 1;\n        $fields['billing_email']['class']    = array( 'form-row-wide' );\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only affects the classic checkout shortcode. The block checkout already asks for the email first."
        },
        {
            "id": "woo-checkout-referral-source",
            "version": 1,
            "title": "\"How Did You Hear About Us?\" Checkout Field",
            "description": "Adds a dropdown to the classic checkout asking customers how they found your store, saves the answer with the order and shows it on the order screen.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "marketing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_referral_options = array(\n    ''        => 'Choose an option',\n    'search'  => 'Search engine',\n    'social'  => 'Social media',\n    'friend'  => 'Friend or family',\n    'ad'      => 'Advertisement',\n    'other'   => 'Other',\n);\n\nadd_action( 'woocommerce_after_order_notes', function ( $checkout ) use ( $wpide_referral_options ) {\n    woocommerce_form_field( 'wpide_referral_source', array(\n        'type'     => 'select',\n        'label'    => 'How did you hear about us?',\n        'required' => false,\n        'class'    => array( 'form-row-wide' ),\n        'options'  => $wpide_referral_options,\n    ), $checkout ? $checkout->get_value( 'wpide_referral_source' ) : '' );\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order ) use ( $wpide_referral_options ) {\n    $value = isset( $_POST['wpide_referral_source'] ) ? sanitize_key( wp_unslash( $_POST['wpide_referral_source'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    if ( '' !== $value && isset( $wpide_referral_options[ $value ] ) ) {\n        $order->update_meta_data( '_wpide_referral_source', $wpide_referral_options[ $value ] );\n    }\n} );\n\nadd_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {\n    $source = $order->get_meta( '_wpide_referral_source' );\n\n    if ( $source ) {\n        echo '<p><strong>Heard about us via:</strong> ' . esc_html( $source ) . '</p>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit $options to change the choices. Answers are stored in the order meta key _wpide_referral_source."
        },
        {
            "id": "woo-block-checkout-gift-message",
            "version": 1,
            "title": "Gift Message Field on the Block Checkout",
            "description": "Adds an optional \"Gift message\" field to the block-based checkout using the WooCommerce additional checkout fields API. The message is saved with the order and shown in the admin.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "blocks",
                "gift"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_init', function () {\n    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {\n        return;\n    }\n\n    woocommerce_register_additional_checkout_field( array(\n        'id'                => 'wpide/gift-message',\n        'label'             => 'Gift message (optional)',\n        'optionalLabel'     => 'Gift message (optional)',\n        'location'          => 'order',\n        'type'              => 'text',\n        'required'          => false,\n        'sanitize_callback' => function ( $value ) {\n            return mb_substr( sanitize_text_field( $value ), 0, 200 );\n        },\n    ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs WooCommerce 8.9 or newer and the Checkout block. For the classic checkout use a classic checkout field snippet instead."
        },
        {
            "id": "woo-hide-address-for-virtual",
            "version": 1,
            "title": "Skip Address Fields for Virtual-Only Orders",
            "description": "Removes the billing address fields from the classic checkout when everything in the cart is virtual or downloadable, keeping only name and email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "virtual",
                "digital"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_checkout_fields', function ( $fields ) {\n    if ( ! WC()->cart || WC()->cart->needs_shipping() ) {\n        return $fields;\n    }\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( ! $item['data']->is_virtual() ) {\n            return $fields;\n        }\n    }\n\n    // Billing fields to keep for virtual orders.\n    $keep = array( 'billing_first_name', 'billing_last_name', 'billing_email' );\n\n    foreach ( array_keys( $fields['billing'] ) as $key ) {\n        if ( ! in_array( $key, $keep, true ) ) {\n            unset( $fields['billing'][ $key ] );\n        }\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Some payment gateways need the billing address for fraud checks; test a payment before going live."
        },
        {
            "id": "woo-confirm-email-field",
            "version": 1,
            "title": "\"Confirm Email\" Field on Checkout",
            "description": "Adds a second email field to the classic checkout and blocks the order if the two addresses do not match, to cut down on typos.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "email",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    $fields['billing_email_confirm'] = array(\n        'label'    => 'Confirm email address',\n        'type'     => 'email',\n        'required' => true,\n        'class'    => array( 'form-row-wide' ),\n        'priority' => isset( $fields['billing_email']['priority'] ) ? $fields['billing_email']['priority'] + 1 : 111,\n        'validate' => array( 'email' ),\n    );\n\n    return $fields;\n}, 30 );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {\n    $email   = isset( $data['billing_email'] ) ? strtolower( trim( (string) $data['billing_email'] ) ) : '';\n    $confirm = isset( $data['billing_email_confirm'] ) ? strtolower( trim( (string) $data['billing_email_confirm'] ) ) : '';\n\n    if ( array_key_exists( 'billing_email_confirm', $data ) && $email !== $confirm ) {\n        $errors->add( 'validation', 'The email addresses do not match.' );\n    }\n}, 10, 2 );\n\nadd_filter( 'woocommerce_checkout_get_value', function ( $value, $input ) {\n    if ( 'billing_email_confirm' === $input && null === $value && is_user_logged_in() ) {\n        return wp_get_current_user()->user_email;\n    }\n\n    return $value;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Logged-in customers with an email on file see the field pre-filled."
        },
        {
            "id": "woo-age-confirmation-checkbox",
            "version": 1,
            "title": "Age Confirmation Checkbox at Checkout",
            "description": "Adds a required \"I confirm I am 18 or older\" checkbox to the classic checkout and records the confirmation on the order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "age",
                "compliance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_min_age = 18; // Minimum age the customer must confirm.\n\nadd_action( 'woocommerce_review_order_before_submit', function () use ( $wpide_min_age ) {\n    woocommerce_form_field( 'wpide_age_confirm', array(\n        'type'     => 'checkbox',\n        'class'    => array( 'form-row', 'validate-required' ),\n        'label'    => sprintf( 'I confirm that I am %d years or older.', $wpide_min_age ),\n        'required' => true,\n    ) );\n} );\n\nadd_action( 'woocommerce_checkout_process', function () {\n    if ( empty( $_POST['wpide_age_confirm'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        wc_add_notice( 'Please confirm your age to place the order.', 'error' );\n    }\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order ) use ( $wpide_min_age ) {\n    if ( ! empty( $_POST['wpide_age_confirm'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $order->update_meta_data( '_wpide_age_confirmed', $wpide_min_age . '+ confirmed ' . current_time( 'mysql' ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $age to your legal age. This is a self-declaration, not an identity check."
        },
        {
            "id": "woo-newsletter-optin-checkout",
            "version": 1,
            "title": "Newsletter Opt-In Checkbox at Checkout",
            "description": "Adds an unticked \"Send me news and offers\" checkbox to the classic checkout and stores the customer's consent on the order and their account.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "newsletter",
                "consent"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_review_order_before_submit', function () {\n    woocommerce_form_field( 'wpide_newsletter_optin', array(\n        'type'  => 'checkbox',\n        'class' => array( 'form-row' ),\n        'label' => 'Send me news and offers by email. You can unsubscribe at any time.',\n    ) );\n}, 5 );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order ) {\n    if ( empty( $_POST['wpide_newsletter_optin'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $order->update_meta_data( '_wpide_newsletter_optin', current_time( 'mysql' ) );\n\n    if ( $order->get_customer_id() ) {\n        update_user_meta( $order->get_customer_id(), 'wpide_newsletter_optin', current_time( 'mysql' ) );\n    }\n} );\n\nadd_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {\n    $date = $order->get_meta( '_wpide_newsletter_optin' );\n\n    echo '<p><strong>Newsletter:</strong> ' . ( $date ? esc_html( 'Opted in on ' . $date ) : esc_html( 'No' ) ) . '</p>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Consent is saved as order meta _wpide_newsletter_optin and user meta wpide_newsletter_optin. Connect it to your email tool with an export or integration."
        },
        {
            "id": "woo-registration-redirect",
            "version": 1,
            "title": "Redirect New Customers After Registration",
            "description": "Sends customers who create an account from the My Account page to a page of your choice, such as a welcome page or the shop.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "registration",
                "redirect",
                "my-account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_registration_redirect', function ( $redirect ) {\n    $page_slug = 'welcome'; // Slug of the destination page.\n\n    $page = get_page_by_path( $page_slug );\n\n    return $page ? get_permalink( $page ) : wc_get_page_permalink( 'shop' );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $page_slug to the slug of the page to send new customers to."
        },
        {
            "id": "woo-my-account-menu",
            "version": 1,
            "title": "Rename or Remove My Account Menu Items",
            "description": "Tidies the My Account navigation by renaming items and hiding ones you do not use, such as Downloads on a store without digital products.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "menu"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_account_menu_items', function ( $items ) {\n    // Endpoint => new label.\n    $rename = array(\n        'orders'       => 'My orders',\n        'edit-address' => 'Addresses',\n    );\n\n    // Endpoints to hide.\n    $remove = array( 'downloads' );\n\n    foreach ( $remove as $key ) {\n        unset( $items[ $key ] );\n    }\n\n    foreach ( $rename as $key => $label ) {\n        if ( isset( $items[ $key ] ) ) {\n            $items[ $key ] = $label;\n        }\n    }\n\n    return $items;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the $rename and $remove lists. Keys are the endpoint names (orders, downloads, edit-address, edit-account)."
        },
        {
            "id": "woo-my-account-custom-tab",
            "version": 1,
            "title": "Custom \"Support\" Tab in My Account",
            "description": "Adds a new Support section to the My Account area with its own URL and content, for help links, a contact form shortcode or FAQs.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "endpoint"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_account_tab = array(\n    'endpoint' => 'support',          // URL slug: /my-account/support/\n    'label'    => 'Support',          // Menu label.\n    'content'  => '<h3>Need help?</h3><p>Email us at help@example.com and we will reply within one business day.</p>',\n);\n\nadd_action( 'init', function () use ( $wpide_account_tab ) {\n    add_rewrite_endpoint( $wpide_account_tab['endpoint'], EP_ROOT | EP_PAGES );\n} );\n\nadd_filter( 'woocommerce_get_query_vars', function ( $vars ) use ( $wpide_account_tab ) {\n    $vars[ $wpide_account_tab['endpoint'] ] = $wpide_account_tab['endpoint'];\n\n    return $vars;\n} );\n\nadd_filter( 'woocommerce_account_menu_items', function ( $items ) use ( $wpide_account_tab ) {\n    $logout = isset( $items['customer-logout'] ) ? $items['customer-logout'] : null;\n    unset( $items['customer-logout'] );\n\n    $items[ $wpide_account_tab['endpoint'] ] = $wpide_account_tab['label'];\n\n    if ( $logout ) {\n        $items['customer-logout'] = $logout;\n    }\n\n    return $items;\n} );\n\nadd_action( 'woocommerce_account_' . $wpide_account_tab['endpoint'] . '_endpoint', function () use ( $wpide_account_tab ) {\n    echo wp_kses_post( do_shortcode( $wpide_account_tab['content'] ) );\n} );\n\nadd_filter( 'woocommerce_endpoint_' . $wpide_account_tab['endpoint'] . '_title', function () use ( $wpide_account_tab ) {\n    return $wpide_account_tab['label'];\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "After activating, visit Settings > Permalinks and click Save once so the new URL works. Change $content to your text or a shortcode."
        },
        {
            "id": "woo-disable-single-search-redirect",
            "version": 1,
            "title": "Show the Results Page for Single-Match Product Searches",
            "description": "Stops WooCommerce from jumping straight to a product when a search has only one match, so shoppers always see the search results page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_redirect_single_search_result', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-trim-sorting-options",
            "version": 1,
            "title": "Simplify the Shop Sorting Dropdown",
            "description": "Removes sorting options you do not need from the shop's \"Sort by\" menu and lets you rename the ones that remain.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_catalog_orderby', function ( $options ) {\n    // Options to remove.\n    $remove = array( 'rating' );\n\n    // Option => new label.\n    $rename = array(\n        'date'       => 'Newest first',\n        'price'      => 'Price: low to high',\n        'price-desc' => 'Price: high to low',\n    );\n\n    foreach ( $remove as $key ) {\n        unset( $options[ $key ] );\n    }\n\n    foreach ( $rename as $key => $label ) {\n        if ( isset( $options[ $key ] ) ) {\n            $options[ $key ] = $label;\n        }\n    }\n\n    return $options;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Available keys are menu_order, popularity, rating, date, price and price-desc."
        },
        {
            "id": "woo-hide-category-counts",
            "version": 1,
            "title": "Hide Product Counts Next to Categories",
            "description": "Removes the \"(12)\" product count shown beside category names on the shop page when categories are displayed.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "categories",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_subcategory_count_html', '__return_empty_string' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-hide-uncategorized",
            "version": 1,
            "title": "Hide the \"Uncategorized\" Product Category",
            "description": "Keeps the default Uncategorized product category out of category lists on the shop and in the product categories block and widget.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "categories",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_terms_args', function ( $args, $taxonomies ) {\n    if ( is_admin() || ! in_array( 'product_cat', (array) $taxonomies, true ) ) {\n        return $args;\n    }\n\n    $default = (int) get_option( 'default_product_cat' );\n\n    if ( $default ) {\n        $args['exclude'] = array_merge( wp_parse_id_list( $args['exclude'] ?? array() ), array( $default ) );\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products in the category are still visible in the shop; only the category itself is hidden."
        },
        {
            "id": "woo-exclude-category-from-shop",
            "version": 1,
            "title": "Exclude a Category From the Main Shop Page",
            "description": "Hides products from one or more categories on the main shop page while they remain available in their own category and by direct link.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_query', function ( $query ) {\n    $exclude = array( 'wholesale' ); // Category slugs to hide from the shop page.\n\n    if ( ! is_shop() ) {\n        return;\n    }\n\n    $tax_query   = (array) $query->get( 'tax_query' );\n    $tax_query[] = array(\n        'taxonomy' => 'product_cat',\n        'field'    => 'slug',\n        'terms'    => $exclude,\n        'operator' => 'NOT IN',\n    );\n\n    $query->set( 'tax_query', $tax_query );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "List category slugs in $exclude."
        },
        {
            "id": "woo-short-description-in-loop",
            "version": 1,
            "title": "Short Description Under Product Titles in the Shop",
            "description": "Shows a trimmed version of each product's short description below its title on shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "description"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_shop_loop_item_title', function () {\n    global $product;\n\n    $words = 15; // Maximum number of words.\n\n    if ( ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    $text = $product->get_short_description();\n\n    if ( '' === trim( $text ) ) {\n        return;\n    }\n\n    echo '<p class=\"wpide-loop-excerpt\" style=\"font-size:.9em;margin:.4em 0;\">' . esc_html( wp_trim_words( wp_strip_all_tags( $text ), $words ) ) . '</p>';\n}, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $words to set how many words are shown."
        },
        {
            "id": "woo-loop-quantity-input",
            "version": 1,
            "title": "Quantity Box on Shop Page Add to Cart Buttons",
            "description": "Adds a quantity field next to the add to cart button for simple products in shop listings, so shoppers can add several at once without opening the product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "quantity",
                "ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_loop_add_to_cart_link', function ( $html, $product ) {\n    if ( ! $product->is_type( 'simple' ) || ! $product->is_purchasable() || ! $product->is_in_stock() || $product->is_sold_individually() ) {\n        return $html;\n    }\n\n    $qty = woocommerce_quantity_input( array(\n        'min_value'   => $product->get_min_purchase_quantity(),\n        'max_value'   => $product->get_max_purchase_quantity(),\n        'input_value' => $product->get_min_purchase_quantity(),\n        'input_name'  => 'quantity',\n    ), $product, false );\n\n    return '<div class=\"wpide-loop-qty\" style=\"display:flex;gap:6px;align-items:center;justify-content:center;\">' . $qty . $html . '</div>';\n}, 10, 2 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_woocommerce' ) ) {\n        return;\n    }\n    ?>\n<script>\ndocument.addEventListener('change', function (e) {\n    if (!e.target.matches('.wpide-loop-qty input.qty')) { return; }\n    var button = e.target.closest('.wpide-loop-qty').querySelector('.add_to_cart_button');\n    if (button) { button.setAttribute('data-quantity', e.target.value); }\n});\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with AJAX add to cart. Variable products keep their \"Select options\" button."
        },
        {
            "id": "woo-custom-product-tab",
            "version": 1,
            "title": "Add a \"Shipping & Returns\" Product Tab",
            "description": "Adds a tab with the same shipping and returns information to every product page, next to Description and Reviews.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "tabs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_tabs', function ( $tabs ) {\n    $title   = 'Shipping & Returns';\n    $content = '<p>Orders ship within 2 business days. Returns are free within 30 days of delivery.</p>';\n\n    $tabs['wpide_shipping_returns'] = array(\n        'title'    => $title,\n        'priority' => 25,\n        'callback' => function () use ( $title, $content ) {\n            echo '<h2>' . esc_html( $title ) . '</h2>';\n            echo wp_kses_post( do_shortcode( $content ) );\n        },\n    );\n\n    return $tabs;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit $title and $content. HTML and shortcodes are allowed in the content."
        },
        {
            "id": "woo-rename-reorder-tabs",
            "version": 1,
            "title": "Rename and Reorder Product Tabs",
            "description": "Changes the titles and order of the Description, Additional information and Reviews tabs on product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "tabs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_tabs', function ( $tabs ) {\n    // Tab => array( new title, priority ).\n    $settings = array(\n        'description'            => array( 'Details', 10 ),\n        'reviews'                => array( '', 20 ),\n        'additional_information' => array( 'Specifications', 30 ),\n    );\n\n    foreach ( $settings as $key => $setting ) {\n        if ( ! isset( $tabs[ $key ] ) ) {\n            continue;\n        }\n\n        if ( '' !== $setting[0] ) {\n            $tabs[ $key ]['title'] = $setting[0];\n        }\n\n        $tabs[ $key ]['priority'] = $setting[1];\n    }\n\n    return $tabs;\n}, 98 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Lower priority numbers come first. Leave a title empty to keep the default."
        },
        {
            "id": "woo-hide-product-meta",
            "version": 1,
            "title": "Hide SKU, Categories and Tags on Product Pages",
            "description": "Removes the meta block below the add to cart button that lists the SKU, categories and tags of the product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_single_product', function () {\n    remove_action( 'woocommerce_single_product_summary', 'woocommerce_template_single_meta', 40 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "To hide only the SKU, use the \"Hide SKUs on the Front End\" snippet instead."
        },
        {
            "id": "woo-gallery-features",
            "version": 1,
            "title": "Turn Off Product Image Zoom, Lightbox or Slider",
            "description": "Disables the hover zoom, the full-screen lightbox and/or the thumbnail slider of the WooCommerce product gallery.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "gallery",
                "images",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    $keep = array(\n        'wc-product-gallery-zoom'     => false, // Hover zoom.\n        'wc-product-gallery-lightbox' => true,  // Full-screen lightbox.\n        'wc-product-gallery-slider'   => true,  // Thumbnail slider.\n    );\n\n    foreach ( $keep as $feature => $enabled ) {\n        if ( ! $enabled ) {\n            remove_theme_support( $feature );\n        }\n    }\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set each feature to true to keep it or false to turn it off."
        },
        {
            "id": "woo-gallery-thumbnail-columns",
            "version": 1,
            "title": "Change the Number of Product Gallery Thumbnails per Row",
            "description": "Sets how many thumbnails appear in each row below the main product image on product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "gallery",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_thumbnails_columns', function () {\n    return 5; // Thumbnails per row.\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Some themes override the gallery layout with their own CSS; adjust there if the change does not show."
        },
        {
            "id": "woo-custom-placeholder-image",
            "version": 1,
            "title": "Custom Placeholder Image for Products",
            "description": "Replaces the grey WooCommerce placeholder with your own image for products that do not have a featured image yet.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "images",
                "placeholder"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_placeholder_img_src', function ( $src ) {\n    $image_url = ''; // Full URL of your placeholder image.\n\n    return $image_url ? esc_url( $image_url ) : $src;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Upload the image to the Media Library and paste its URL into $image_url."
        },
        {
            "id": "woo-external-products-new-tab",
            "version": 1,
            "title": "Open External/Affiliate Product Links in a New Tab",
            "description": "Makes the buy buttons of external (affiliate) products open the partner site in a new tab, on shop pages and on the product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "affiliate",
                "external",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_loop_add_to_cart_link', function ( $html, $product ) {\n    if ( $product->is_type( 'external' ) && false === strpos( $html, 'target=' ) ) {\n        $html = str_replace( '<a ', '<a target=\"_blank\" rel=\"noopener sponsored\" ', $html );\n    }\n\n    return $html;\n}, 10, 2 );\n\nadd_action( 'woocommerce_after_add_to_cart_form', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->is_type( 'external' ) ) {\n        return;\n    }\n\n    echo \"<script>document.querySelectorAll('form.cart').forEach(function(f){ if (f.getAttribute('method') === 'get') { f.setAttribute('target', '_blank'); f.setAttribute('rel', 'noopener sponsored'); } });</script>\\n\";\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Adds rel=\"noopener sponsored\" to the links, which is recommended for affiliate links."
        },
        {
            "id": "woo-remove-cross-sells-cart",
            "version": 1,
            "title": "Remove Cross-Sells From the Cart Page",
            "description": "Hides the \"You may be interested in...\" product suggestions below the cart table on the classic cart page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "cross-sells"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_cart', function () {\n    remove_action( 'woocommerce_cart_collaterals', 'woocommerce_cross_sell_display' );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only the classic cart is affected. In the Cart block, remove the cross-sells inner block in the editor."
        },
        {
            "id": "woo-upsells-layout",
            "version": 1,
            "title": "Number and Columns of Upsell Products",
            "description": "Controls how many \"You may also like\" upsell products appear on product pages and in how many columns.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "upsells",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_upsell_display_args', function ( $args ) {\n    $args['posts_per_page'] = 3; // How many upsells to show.\n    $args['columns']        = 3; // Columns.\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $limit to 0 to show all upsells."
        },
        {
            "id": "woo-checkout-item-thumbnails",
            "version": 1,
            "title": "Product Thumbnails in the Checkout Order Summary",
            "description": "Shows a small image beside each product name in the \"Your order\" table on the classic checkout.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_cart_item_name', function ( $name, $item, $key ) {\n    $size = array( 48, 48 ); // Thumbnail size in pixels.\n\n    if ( ! is_checkout() || empty( $item['data'] ) ) {\n        return $name;\n    }\n\n    $thumb = $item['data']->get_image( $size, array( 'style' => 'vertical-align:middle;margin-right:8px;border-radius:3px;' ) );\n\n    return $thumb . $name;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $size to a different image size or pixel array."
        },
        {
            "id": "woo-unlink-cart-items",
            "version": 1,
            "title": "Remove Product Links in the Cart",
            "description": "Shows product names in the cart as plain text instead of links, which keeps shoppers on the path to checkout.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_cart_item_permalink', '__return_empty_string' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-auto-update-cart",
            "version": 1,
            "title": "Update the Cart Automatically When Quantities Change",
            "description": "Refreshes the classic cart totals as soon as a quantity is changed, so shoppers do not need to press \"Update cart\". The button is hidden.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "ajax",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_cart' ) || ! is_cart() ) {\n        return;\n    }\n    ?>\n<style>.woocommerce-cart-form button[name=\"update_cart\"]{display:none !important;}</style>\n<script>\n(function () {\n    var timer;\n    document.addEventListener('change', function (e) {\n        if (!e.target.matches('.woocommerce-cart-form input.qty')) { return; }\n        clearTimeout(timer);\n        timer = setTimeout(function () {\n            var button = document.querySelector('.woocommerce-cart-form button[name=\"update_cart\"]');\n            if (button) { button.disabled = false; button.click(); }\n        }, 500);\n    });\n})();\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only for the classic cart shortcode; the Cart block already updates instantly."
        },
        {
            "id": "woo-added-to-cart-message",
            "version": 1,
            "title": "Custom \"Added to Cart\" Message",
            "description": "Rewrites the notice shown after a product is added to the cart, with your own text and a link to the cart or checkout.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wc_add_to_cart_message_html', function ( $message, $products ) {\n    $link_to = 'cart'; // 'cart' or 'checkout'.\n\n    $count = 0;\n    $names = array();\n\n    foreach ( (array) $products as $product_id => $qty ) {\n        $count  += (int) $qty;\n        $names[] = get_the_title( $product_id );\n    }\n\n    $url   = 'checkout' === $link_to ? wc_get_checkout_url() : wc_get_cart_url();\n    $label = 'checkout' === $link_to ? 'Checkout now' : 'View cart';\n\n    return sprintf(\n        '<a href=\"%1$s\" tabindex=\"1\" class=\"button wc-forward\">%2$s</a> %3$s',\n        esc_url( $url ),\n        esc_html( $label ),\n        esc_html( sprintf( 'Great choice! %s is now in your cart.', implode( ', ', $names ) ) )\n    );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $link_to to 'checkout' to point the button at checkout instead of the cart."
        },
        {
            "id": "woo-empty-cart-message",
            "version": 1,
            "title": "Custom Empty Cart Message",
            "description": "Replaces \"Your cart is currently empty\" on the classic cart page with a friendlier message of your own.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wc_empty_cart_message', function () {\n    return 'Your cart is empty for now. Browse our bestsellers and find something you love.';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The Cart block has its own empty cart template you can edit in the Site Editor."
        },
        {
            "id": "woo-redirect-empty-cart",
            "version": 1,
            "title": "Redirect an Empty Cart to the Shop",
            "description": "Sends visitors who open the cart page with nothing in it straight to the shop page instead of showing an empty cart.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! function_exists( 'is_cart' ) || ! is_cart() || ! WC()->cart || ! WC()->cart->is_empty() ) {\n        return;\n    }\n\n    $target = wc_get_page_permalink( 'shop' ); // Where empty carts go.\n\n    wp_safe_redirect( $target );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $target to redirect to another page such as a category or the homepage."
        },
        {
            "id": "woo-disable-persistent-cart",
            "version": 1,
            "title": "Disable the Persistent Cart",
            "description": "Stops WooCommerce from saving logged-in customers' carts to their account between sessions, so each visit starts with an empty cart.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "sessions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_persistent_cart_enabled', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Guests are not affected because their carts are stored in a session cookie."
        },
        {
            "id": "woo-custom-thank-you-page",
            "version": 1,
            "title": "Redirect to a Custom Thank-You Page",
            "description": "Sends customers to your own thank-you page after a successful order instead of the default order received page, passing the order number along.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "thank-you",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    $page_slug = 'thank-you'; // Slug of your thank-you page.\n\n    if ( ! function_exists( 'is_wc_endpoint_url' ) || ! is_wc_endpoint_url( 'order-received' ) ) {\n        return;\n    }\n\n    $order_id = absint( get_query_var( 'order-received' ) );\n    $key      = isset( $_GET['key'] ) ? wc_clean( wp_unslash( $_GET['key'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n    $order    = $order_id ? wc_get_order( $order_id ) : false;\n    $page     = get_page_by_path( $page_slug );\n\n    if ( ! $order || ! $page || ! hash_equals( $order->get_order_key(), $key ) || $order->has_status( 'failed' ) ) {\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 'order', $order->get_order_number(), get_permalink( $page ) ) );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Create the page and set $page_slug. Failed orders are left on the default page so the customer can retry payment."
        },
        {
            "id": "woo-order-received-title",
            "version": 1,
            "title": "Change the \"Order Received\" Page Title",
            "description": "Replaces the \"Order received\" heading on the thank-you page with your own text.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "thank-you",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_endpoint_order-received_title', function () {\n    return 'Thank you for your order!';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-thankyou-next-order-coupon",
            "version": 1,
            "title": "Next-Order Coupon Banner on the Thank-You Page",
            "description": "Shows a banner with a discount code for the customer's next purchase at the top of the order received page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "thank-you",
                "coupons",
                "retention"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_before_thankyou",
            "insert_number": 1,
            "code": "<div class=\"wpide-next-order\" style=\"margin:0 0 24px;padding:20px;border:2px dashed #2271b1;border-radius:8px;text-align:center;\">\n    <p style=\"margin:0 0 6px;font-size:1.1em;\"><strong>Thanks for shopping with us!</strong></p>\n    <p style=\"margin:0;\">Use code <code style=\"font-size:1.1em;padding:2px 8px;background:#f0f6fc;\">THANKYOU10</code> for 10% off your next order.</p>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Create the coupon in WooCommerce > Marketing > Coupons and replace THANKYOU10 with its code."
        },
        {
            "id": "woo-shop-promo-banner",
            "version": 1,
            "title": "Promotional Banner Above the Product List",
            "description": "Displays a banner with a message and a button above the products on the shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "banner",
                "promotion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "wc_before_products_list",
            "insert_number": 1,
            "code": "<div class=\"wpide-shop-banner\" style=\"margin:0 0 24px;padding:20px 24px;border-radius:8px;background:#1e1e1e;color:#fff;display:flex;flex-wrap:wrap;gap:12px;align-items:center;justify-content:space-between;\">\n    <p style=\"margin:0;font-size:1.05em;\"><strong>Spring sale:</strong> 20% off selected items until Sunday.</p>\n    <p style=\"margin:0;\"><a class=\"button\" href=\"/product-category/sale/\">Shop the sale</a></p>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the text and link. Remove the button paragraph if you only need a message."
        },
        {
            "id": "woo-cart-trust-notice",
            "version": 1,
            "title": "Reassurance Notice Above the Cart",
            "description": "Shows a short list of reassurances (secure payment, free returns, fast dispatch) above the cart table to reduce cart abandonment.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "trust",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_before_cart",
            "insert_number": 1,
            "code": "<ul class=\"wpide-cart-trust\" style=\"display:flex;flex-wrap:wrap;gap:8px 24px;list-style:none;margin:0 0 20px;padding:12px 16px;border-radius:6px;background:#f6f7f7;font-size:.95em;\">\n    <li>&#10003; Secure payment</li>\n    <li>&#10003; Free returns within 30 days</li>\n    <li>&#10003; Dispatched within 24 hours</li>\n</ul>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the three items to match your store policies."
        },
        {
            "id": "woo-checkout-help-box",
            "version": 1,
            "title": "Help Box Above the Checkout Form",
            "description": "Adds a small \"Questions about your order?\" box with your contact details at the top of the checkout page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "support"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_before_checkout_form",
            "insert_number": 1,
            "code": "<div class=\"wpide-checkout-help\" style=\"margin:0 0 20px;padding:14px 18px;border-left:4px solid #2271b1;background:#f0f6fc;\">\n    <strong>Questions about your order?</strong> Call <a href=\"tel:+15550100000\">+1 555 010 0000</a> or email <a href=\"mailto:orders@example.com\">orders@example.com</a>.\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Replace the phone number and email address with your own."
        },
        {
            "id": "woo-cart-help-after",
            "version": 1,
            "title": "Contact Links Below the Cart",
            "description": "Adds a \"Need help choosing?\" line with contact links below the cart on the classic cart page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "support"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_after_cart",
            "insert_number": 1,
            "code": "<p class=\"wpide-cart-help\" style=\"margin-top:24px;text-align:center;font-size:.95em;\">Need help choosing? <a href=\"/contact/\">Contact our team</a> or email <a href=\"mailto:hello@example.com\">hello@example.com</a>.</p>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Replace the links with your own contact page and email address."
        },
        {
            "id": "woo-size-guide-section",
            "version": 1,
            "title": "Size Guide Below the Product Summary",
            "description": "Adds a collapsible size guide table below the product summary on every product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "size-guide"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "wc_after_single_product_summary",
            "insert_number": 1,
            "code": "<details class=\"wpide-size-guide\" style=\"clear:both;margin:24px 0;padding:12px 16px;border:1px solid #dcdcde;border-radius:6px;\">\n    <summary style=\"cursor:pointer;font-weight:600;\">Size guide</summary>\n    <table style=\"width:100%;margin-top:12px;border-collapse:collapse;\">\n        <thead><tr><th style=\"text-align:left;\">Size</th><th style=\"text-align:left;\">Chest (cm)</th><th style=\"text-align:left;\">Waist (cm)</th></tr></thead>\n        <tbody>\n            <tr><td>S</td><td>86-91</td><td>71-76</td></tr>\n            <tr><td>M</td><td>96-101</td><td>81-86</td></tr>\n            <tr><td>L</td><td>106-111</td><td>91-96</td></tr>\n            <tr><td>XL</td><td>116-121</td><td>101-106</td></tr>\n        </tbody>\n    </table>\n</details>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Replace the table with your own measurements. Use a WPIDE condition to show it only on clothing categories."
        },
        {
            "id": "woo-product-page-guarantee",
            "version": 1,
            "title": "Guarantee Note Before Single Products",
            "description": "Shows a slim guarantee message at the top of every product page, such as a money-back promise.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "trust"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "wc_before_single_product",
            "insert_number": 1,
            "code": "<p class=\"wpide-guarantee\" style=\"margin:0 0 16px;padding:8px 14px;border-radius:4px;background:#edfaef;color:#1e4620;font-size:.95em;\">&#9733; 60-day money-back guarantee on every product.</p>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the message to match your guarantee."
        },
        {
            "id": "woo-shop-bottom-seo-text",
            "version": 1,
            "title": "Text Block Below the Product List",
            "description": "Adds an introduction or SEO text block underneath the products on the shop and category pages, where it does not push products down.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "seo",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "wc_after_products_list",
            "insert_number": 1,
            "code": "<div class=\"wpide-shop-footer-text\" style=\"margin-top:32px;font-size:.95em;line-height:1.6;\">\n    <h2 style=\"font-size:1.2em;\">About our products</h2>\n    <p>Every item in our store is sourced from small makers and tested before it ships. Not sure what to pick? Our team is happy to help.</p>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Replace the paragraph with your own content."
        },
        {
            "id": "woo-order-again-processing",
            "version": 1,
            "title": "Show \"Order Again\" for Processing Orders",
            "description": "Lets customers reorder from orders that are still processing, not only completed ones, using the \"Order again\" button in My Account.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "orders",
                "reorder"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_valid_order_statuses_for_order_again', function ( $statuses ) {\n    $statuses[] = 'processing';\n\n    return array_unique( $statuses );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add more statuses to the list if needed, for example on-hold."
        },
        {
            "id": "woo-cancel-on-hold-orders",
            "version": 1,
            "title": "Let Customers Cancel On-Hold Orders",
            "description": "Adds a Cancel button in My Account for orders that are on hold (for example unpaid bank transfers), not just pending ones.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "orders",
                "cancel"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_valid_order_statuses_for_cancel', function ( $statuses ) {\n    $statuses[] = 'on-hold';\n\n    return array_unique( $statuses );\n}, 10, 1 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Cancelled orders restock their items automatically."
        },
        {
            "id": "woo-my-account-orders-per-page",
            "version": 1,
            "title": "Orders per Page in My Account",
            "description": "Changes how many orders appear on each page of the My Account Orders list.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_my_account_my_orders_query', function ( $args ) {\n    $args['limit'] = 20; // Orders per page.\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-hide-shipping-cart-page",
            "version": 1,
            "title": "Hide Shipping Costs on the Cart Page",
            "description": "Leaves shipping out of the cart totals so it is only calculated and shown at checkout, once the customer has entered an address.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_cart_ready_to_calc_shipping', function ( $show ) {\n    return function_exists( 'is_cart' ) && is_cart() ? false : $show;\n}, 99 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Applies to the classic cart. The order total on the cart page excludes shipping until checkout."
        },
        {
            "id": "woo-cart-total-weight",
            "version": 1,
            "title": "Show the Total Weight in the Cart and Checkout",
            "description": "Adds a \"Total weight\" row to the cart and checkout totals, useful for stores that charge shipping by weight.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "shipping",
                "weight"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cart_weight_row = function () {\n    if ( ! WC()->cart ) {\n        return;\n    }\n\n    $weight = (float) WC()->cart->get_cart_contents_weight();\n\n    if ( $weight <= 0 ) {\n        return;\n    }\n\n    printf(\n        '<tr class=\"wpide-cart-weight\"><th>%s</th><td data-title=\"%s\">%s</td></tr>',\n        esc_html( 'Total weight' ),\n        esc_attr( 'Total weight' ),\n        esc_html( wc_format_weight( $weight ) )\n    );\n};\n\nadd_action( 'woocommerce_cart_totals_before_order_total', $wpide_cart_weight_row );\nadd_action( 'woocommerce_review_order_before_order_total', $wpide_cart_weight_row );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the weight unit set in WooCommerce > Settings > Products."
        },
        {
            "id": "woo-maximum-cart-weight",
            "version": 1,
            "title": "Maximum Cart Weight",
            "description": "Prevents checkout when the products in the cart weigh more than your carrier allows and tells the customer by how much they are over.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "shipping",
                "weight"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_check_cart_items', function () {\n    $max_weight = 30; // In the store weight unit (kg, lbs...).\n\n    $weight = (float) WC()->cart->get_cart_contents_weight();\n\n    if ( $weight > $max_weight ) {\n        wc_add_notice( sprintf( 'Your order weighs %1$s, above our maximum of %2$s per order. Please remove %3$s to continue.', wc_format_weight( $weight ), wc_format_weight( $max_weight ), wc_format_weight( $weight - $max_weight ) ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $max_weight in your store's weight unit. Products without a weight count as 0."
        },
        {
            "id": "woo-order-number-prefix",
            "version": 1,
            "title": "Add a Prefix to Order Numbers",
            "description": "Shows order numbers with a prefix such as \"WEB-1045\" in emails, My Account and the admin, without changing the real order IDs.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "order-number"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_order_number', function ( $number ) {\n    $prefix = 'WEB-'; // Text before every order number.\n\n    return $prefix . $number;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Searching the orders screen still uses the plain number. Payment gateways may display the prefixed number in their dashboards."
        },
        {
            "id": "woo-product-price-shortcode",
            "version": 1,
            "title": "Product Price Shortcode",
            "description": "Adds a [wpide_product_price id=\"123\"] shortcode that prints the current price of any product, including sale formatting, inside posts and pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shortcode",
                "price"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_product_price', function ( $atts ) {\n    $atts = shortcode_atts( array( 'id' => 0 ), $atts, 'wpide_product_price' );\n\n    $id      = absint( $atts['id'] ) ? absint( $atts['id'] ) : get_the_ID();\n    $product = $id ? wc_get_product( $id ) : false;\n\n    if ( ! $product ) {\n        return '';\n    }\n\n    return '<span class=\"wpide-product-price\">' . wp_kses_post( $product->get_price_html() ) . '</span>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the product ID from the Products list. On a product page the id can be omitted."
        },
        {
            "id": "woo-cart-count-shortcode",
            "version": 1,
            "title": "Cart Link With Item Count Shortcode",
            "description": "Adds a [wpide_cart_link] shortcode that shows a cart link with the number of items and total, which updates automatically when products are added.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "shortcode",
                "header"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cart_link_html = function () {\n    if ( ! WC()->cart ) {\n        return '<a class=\"wpide-cart-link\" href=\"' . esc_url( wc_get_cart_url() ) . '\">Cart</a>';\n    }\n\n    $count = WC()->cart->get_cart_contents_count();\n\n    return sprintf(\n        '<a class=\"wpide-cart-link\" href=\"%1$s\">Cart <span class=\"wpide-cart-count\">(%2$d)</span> %3$s</a>',\n        esc_url( wc_get_cart_url() ),\n        (int) $count,\n        $count ? wp_kses_post( WC()->cart->get_cart_subtotal() ) : ''\n    );\n};\n\nadd_shortcode( 'wpide_cart_link', function () use ( $wpide_cart_link_html ) {\n    return $wpide_cart_link_html();\n} );\n\nadd_filter( 'woocommerce_add_to_cart_fragments', function ( $fragments ) use ( $wpide_cart_link_html ) {\n    $fragments['a.wpide-cart-link'] = $wpide_cart_link_html();\n\n    return $fragments;\n} );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    if ( function_exists( 'is_woocommerce' ) && wp_script_is( 'wc-cart-fragments', 'registered' ) ) {\n        wp_enqueue_script( 'wc-cart-fragments' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Place it in a header widget, Shortcode block or menu. Uses WooCommerce cart fragments to refresh."
        },
        {
            "id": "woo-cart-menu-item",
            "version": 1,
            "title": "Add a Cart Item to the Main Menu",
            "description": "Appends a cart link with the item count to your theme's primary menu, and keeps the count up to date as products are added.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "menu",
                "header"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cart_menu_location = 'primary'; // Theme menu location.\n\n$wpide_cart_menu_link = function () {\n    $count = WC()->cart ? WC()->cart->get_cart_contents_count() : 0;\n\n    return sprintf( '<a class=\"wpide-menu-cart\" href=\"%1$s\">Cart (%2$d)</a>', esc_url( wc_get_cart_url() ), (int) $count );\n};\n\nadd_filter( 'wp_nav_menu_items', function ( $items, $args ) use ( $wpide_cart_menu_location, $wpide_cart_menu_link ) {\n    if ( isset( $args->theme_location ) && $args->theme_location === $wpide_cart_menu_location ) {\n        $items .= '<li class=\"menu-item wpide-menu-cart-item\">' . $wpide_cart_menu_link() . '</li>';\n    }\n\n    return $items;\n}, 10, 2 );\n\nadd_filter( 'woocommerce_add_to_cart_fragments', function ( $fragments ) use ( $wpide_cart_menu_link ) {\n    $fragments['a.wpide-menu-cart'] = $wpide_cart_menu_link();\n\n    return $fragments;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $location to your theme's menu location slug (look under Appearance > Menus > Manage Locations). Works with classic menus."
        },
        {
            "id": "woo-sticky-add-to-cart",
            "version": 1,
            "title": "Sticky Add to Cart Bar on Product Pages",
            "description": "Shows a slim bar with the product name, price and a buy button fixed to the bottom of the screen once the shopper scrolls past the main add to cart button.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "mobile",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_product' ) || ! is_product() ) {\n        return;\n    }\n\n    $product = wc_get_product( get_queried_object_id() );\n\n    if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) {\n        return;\n    }\n    ?>\n<div id=\"wpide-sticky-cart\" hidden style=\"position:fixed;left:0;right:0;bottom:0;z-index:9998;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#fff;box-shadow:0 -2px 10px rgba(0,0,0,.12);\">\n    <span style=\"font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;\"><?php echo esc_html( $product->get_name() ); ?></span>\n    <span><?php echo wp_kses_post( $product->get_price_html() ); ?></span>\n    <button type=\"button\" class=\"button alt\"><?php echo esc_html( $product->single_add_to_cart_text() ); ?></button>\n</div>\n<script>\n(function () {\n    var bar = document.getElementById('wpide-sticky-cart');\n    var form = document.querySelector('form.cart');\n    if (!bar || !form || !('IntersectionObserver' in window)) { return; }\n    new IntersectionObserver(function (entries) {\n        bar.hidden = entries[0].isIntersecting || entries[0].boundingClientRect.top > 0;\n    }).observe(form);\n    bar.querySelector('button').addEventListener('click', function () {\n        form.scrollIntoView({ behavior: 'smooth', block: 'center' });\n        var submit = form.querySelector('.single_add_to_cart_button');\n        if (submit && form.classList.contains('cart') && !form.classList.contains('variations_form')) { submit.focus(); }\n    });\n})();\n</script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The button scrolls back to the product form, so variations and add-ons are still chosen normally."
        },
        {
            "id": "woo-image-swap-hover",
            "version": 1,
            "title": "Show the Second Product Image on Hover",
            "description": "In shop listings, swaps the product image for the first gallery image when the shopper hovers over it, giving a quick second look.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "images",
                "hover"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop_item_title', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    $gallery = $product->get_gallery_image_ids();\n\n    if ( empty( $gallery ) ) {\n        return;\n    }\n\n    echo wp_get_attachment_image( (int) $gallery[0], 'woocommerce_thumbnail', false, array( 'class' => 'wpide-hover-image', 'loading' => 'lazy', 'alt' => '' ) );\n}, 11 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.woocommerce ul.products li.product a{position:relative;display:block;}.woocommerce ul.products li.product .wpide-hover-image{position:absolute;top:0;left:0;width:100%;height:auto;opacity:0;transition:opacity .3s;}.woocommerce ul.products li.product:hover .wpide-hover-image{opacity:1;}@media (hover:none){.woocommerce ul.products li.product .wpide-hover-image{display:none;}}</style>' . \"\\n\";\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products without gallery images keep their single image."
        },
        {
            "id": "woo-related-by-tag-only",
            "version": 1,
            "title": "Related Products by Tag Only",
            "description": "Makes the related products section match products by shared tags instead of shared categories, for more precise suggestions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "related-products",
                "tags"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_product_related_posts_relate_by_category', '__return_false' );\nadd_filter( 'woocommerce_product_related_posts_relate_by_tag', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products without tags will show no related products."
        },
        {
            "id": "woo-search-products-only",
            "version": 1,
            "title": "Search Only Products",
            "description": "Makes the site search return products only, turning the regular search box into a product search.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() ) {\n        $query->set( 'post_type', 'product' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Admin searches are not affected."
        },
        {
            "id": "woo-todays-sales-widget",
            "version": 1,
            "title": "Today's Sales Dashboard Widget",
            "description": "Adds a dashboard widget showing today's number of orders, revenue and average order value, so you can check the day at a glance.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "dashboard",
                "reports",
                "sales"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'view_woocommerce_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_todays_sales', 'Today\\'s sales', function () {\n        $start  = new DateTime( 'today', wp_timezone() );\n        $orders = wc_get_orders( array(\n            'status'       => array( 'wc-processing', 'wc-completed', 'wc-on-hold' ),\n            'date_created' => '>=' . $start->getTimestamp(),\n            'limit'        => -1,\n            'type'         => 'shop_order',\n        ) );\n\n        $count   = count( $orders );\n        $revenue = 0;\n\n        foreach ( $orders as $order ) {\n            $revenue += (float) $order->get_total() - (float) $order->get_total_refunded();\n        }\n\n        $average = $count ? $revenue / $count : 0;\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        printf( '<tr><td>Orders</td><td><strong>%d</strong></td></tr>', (int) $count );\n        printf( '<tr><td>Revenue</td><td><strong>%s</strong></td></tr>', wp_kses_post( wc_price( $revenue ) ) );\n        printf( '<tr><td>Average order</td><td><strong>%s</strong></td></tr>', wp_kses_post( wc_price( $average ) ) );\n        echo '</tbody></table>';\n        $hpos = class_exists( '\\\\Automattic\\\\WooCommerce\\\\Utilities\\\\OrderUtil' ) && \\Automattic\\WooCommerce\\Utilities\\OrderUtil::custom_orders_table_usage_is_enabled();\n        printf( '<p><a href=\"%s\">View orders</a></p>', esc_url( admin_url( $hpos ? 'admin.php?page=wc-orders' : 'edit.php?post_type=shop_order' ) ) );\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Counts orders in processing, completed and on-hold status created since midnight in the site timezone."
        },
        {
            "id": "woo-admin-bar-processing-orders",
            "version": 1,
            "title": "Processing Orders Count in the Admin Bar",
            "description": "Adds a toolbar item showing how many orders are waiting to be processed, linking to the filtered orders list. Visible to shop managers and administrators.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin-bar",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! current_user_can( 'edit_shop_orders' ) ) {\n        return;\n    }\n\n    $count = get_transient( 'wpide_processing_orders_count' );\n\n    if ( false === $count ) {\n        $result = wc_get_orders( array( 'status' => 'wc-processing', 'limit' => 1, 'paginate' => true, 'return' => 'ids', 'type' => 'shop_order' ) );\n        $count  = (int) $result->total;\n        set_transient( 'wpide_processing_orders_count', $count, 5 * MINUTE_IN_SECONDS );\n    }\n\n    $hpos = class_exists( '\\\\Automattic\\\\WooCommerce\\\\Utilities\\\\OrderUtil' ) && \\Automattic\\WooCommerce\\Utilities\\OrderUtil::custom_orders_table_usage_is_enabled();\n\n    $bar->add_node( array(\n        'id'    => 'wpide-processing-orders',\n        'title' => sprintf( 'Orders to process: %d', (int) $count ),\n        'href'  => admin_url( $hpos ? 'admin.php?page=wc-orders&status=wc-processing' : 'edit.php?post_type=shop_order&post_status=wc-processing' ),\n    ) );\n}, 80 );\n\nadd_action( 'woocommerce_order_status_changed', function () {\n    delete_transient( 'wpide_processing_orders_count' );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The count is cached for five minutes to keep the toolbar fast."
        },
        {
            "id": "woo-hide-loop-ratings",
            "version": 1,
            "title": "Hide Star Ratings in Shop Listings",
            "description": "Removes the star rating below product titles on shop and category pages. Ratings stay visible on product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "reviews",
                "ratings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop', function () {\n    remove_action( 'woocommerce_after_shop_loop_item_title', 'woocommerce_template_loop_rating', 5 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-stock-status-in-loop",
            "version": 1,
            "title": "Show Stock Status in Shop Listings",
            "description": "Displays \"In stock\", \"Only 3 left\" or \"Out of stock\" under each product's price on the shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "stock"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_shop_loop_item_title', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || $product->is_type( array( 'variable', 'grouped', 'external' ) ) ) {\n        return;\n    }\n\n    echo wp_kses_post( wc_get_stock_html( $product ) );\n}, 15 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the same availability text as the product page."
        },
        {
            "id": "woo-validate-phone",
            "version": 1,
            "title": "Validate the Checkout Phone Number Format",
            "description": "Rejects billing phone numbers that contain letters or too few digits on the classic checkout, so you get numbers you can actually call.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {\n    $min_digits = 7; // Minimum number of digits.\n\n    if ( empty( $data['billing_phone'] ) ) {\n        return;\n    }\n\n    $phone  = (string) $data['billing_phone'];\n    $digits = preg_replace( '/\\D/', '', $phone );\n\n    if ( preg_match( '/[^0-9\\s\\-\\.\\(\\)\\+]/', $phone ) || strlen( $digits ) < $min_digits ) {\n        $errors->add( 'validation', 'Please enter a valid phone number.' );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $min_digits to suit your country. Spaces, dashes, dots, brackets and a leading + are allowed."
        },
        {
            "id": "woo-remove-optional-label",
            "version": 1,
            "title": "Remove \"(optional)\" From Checkout Field Labels",
            "description": "Hides the \"(optional)\" text WooCommerce adds after the labels of fields that are not required.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_form_field', function ( $field ) {\n    return preg_replace( '#\\s*<span class=\"optional\">.*?</span>#', '', $field );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Required fields still show their asterisk."
        },
        {
            "id": "woo-replace-store-text",
            "version": 1,
            "title": "Replace WooCommerce Texts Without a Translation Plugin",
            "description": "Swaps specific WooCommerce texts, such as \"Billing details\" or \"Proceed to checkout\", for your own wording using a simple list.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "text",
                "translation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_store_text = array(\n    // Original text => replacement.\n    'Billing details'     => 'Your details',\n    'Proceed to checkout' => 'Continue to checkout',\n    'Related products'    => 'You may also like',\n);\n\nadd_filter( 'gettext_woocommerce', function ( $translation, $text ) use ( $wpide_store_text ) {\n    return isset( $wpide_store_text[ $text ] ) ? $wpide_store_text[ $text ] : $translation;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The original text must match exactly, including capitals. Texts in blocks may come from JavaScript translations and are not affected."
        },
        {
            "id": "woo-auto-generate-sku",
            "version": 1,
            "title": "Generate SKUs Automatically",
            "description": "Gives every product saved without a SKU an automatic one based on a prefix and the product ID, for example \"SHOP-0142\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sku",
                "products",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    $prefix = 'SHOP-'; // Text before the number.\n    $pad    = 4;       // Minimum number of digits.\n\n    if ( '' !== $product->get_sku( 'edit' ) || ! $product->get_id() ) {\n        return;\n    }\n\n    $sku = $prefix . str_pad( (string) $product->get_id(), $pad, '0', STR_PAD_LEFT );\n\n    if ( ! wc_get_product_id_by_sku( $sku ) ) {\n        try {\n            $product->set_sku( $sku );\n        } catch ( WC_Data_Exception $e ) {\n            // SKU already in use: leave it empty.\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only fills empty SKUs when a product is saved; existing SKUs are never changed. Change $prefix and $pad."
        },
        {
            "id": "woo-store-opening-hours",
            "version": 1,
            "title": "Accept Orders Only During Opening Hours",
            "description": "Disables checkout outside your opening hours and shows a notice with the time you open again. Ideal for restaurants and local delivery.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "hours",
                "restaurant"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_opening_hours = array(\n    // Day number => array( open, close ) in 24h format.\n    1 => array( '09:00', '21:00' ),\n    2 => array( '09:00', '21:00' ),\n    3 => array( '09:00', '21:00' ),\n    4 => array( '09:00', '21:00' ),\n    5 => array( '09:00', '22:00' ),\n    6 => array( '10:00', '22:00' ),\n);\n\n$wpide_store_is_open = function () use ( $wpide_opening_hours ) {\n    $now = new DateTime( 'now', wp_timezone() );\n    $day = (int) $now->format( 'N' );\n\n    if ( empty( $wpide_opening_hours[ $day ] ) ) {\n        return false;\n    }\n\n    $time = $now->format( 'H:i' );\n\n    return $time >= $wpide_opening_hours[ $day ][0] && $time < $wpide_opening_hours[ $day ][1];\n};\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_store_is_open ) {\n    if ( ! $wpide_store_is_open() ) {\n        wc_add_notice( 'We are closed right now. You can browse and fill your cart, and place your order during our opening hours.', 'error' );\n    }\n} );\n\nadd_action( 'woocommerce_checkout_process', function () use ( $wpide_store_is_open ) {\n    if ( ! $wpide_store_is_open() ) {\n        wc_add_notice( 'Sorry, we are not taking orders at the moment.', 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Times use the site timezone from Settings > General. Set $open and $close per weekday (1 = Monday ... 7 = Sunday); leave a day out to close all day."
        },
        {
            "id": "woo-vacation-mode",
            "version": 1,
            "title": "Store Vacation Mode",
            "description": "Pauses all purchasing between two dates and shows a store-wide notice explaining when you will be back, while the catalog stays online.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "vacation",
                "notice",
                "store"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_vacation = array(\n    'start'   => '2026-12-23',   // First day closed (YYYY-MM-DD).\n    'end'     => '2027-01-02',   // Last day closed.\n    'message' => 'We are on a short break. Orders placed now will ship after January 3.',\n);\n\n$wpide_on_vacation = function () use ( $wpide_vacation ) {\n    $today = wp_date( 'Y-m-d' );\n\n    return $today >= $wpide_vacation['start'] && $today <= $wpide_vacation['end'];\n};\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable ) use ( $wpide_on_vacation ) {\n    return $wpide_on_vacation() ? false : $purchasable;\n}, 99 );\n\nadd_filter( 'woocommerce_variation_is_purchasable', function ( $purchasable ) use ( $wpide_on_vacation ) {\n    return $wpide_on_vacation() ? false : $purchasable;\n}, 99 );\n\nadd_action( 'wp_body_open', function () use ( $wpide_on_vacation, $wpide_vacation ) {\n    if ( $wpide_on_vacation() ) {\n        echo '<div class=\"wpide-vacation-notice\" style=\"padding:10px 16px;background:#fcf9e8;border-bottom:1px solid #dba617;text-align:center;\">' . esc_html( $wpide_vacation['message'] ) . '</div>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Dates use the site timezone. Purchasing resumes automatically after $end."
        },
        {
            "id": "woo-link-guest-orders",
            "version": 1,
            "title": "Link Past Guest Orders to New Accounts",
            "description": "When a customer registers, attaches any earlier guest orders placed with the same email address to their new account so they appear in My Account.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "customers",
                "orders",
                "accounts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_created_customer', function ( $customer_id ) {\n    if ( function_exists( 'wc_update_new_customer_past_orders' ) ) {\n        wc_update_new_customer_past_orders( $customer_id );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs once per new customer account, using WooCommerce's own linking function."
        },
        {
            "id": "woo-prevent-repeat-purchase",
            "version": 1,
            "title": "Allow Each Customer to Buy a Product Only Once",
            "description": "Stops logged-in customers from buying the same product twice, for example a course, a membership or a limited item, and tells them they already own it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "products",
                "limits",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_once_products = array(); // Product IDs limited to one purchase, empty for all.\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) use ( $wpide_once_products ) {\n    if ( ! $purchasable || ! is_user_logged_in() ) {\n        return $purchasable;\n    }\n\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    if ( $wpide_once_products && ! in_array( $id, array_map( 'intval', $wpide_once_products ), true ) ) {\n        return $purchasable;\n    }\n\n    $user = wp_get_current_user();\n\n    return wc_customer_bought_product( $user->user_email, $user->ID, $id ) ? false : $purchasable;\n}, 20, 2 );\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_once_products ) {\n    global $product;\n\n    if ( ! is_user_logged_in() || ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    if ( $wpide_once_products && ! in_array( $product->get_id(), array_map( 'intval', $wpide_once_products ), true ) ) {\n        return;\n    }\n\n    $user = wp_get_current_user();\n\n    if ( wc_customer_bought_product( $user->user_email, $user->ID, $product->get_id() ) ) {\n        echo '<p class=\"wpide-already-bought\">' . esc_html( 'You have already purchased this product.' ) . '</p>';\n    }\n}, 31 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "List product IDs in $product_ids to limit only those, or leave it empty for all products. Guests cannot be checked, so consider requiring an account."
        },
        {
            "id": "woo-skip-cart-page",
            "version": 1,
            "title": "Skip the Cart Page",
            "description": "Sends shoppers who open the cart page straight to checkout when their cart has items, for a shorter path to purchase.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "checkout",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! function_exists( 'is_cart' ) || ! is_cart() || ! WC()->cart || WC()->cart->is_empty() ) {\n        return;\n    }\n\n    if ( wc_notice_count( 'error' ) > 0 ) {\n        return; // Stay on the cart so the shopper can fix cart errors.\n    }\n\n    wp_safe_redirect( wc_get_checkout_url() );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The classic checkout does not let shoppers change quantities, so they return to the product page to adjust their order."
        },
        {
            "id": "woo-orders-items-column",
            "version": 1,
            "title": "Purchased Items Column in the Orders List",
            "description": "Adds an \"Items\" column to the admin orders screen listing what each order contains, so you can see what to pack without opening orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_items_column_add = function ( $columns ) {\n    $new = array();\n\n    foreach ( $columns as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'order_number' === $key ) {\n            $new['wpide_items'] = 'Items';\n        }\n    }\n\n    return $new;\n};\n\n$wpide_items_column_render = function ( $column, $order ) {\n    if ( 'wpide_items' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n\n    if ( ! $order ) {\n        return;\n    }\n\n    $lines = array();\n\n    foreach ( $order->get_items() as $item ) {\n        $lines[] = esc_html( $item->get_quantity() . ' x ' . $item->get_name() );\n    }\n\n    echo wp_kses_post( implode( '<br>', $lines ) );\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_items_column_add );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_items_column_add );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_items_column_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_items_column_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with both the HPOS orders screen and the legacy posts-based screen."
        },
        {
            "id": "woo-orders-coupons-column",
            "version": 1,
            "title": "Coupons Used Column in the Orders List",
            "description": "Shows which coupon codes were used on each order in a new column on the admin orders screen.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "coupons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_coupon_column_add = function ( $columns ) {\n    $columns['wpide_coupons'] = 'Coupons';\n\n    return $columns;\n};\n\n$wpide_coupon_column_render = function ( $column, $order ) {\n    if ( 'wpide_coupons' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n    $codes = $order ? $order->get_coupon_codes() : array();\n\n    echo $codes ? esc_html( implode( ', ', $codes ) ) : '&ndash;';\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_coupon_column_add, 20 );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_coupon_column_add, 20 );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_coupon_column_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_coupon_column_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with both the HPOS orders screen and the legacy posts-based screen."
        },
        {
            "id": "woo-products-sales-column",
            "version": 1,
            "title": "Sortable Total Sales Column for Products",
            "description": "Adds a \"Sales\" column to the admin products list showing how many units each product has sold, and lets you sort by it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "products",
                "sales"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_edit-product_columns', function ( $columns ) {\n    $columns['wpide_sales'] = 'Sales';\n\n    return $columns;\n}, 20 );\n\nadd_action( 'manage_product_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_sales' === $column ) {\n        $product = wc_get_product( $post_id );\n        echo $product ? (int) $product->get_total_sales() : 0;\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_edit-product_sortable_columns', function ( $columns ) {\n    $columns['wpide_sales'] = 'wpide_sales';\n\n    return $columns;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() && $query->is_main_query() && 'wpide_sales' === $query->get( 'orderby' ) ) {\n        $query->set( 'meta_key', 'total_sales' );\n        $query->set( 'orderby', 'meta_value_num' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the total_sales counter WooCommerce keeps for each product."
        },
        {
            "id": "woo-product-internal-note",
            "version": 1,
            "title": "Private Internal Note Field for Products",
            "description": "Adds an \"Internal note\" box to the product edit screen for staff-only information such as supplier details. It is never shown to customers.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "products",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_general_product_data', function () {\n    echo '<div class=\"options_group\">';\n    woocommerce_wp_textarea_input( array(\n        'id'          => '_wpide_internal_note',\n        'label'       => 'Internal note',\n        'description' => 'Only visible to staff.',\n        'desc_tip'    => true,\n    ) );\n    echo '</div>';\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    if ( isset( $_POST['_wpide_internal_note'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product nonce.\n        $product->update_meta_data( '_wpide_internal_note', sanitize_textarea_field( wp_unslash( $_POST['_wpide_internal_note'] ) ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Saved in the product meta key _wpide_internal_note."
        },
        {
            "id": "woo-custom-order-status",
            "version": 1,
            "title": "Add an \"Awaiting Shipment\" Order Status",
            "description": "Registers a new order status you can set on orders that are packed and waiting for the carrier, and adds it to the bulk actions menu.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "status"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_register_shop_order_post_statuses', function ( $statuses ) {\n    $statuses['wc-awaiting-shipment'] = array(\n        'label'                     => 'Awaiting shipment',\n        'public'                    => false,\n        'exclude_from_search'       => false,\n        'show_in_admin_all_list'    => true,\n        'show_in_admin_status_list' => true,\n        /* translators: %s: number of orders */\n        'label_count'               => _n_noop( 'Awaiting shipment <span class=\"count\">(%s)</span>', 'Awaiting shipment <span class=\"count\">(%s)</span>' ),\n    );\n\n    return $statuses;\n} );\n\nadd_filter( 'wc_order_statuses', function ( $statuses ) {\n    $new = array();\n\n    foreach ( $statuses as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'wc-processing' === $key ) {\n            $new['wc-awaiting-shipment'] = 'Awaiting shipment';\n        }\n    }\n\n    return $new;\n} );\n\n$wpide_awaiting_bulk = function ( $actions ) {\n    $actions['mark_awaiting-shipment'] = 'Change status to awaiting shipment';\n\n    return $actions;\n};\n\nadd_filter( 'bulk_actions-woocommerce_page_wc-orders', $wpide_awaiting_bulk, 20 );\nadd_filter( 'bulk_actions-edit-shop_order', $wpide_awaiting_bulk, 20 );\n\nadd_filter( 'woocommerce_reports_order_statuses', function ( $statuses ) {\n    if ( is_array( $statuses ) ) {\n        $statuses[] = 'awaiting-shipment';\n    }\n\n    return $statuses;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change the slug and label to create a different status. Orders keep this status if you later remove the snippet, so move them to another status first."
        },
        {
            "id": "woo-returning-customer-badge",
            "version": 1,
            "title": "Returning Customer Info on the Order Screen",
            "description": "Shows how many previous orders the customer has placed and their total spend on the order edit screen, so you can recognize loyal buyers.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_admin_order_data_after_order_details', function ( $order ) {\n    $email = $order->get_billing_email();\n\n    if ( ! $email ) {\n        return;\n    }\n\n    $args = array(\n        'limit'   => -1,\n        'return'  => 'ids',\n        'type'    => 'shop_order',\n        'status'  => array( 'wc-completed', 'wc-processing', 'wc-on-hold' ),\n        'exclude' => array( $order->get_id() ),\n    );\n\n    if ( $order->get_customer_id() ) {\n        $args['customer_id'] = $order->get_customer_id();\n    } else {\n        $args['billing_email'] = $email;\n    }\n\n    $ids   = wc_get_orders( $args );\n    $spent = 0;\n\n    foreach ( $ids as $id ) {\n        $previous = wc_get_order( $id );\n        $spent   += $previous ? (float) $previous->get_total() : 0;\n    }\n\n    echo '<p class=\"form-field form-field-wide\" style=\"clear:both;padding-top:8px;\">';\n\n    if ( $ids ) {\n        printf( '<strong>Returning customer:</strong> %1$d previous orders, %2$s spent.', count( $ids ), wp_kses_post( wc_price( $spent ) ) );\n    } else {\n        echo '<strong>New customer:</strong> first order.';\n    }\n\n    echo '</p>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Guest orders are matched by billing email."
        },
        {
            "id": "woo-dispatch-cutoff-notice",
            "version": 1,
            "title": "\"Order Within X Hours for Same-Day Dispatch\" Notice",
            "description": "Shows a live notice on product pages telling shoppers how long they have left to order for dispatch today, or that orders ship on the next working day.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "shipping",
                "urgency"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_single_product_summary', function () {\n    $cutoff       = '14:00';                  // Daily dispatch cutoff.\n    $working_days = array( 1, 2, 3, 4, 5 );   // Days you ship.\n\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->is_in_stock() || $product->is_virtual() ) {\n        return;\n    }\n\n    $now      = new DateTime( 'now', wp_timezone() );\n    $deadline = new DateTime( 'today ' . $cutoff, wp_timezone() );\n\n    if ( in_array( (int) $now->format( 'N' ), $working_days, true ) && $now < $deadline ) {\n        $diff = $now->diff( $deadline );\n        $text = sprintf( 'Order within %1$dh %2$dm for dispatch today.', $diff->h, $diff->i );\n    } else {\n        $text = 'Order now for dispatch on the next working day.';\n    }\n\n    echo '<p class=\"wpide-dispatch-notice\" style=\"padding:8px 12px;border-radius:4px;background:#f0f6fc;\">&#128666; ' . esc_html( $text ) . '</p>';\n}, 25 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $cutoff (24h, site timezone) and $working_days (1 = Monday ... 7 = Sunday)."
        },
        {
            "id": "woo-trust-badges-after-cart-button",
            "version": 1,
            "title": "Payment and Trust Icons Under the Add to Cart Button",
            "description": "Adds a short secure-checkout line with accepted payment methods under the add to cart button on product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "trust",
                "payments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_add_to_cart_form', function () {\n    $methods = array( 'Visa', 'Mastercard', 'PayPal', 'Apple Pay' ); // Accepted methods.\n\n    echo '<div class=\"wpide-trust-line\" style=\"margin:12px 0 0;font-size:.9em;color:#50575e;\">';\n    echo '<span aria-hidden=\"true\">&#128274;</span> ' . esc_html( 'Secure checkout' ) . ' &middot; ';\n    echo esc_html( implode( ' / ', $methods ) );\n    echo '</div>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit $methods to list the payment methods you accept."
        },
        {
            "id": "woo-recently-viewed-shortcode",
            "version": 1,
            "title": "Recently Viewed Products Shortcode",
            "description": "Remembers the products each visitor looks at and adds a [wpide_recently_viewed] shortcode that displays them as a product grid.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shortcode",
                "products",
                "personalization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! function_exists( 'is_product' ) || ! is_product() || headers_sent() ) {\n        return;\n    }\n\n    $id     = (int) get_queried_object_id();\n    $viewed = isset( $_COOKIE['wpide_viewed'] ) ? wp_parse_id_list( wp_unslash( $_COOKIE['wpide_viewed'] ) ) : array();\n    $viewed = array_diff( $viewed, array( $id ) );\n    array_unshift( $viewed, $id );\n    $viewed = array_slice( $viewed, 0, 15 );\n\n    setcookie( 'wpide_viewed', implode( ',', $viewed ), array(\n        'expires'  => time() + 30 * DAY_IN_SECONDS,\n        'path'     => COOKIEPATH ? COOKIEPATH : '/',\n        'domain'   => COOKIE_DOMAIN,\n        'secure'   => is_ssl(),\n        'httponly' => true,\n        'samesite' => 'Lax',\n    ) );\n}, 20 );\n\nadd_shortcode( 'wpide_recently_viewed', function ( $atts ) {\n    $atts   = shortcode_atts( array( 'limit' => 4, 'columns' => 4 ), $atts, 'wpide_recently_viewed' );\n    $viewed = isset( $_COOKIE['wpide_viewed'] ) ? wp_parse_id_list( wp_unslash( $_COOKIE['wpide_viewed'] ) ) : array();\n\n    if ( is_product() ) {\n        $viewed = array_diff( $viewed, array( (int) get_queried_object_id() ) );\n    }\n\n    $viewed = array_slice( $viewed, 0, absint( $atts['limit'] ) );\n\n    if ( ! $viewed ) {\n        return '';\n    }\n\n    return do_shortcode( sprintf( '[products ids=\"%1$s\" orderby=\"post__in\" columns=\"%2$d\"]', esc_attr( implode( ',', $viewed ) ), absint( $atts['columns'] ) ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products are stored in a first-party cookie for 30 days. Use [wpide_recently_viewed limit=\"4\"] to change how many are shown."
        },
        {
            "id": "woo-units-sold-social-proof",
            "version": 1,
            "title": "\"Sold X Times\" Note on Product Pages",
            "description": "Shows how many times a product has been bought below its price, as social proof once it passes a minimum number of sales.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "sales",
                "social-proof"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_single_product_summary', function () {\n    $min_sales = 10; // Hide the note below this many sales.\n\n    global $product;\n\n    if ( ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    $sales = (int) $product->get_total_sales();\n\n    if ( $sales < $min_sales ) {\n        return;\n    }\n\n    echo '<p class=\"wpide-units-sold\" style=\"margin:.5em 0;font-size:.95em;\">' . esc_html( sprintf( 'Bought %s times', number_format_i18n( $sales ) ) ) . '</p>';\n}, 11 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $min_sales so new products do not show a low number."
        },
        {
            "id": "woo-attribute-in-loop",
            "version": 1,
            "title": "Show a Product Attribute in Shop Listings",
            "description": "Displays the values of an attribute, such as available colors or sizes, under each product title on shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "attributes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_shop_loop_item_title', function () {\n    $attribute = 'pa_color'; // Attribute taxonomy or local attribute name.\n    $label     = 'Colors';   // Label shown before the values.\n\n    global $product;\n\n    if ( ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    $values = $product->get_attribute( $attribute );\n\n    if ( '' === $values ) {\n        return;\n    }\n\n    echo '<p class=\"wpide-loop-attribute\" style=\"margin:.3em 0;font-size:.85em;color:#50575e;\">' . esc_html( $label . ': ' . $values ) . '</p>';\n}, 6 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $attribute to the attribute slug, for example pa_color for a global \"Color\" attribute."
        },
        {
            "id": "woo-variation-dropdown-text",
            "version": 1,
            "title": "Change the \"Choose an Option\" Text",
            "description": "Replaces the placeholder in variation dropdowns with a clearer prompt that names the attribute, such as \"Select a size\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_dropdown_variation_attribute_options_args', function ( $args ) {\n    if ( ! empty( $args['attribute'] ) ) {\n        $args['show_option_none'] = sprintf( 'Select a %s', strtolower( wc_attribute_label( $args['attribute'], $args['product'] ?? '' ) ) );\n    }\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-variation-ajax-threshold",
            "version": 1,
            "title": "Raise the Variation Limit for Instant Price Updates",
            "description": "Increases the number of variations WooCommerce loads with the page (30 by default), so large variable products update price and stock instantly and hide unavailable combinations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_ajax_variation_threshold', function () {\n    return 100; // Maximum variations loaded with the page.\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Very large values make product pages heavier. 100 to 150 suits most stores."
        },
        {
            "id": "woo-sale-end-date",
            "version": 1,
            "title": "Show When a Sale Ends",
            "description": "Displays \"Sale ends on 31 May\" under the price of products with a scheduled sale end date.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "urgency",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_single_product_summary', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->is_on_sale() ) {\n        return;\n    }\n\n    $end = $product->get_date_on_sale_to();\n\n    if ( ! $end ) {\n        return;\n    }\n\n    echo '<p class=\"wpide-sale-ends\" style=\"color:#b32d2e;font-weight:600;\">' . esc_html( sprintf( 'Sale ends on %s', wc_format_datetime( $end, get_option( 'date_format' ) ) ) ) . '</p>';\n}, 11 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only appears when the sale has an end date set in the product's price schedule."
        },
        {
            "id": "woo-currency-symbol",
            "version": 1,
            "title": "Change a Currency Symbol",
            "description": "Replaces the symbol shown for a currency, for example \"AU$\" instead of \"$\" so international shoppers know which dollar you charge in.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "currency",
                "prices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_currency_symbol', function ( $symbol, $currency ) {\n    $target     = 'AUD'; // ISO currency code to change.\n    $new_symbol = 'AU$'; // Symbol to show.\n\n    return $currency === $target ? $new_symbol : $symbol;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $currency to the ISO code and $symbol to what should be displayed."
        },
        {
            "id": "woo-add-custom-currency",
            "version": 1,
            "title": "Add a Custom Currency",
            "description": "Adds a currency that WooCommerce does not list, such as a local or loyalty currency, so you can select it in the general settings.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "currency",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_custom_currency = array(\n    'code'   => 'XYZ',            // Three-letter code.\n    'name'   => 'Example Credits', // Name shown in settings.\n    'symbol' => 'Cr',             // Symbol shown with prices.\n);\n\nadd_filter( 'woocommerce_currencies', function ( $currencies ) use ( $wpide_custom_currency ) {\n    $currencies[ $wpide_custom_currency['code'] ] = $wpide_custom_currency['name'];\n\n    return $currencies;\n} );\n\nadd_filter( 'woocommerce_currency_symbol', function ( $symbol, $currency ) use ( $wpide_custom_currency ) {\n    return $currency === $wpide_custom_currency['code'] ? $wpide_custom_currency['symbol'] : $symbol;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "After activating, choose the new currency in WooCommerce > Settings > General. Payment gateways may not support custom currencies."
        },
        {
            "id": "woo-ship-to-different-unchecked",
            "version": 1,
            "title": "Untick \"Ship to a Different Address\" by Default",
            "description": "Makes the \"Ship to a different address?\" box on the classic checkout start unticked, so most customers only fill in one address.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_ship_to_different_address_checked', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shipping still goes to the billing address unless the customer ticks the box."
        },
        {
            "id": "woo-default-checkout-country",
            "version": 1,
            "title": "Set the Default Checkout Country",
            "description": "Pre-selects a billing and shipping country on checkout for new customers, which saves most of your buyers a step.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "countries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_default_country = 'US'; // Two-letter country code.\n\nadd_filter( 'default_checkout_billing_country', function ( $country ) use ( $wpide_default_country ) {\n    return $country ? $country : $wpide_default_country;\n} );\n\nadd_filter( 'default_checkout_shipping_country', function ( $country ) use ( $wpide_default_country ) {\n    return $country ? $country : $wpide_default_country;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use a two-letter country code. Returning customers keep the country saved in their account."
        },
        {
            "id": "woo-schema-brand",
            "version": 1,
            "title": "Add the Brand to Product Structured Data",
            "description": "Includes the product brand in the product schema WooCommerce outputs, which Google uses for rich results and Merchant listings.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "seo",
                "schema",
                "brands"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_structured_data_product', function ( $markup, $product ) {\n    $default_brand = get_bloginfo( 'name' ); // Used when the product has no brand.\n\n    if ( ! empty( $markup['brand'] ) ) {\n        return $markup;\n    }\n\n    $brand = '';\n\n    if ( taxonomy_exists( 'product_brand' ) ) {\n        $terms = get_the_terms( $product->get_id(), 'product_brand' );\n\n        if ( $terms && ! is_wp_error( $terms ) ) {\n            $brand = $terms[0]->name;\n        }\n    }\n\n    $brand = $brand ? $brand : $default_brand;\n\n    if ( $brand ) {\n        $markup['brand'] = array(\n            '@type' => 'Brand',\n            'name'  => wp_strip_all_tags( $brand ),\n        );\n    }\n\n    return $markup;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the WooCommerce Brands taxonomy when it exists, otherwise falls back to $default_brand."
        },
        {
            "id": "woo-remove-sale-badge",
            "version": 1,
            "title": "Remove the \"Sale!\" Badge",
            "description": "Hides the sale badge on product images in the shop and on product pages, while the crossed-out price still shows the discount.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "badge"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_sale_flash', '__return_empty_string' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "\"\""
        },
        {
            "id": "woo-hide-shop-page-title",
            "version": 1,
            "title": "Hide the Shop Page Title",
            "description": "Removes the large \"Shop\" heading at the top of the main shop and category pages for a cleaner layout.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "title"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_show_page_title', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Some block themes print the title from the template; edit the Product Catalog template in that case."
        },
        {
            "id": "woo-remove-result-count-ordering",
            "version": 1,
            "title": "Remove the Result Count and Sorting Dropdown",
            "description": "Hides \"Showing 1-12 of 40 results\" and the \"Default sorting\" menu above the products on shop and category pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop', function () {\n    $remove_count   = true; // Hide the result count.\n    $remove_sorting = true; // Hide the sorting dropdown.\n\n    if ( $remove_count ) {\n        remove_action( 'woocommerce_before_shop_loop', 'woocommerce_result_count', 20 );\n    }\n\n    if ( $remove_sorting ) {\n        remove_action( 'woocommerce_before_shop_loop', 'woocommerce_catalog_ordering', 30 );\n    }\n}, 1 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set either value to false to keep that element."
        },
        {
            "id": "woo-search-by-sku",
            "version": 1,
            "title": "Find Products by SKU in the Site Search",
            "description": "Lets shoppers type a SKU into the regular search box and find the matching product, including SKUs of variations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "sku"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_search', function ( $search, $query ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || '' === $search ) {\n        return $search;\n    }\n\n    $term = trim( (string) $query->get( 's' ) );\n\n    if ( '' === $term || strlen( $term ) > 100 ) {\n        return $search;\n    }\n\n    $id = wc_get_product_id_by_sku( $term );\n\n    if ( ! $id ) {\n        return $search;\n    }\n\n    $product = wc_get_product( $id );\n    $id      = $product && $product->get_parent_id() ? $product->get_parent_id() : $id;\n\n    // Wrap the original search clause and add the SKU match to it.\n    $inner = preg_replace( '/^\\s*AND\\s*/', '', $search, 1, $replaced );\n\n    if ( ! $replaced ) {\n        return $search;\n    }\n\n    return ' AND ( ( ' . $inner . ' ) ' . $wpdb->prepare( \"OR {$wpdb->posts}.ID = %d\", $id ) . ' ) ';\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Matches exact SKUs. Variation matches return the parent product."
        },
        {
            "id": "woo-hide-helper-notices",
            "version": 1,
            "title": "Hide WooCommerce.com Connection Notices",
            "description": "Silences the admin notices asking you to connect your store to WooCommerce.com or renew extension subscriptions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_helper_suppress_admin_notices', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "You will no longer be reminded about expiring extension subscriptions in the admin."
        },
        {
            "id": "woo-checkout-delivery-estimate",
            "version": 1,
            "title": "Estimated Delivery Dates Above Checkout",
            "description": "Shows \"Estimated delivery: Tue 4 - Thu 6 Nov\" at the top of the checkout page, counting only working days from today.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "delivery",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "woocommerce_before_checkout_form",
            "insert_number": 1,
            "code": "$min_days     = 2;                      // Fastest delivery, in working days.\n$max_days     = 4;                      // Slowest delivery, in working days.\n$working_days = array( 1, 2, 3, 4, 5 ); // 1 = Monday ... 7 = Sunday.\n\n$add_working_days = function ( $days ) use ( $working_days ) {\n    $date = new DateTime( 'now', wp_timezone() );\n\n    while ( $days > 0 ) {\n        $date->modify( '+1 day' );\n\n        if ( in_array( (int) $date->format( 'N' ), $working_days, true ) ) {\n            $days--;\n        }\n    }\n\n    return $date;\n};\n\n$from = $add_working_days( $min_days );\n$to   = $add_working_days( $max_days );\n\nprintf(\n    '<p class=\"wpide-delivery-estimate\" style=\"margin:0 0 20px;padding:12px 16px;border-radius:6px;background:#f6f7f7;\">&#128230; Estimated delivery: <strong>%1$s &ndash; %2$s</strong></p>',\n    esc_html( wp_date( 'D j M', $from->getTimestamp() ) ),\n    esc_html( wp_date( 'D j M', $to->getTimestamp() ) )\n);",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $min_days and $max_days to your usual delivery window in working days and adjust $working_days."
        },
        {
            "id": "woo-cart-savings-summary",
            "version": 1,
            "title": "\"You're Saving X\" Summary Above the Cart",
            "description": "Adds up the discounts from sale prices and coupons in the cart and shows the total saving at the top of the cart page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "sale",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "woocommerce_before_cart",
            "insert_number": 1,
            "code": "if ( WC()->cart && ! WC()->cart->is_empty() ) {\n    $saving = 0;\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $product = $item['data'];\n\n        if ( $product->is_on_sale() ) {\n            $regular = (float) wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price() ) );\n            $current = (float) wc_get_price_to_display( $product );\n            $saving += max( 0, $regular - $current ) * (int) $item['quantity'];\n        }\n    }\n\n    $saving += (float) WC()->cart->get_discount_total() + ( WC()->cart->display_prices_including_tax() ? (float) WC()->cart->get_discount_tax() : 0 );\n\n    if ( $saving > 0 ) {\n        echo '<div class=\"wpide-cart-savings\" style=\"margin:0 0 20px;padding:12px 16px;border-radius:6px;background:#edfaef;color:#1e4620;font-weight:600;\">' . wp_kses_post( sprintf( 'You are saving %s on this order!', wc_price( $saving ) ) ) . '</div>';\n    }\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only appears when the shopper saves something. Sale savings use the regular price of each product."
        },
        {
            "id": "woo-cart-top-categories",
            "version": 1,
            "title": "\"Keep Shopping\" Category Links Below the Cart",
            "description": "Lists your most popular product categories as buttons below the cart, giving shoppers an easy way to add more before checking out.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "categories",
                "upsell"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "woocommerce_after_cart",
            "insert_number": 1,
            "code": "$count = 5; // Number of categories to show.\n\n$terms = get_terms( array(\n    'taxonomy'   => 'product_cat',\n    'hide_empty' => true,\n    'orderby'    => 'count',\n    'order'      => 'DESC',\n    'number'     => $count,\n    'exclude'    => array( (int) get_option( 'default_product_cat' ) ),\n) );\n\nif ( $terms && ! is_wp_error( $terms ) ) {\n    echo '<div class=\"wpide-keep-shopping\" style=\"margin-top:32px;\"><p style=\"font-weight:600;\">Keep shopping</p><p>';\n\n    foreach ( $terms as $term ) {\n        printf( '<a class=\"button\" style=\"margin:0 8px 8px 0;\" href=\"%1$s\">%2$s</a>', esc_url( get_term_link( $term ) ), esc_html( $term->name ) );\n    }\n\n    echo '</p></div>';\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $count to show more or fewer categories. Categories are ordered by number of products."
        },
        {
            "id": "woo-product-faq",
            "version": 1,
            "title": "FAQ Accordion After Product Pages",
            "description": "Adds a short list of common questions and answers (shipping, returns, warranty) at the bottom of every product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "faq"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "wc_after_single_product",
            "insert_number": 1,
            "code": "<section class=\"wpide-product-faq\" style=\"clear:both;margin:40px 0;\">\n    <h2>Frequently asked questions</h2>\n    <details style=\"margin-bottom:10px;padding:12px 16px;border:1px solid #dcdcde;border-radius:6px;\">\n        <summary style=\"cursor:pointer;font-weight:600;\">When will my order ship?</summary>\n        <p style=\"margin:10px 0 0;\">Orders placed before 2 pm on a working day ship the same day.</p>\n    </details>\n    <details style=\"margin-bottom:10px;padding:12px 16px;border:1px solid #dcdcde;border-radius:6px;\">\n        <summary style=\"cursor:pointer;font-weight:600;\">Can I return this product?</summary>\n        <p style=\"margin:10px 0 0;\">Yes, you can return unused items within 30 days for a full refund.</p>\n    </details>\n    <details style=\"margin-bottom:10px;padding:12px 16px;border:1px solid #dcdcde;border-radius:6px;\">\n        <summary style=\"cursor:pointer;font-weight:600;\">Is there a warranty?</summary>\n        <p style=\"margin:10px 0 0;\">All products come with a two-year warranty against manufacturing defects.</p>\n    </details>\n</section>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the questions and answers. Use WPIDE conditions to show different FAQs per category."
        },
        {
            "id": "woo-thankyou-next-steps",
            "version": 1,
            "title": "\"What Happens Next\" Steps on the Thank-You Page",
            "description": "Shows three simple steps at the top of the order received page explaining what happens after the order, which reduces \"where is my order?\" emails.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "thank-you",
                "customer-experience"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_before_thankyou",
            "insert_number": 1,
            "code": "<ol class=\"wpide-next-steps\" style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin:0 0 28px;padding:0;list-style:none;counter-reset:step;\">\n    <li style=\"padding:16px;border-radius:8px;background:#f6f7f7;\"><strong>1. Confirmation</strong><br>You will receive an order confirmation email in a few minutes.</li>\n    <li style=\"padding:16px;border-radius:8px;background:#f6f7f7;\"><strong>2. Packing</strong><br>We pack your order within one working day.</li>\n    <li style=\"padding:16px;border-radius:8px;background:#f6f7f7;\"><strong>3. Shipping</strong><br>You get a tracking link by email as soon as it ships.</li>\n</ol>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the steps to match your fulfillment process."
        },
        {
            "id": "woo-category-banner-image",
            "version": 1,
            "title": "Category Image Banner Above Products",
            "description": "Displays the image you set on a product category as a wide banner at the top of that category's page, which many themes do not show.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "categories",
                "images",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "wc_before_products_list",
            "insert_number": 1,
            "code": "if ( is_product_category() ) {\n    $term     = get_queried_object();\n    $image_id = (int) get_term_meta( $term->term_id, 'thumbnail_id', true );\n\n    if ( $image_id ) {\n        echo '<div class=\"wpide-category-banner\" style=\"margin:0 0 24px;border-radius:8px;overflow:hidden;\">';\n        echo wp_get_attachment_image( $image_id, 'full', false, array(\n            'style'   => 'display:block;width:100%;height:auto;max-height:320px;object-fit:cover;',\n            'alt'     => $term->name,\n            'loading' => 'eager',\n        ) );\n        echo '</div>';\n    }\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set the image in Products > Categories > Edit category > Thumbnail. Categories without an image show nothing."
        },
        {
            "id": "woo-back-to-category-link",
            "version": 1,
            "title": "\"Back to Category\" Link Above Products",
            "description": "Adds a \"Back to Shoes\" style link above each product page that returns the shopper to the product's main category.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "wc_before_single_product",
            "insert_number": 1,
            "code": "$product_id = get_the_ID();\n$term_id    = 0;\n\nforeach ( array( '_yoast_wpseo_primary_product_cat', 'rank_math_primary_product_cat' ) as $meta_key ) {\n    $term_id = $term_id ? $term_id : (int) get_post_meta( $product_id, $meta_key, true );\n}\n\n$term = $term_id ? get_term( $term_id, 'product_cat' ) : null;\n\nif ( ! $term || is_wp_error( $term ) ) {\n    $terms = get_the_terms( $product_id, 'product_cat' );\n    $term  = $terms && ! is_wp_error( $terms ) ? $terms[0] : null;\n}\n\nif ( $term ) {\n    printf( '<p class=\"wpide-back-to-cat\" style=\"margin:0 0 12px;\"><a href=\"%1$s\">&larr; Back to %2$s</a></p>', esc_url( get_term_link( $term ) ), esc_html( $term->name ) );\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the product's first category, or the primary category set by Yoast SEO or Rank Math when available."
        },
        {
            "id": "woo-checkout-guarantee-footer",
            "version": 1,
            "title": "Guarantee and Secure Payment Note Under Checkout",
            "description": "Shows a reassuring note with your guarantee, secure payment and support details below the checkout form, where customers decide to place the order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "trust"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_after_checkout_form",
            "insert_number": 1,
            "code": "<div class=\"wpide-checkout-trust\" style=\"display:flex;flex-wrap:wrap;gap:12px 32px;justify-content:center;margin:28px 0 0;padding:16px;border-top:1px solid #dcdcde;font-size:.95em;color:#50575e;\">\n    <span>&#128274; Payments are encrypted and secure</span>\n    <span>&#8617; 30-day money-back guarantee</span>\n    <span>&#9993; Questions? hello@example.com</span>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the three items to match your policies."
        },
        {
            "id": "woo-product-grid-scss",
            "version": 1,
            "title": "Modern Product Grid Cards (SCSS)",
            "description": "Restyles the WooCommerce shop grid into clean cards with rounded images, consistent spacing, aligned buttons and a hover effect.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shop",
                "scss",
                "design"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$card-bg: #ffffff;\n$card-radius: 10px;\n$card-shadow: 0 1px 6px rgba(0, 0, 0, .08);\n$accent: #2271b1;\n\n.woocommerce ul.products li.product {\n    display: flex;\n    flex-direction: column;\n    padding: 12px 12px 16px;\n    border-radius: $card-radius;\n    background: $card-bg;\n    box-shadow: $card-shadow;\n    text-align: center;\n    transition: box-shadow .2s ease, transform .2s ease;\n\n    a img {\n        margin-bottom: 12px;\n        border-radius: $card-radius - 4px;\n    }\n\n    .woocommerce-loop-product__title {\n        font-size: 1rem;\n        line-height: 1.35;\n    }\n\n    .price {\n        color: $accent;\n        font-weight: 600;\n    }\n\n    .button {\n        margin-top: auto;\n    }\n\n    &:hover {\n        box-shadow: 0 6px 18px rgba(0, 0, 0, .12);\n        transform: translateY(-2px);\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        &:hover {\n            transform: none;\n        }\n    }\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change the variables at the top to match your brand. Works with the classic shop loop markup."
        },
        {
            "id": "markdown-versions-for-ai-crawlers",
            "version": 1,
            "title": "Markdown Versions of Posts for AI Crawlers, Plus llms.txt",
            "description": "Serves any post or page as clean Markdown when .md is added to its address (or ?format=markdown), and publishes an /llms.txt index of your pages and recent posts for AI tools.",
            "category": "seo",
            "tags": [
                "seo",
                "ai",
                "markdown",
                "llms-txt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that get a Markdown version.\n$wpide_md_post_types = array( 'post', 'page' );\n\n// How many recent posts and top-level pages /llms.txt lists (0 leaves the section out).\n$wpide_llms_posts = 30;\n$wpide_llms_pages = 20;\n\n// Keep the Markdown copies out of search results. The normal page stays the original.\n$wpide_md_noindex = true;\n\n$wpide_md_plain = function ( $text ) {\n    $text = html_entity_decode( wp_strip_all_tags( (string) $text ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );\n\n    return trim( (string) preg_replace( '/\\s+/u', ' ', $text ) );\n};\n\n$wpide_md_target = function ( $url ) {\n    $url = trim( (string) $url );\n\n    if ( '' !== $url && ! preg_match( '#^[a-z][a-z0-9+.-]*:#i', $url ) ) {\n        $url = WP_Http::make_absolute_url( $url, home_url( '/' ) );\n    }\n\n    return str_replace( array( ' ', '(', ')' ), array( '%20', '%28', '%29' ), $url );\n};\n\n// Markdown address of a post: /post-name.md, or ?format=markdown for plain permalinks and the front page.\n$wpide_md_url = function ( $post ) {\n    $permalink = get_permalink( $post );\n\n    if ( ! $permalink ) {\n        return '';\n    }\n\n    if ( false !== strpos( $permalink, '?' ) || untrailingslashit( $permalink ) === untrailingslashit( home_url() ) ) {\n        return add_query_arg( 'format', 'markdown', $permalink );\n    }\n\n    return untrailingslashit( $permalink ) . '.md';\n};\n\n// Small HTML to Markdown converter: headings, paragraphs, emphasis, links, images, lists, quotes, code and tables.\n$wpide_md_convert = function ( DOMNode $parent ) use ( &$wpide_md_convert, $wpide_md_plain, $wpide_md_target ) {\n    $out = '';\n\n    $inline = function ( $raw, $marker ) {\n        $inner = trim( $raw );\n\n        if ( '' === $inner ) {\n            return '' === $raw ? '' : ' ';\n        }\n\n        return ( preg_match( '/^\\s/u', $raw ) ? ' ' : '' ) . $marker . $inner . $marker . ( preg_match( '/\\s$/u', $raw ) ? ' ' : '' );\n    };\n\n    foreach ( $parent->childNodes as $node ) {\n        if ( XML_TEXT_NODE === $node->nodeType || XML_CDATA_SECTION_NODE === $node->nodeType ) {\n            $out .= (string) preg_replace( '/\\s+/u', ' ', $node->nodeValue );\n            continue;\n        }\n\n        if ( XML_ELEMENT_NODE !== $node->nodeType ) {\n            continue;\n        }\n\n        $tag = strtolower( $node->nodeName );\n\n        switch ( $tag ) {\n            case 'script':\n            case 'style':\n            case 'noscript':\n            case 'template':\n            case 'svg':\n            case 'form':\n            case 'button':\n            case 'input':\n            case 'select':\n            case 'textarea':\n                break;\n\n            case 'h1':\n            case 'h2':\n            case 'h3':\n            case 'h4':\n            case 'h5':\n            case 'h6':\n                $text = trim( (string) preg_replace( '/\\s+/u', ' ', $wpide_md_convert( $node ) ) );\n                if ( '' !== $text ) {\n                    $out .= \"\\n\\n\" . str_repeat( '#', (int) substr( $tag, 1 ) ) . ' ' . $text . \"\\n\\n\";\n                }\n                break;\n\n            case 'p':\n                $out .= \"\\n\\n\" . trim( $wpide_md_convert( $node ) ) . \"\\n\\n\";\n                break;\n\n            case 'br':\n                $out .= \"  \\n\";\n                break;\n\n            case 'hr':\n                $out .= \"\\n\\n---\\n\\n\";\n                break;\n\n            case 'strong':\n            case 'b':\n                $out .= $inline( $wpide_md_convert( $node ), '**' );\n                break;\n\n            case 'em':\n            case 'i':\n                $out .= $inline( $wpide_md_convert( $node ), '*' );\n                break;\n\n            case 'del':\n            case 's':\n            case 'strike':\n                $out .= $inline( $wpide_md_convert( $node ), '~~' );\n                break;\n\n            case 'code':\n                $code = $node->textContent;\n                if ( '' !== trim( $code ) ) {\n                    $out .= false !== strpos( $code, '`' ) ? '`` ' . $code . ' ``' : '`' . $code . '`';\n                }\n                break;\n\n            case 'pre':\n                $language = '';\n                $classes  = $node->getAttribute( 'class' );\n                foreach ( $node->getElementsByTagName( 'code' ) as $code_node ) {\n                    $classes .= ' ' . $code_node->getAttribute( 'class' );\n                    break;\n                }\n                if ( preg_match( '/(?:language|lang)-([\\w+#.-]+)/', $classes, $match ) ) {\n                    $language = $match[1];\n                }\n                $code  = rtrim( str_replace( \"\\r\\n\", \"\\n\", $node->textContent ) );\n                $fence = false !== strpos( $code, '```' ) ? '~~~' : '```';\n                $out  .= \"\\n\\n\" . $fence . $language . \"\\n\" . $code . \"\\n\" . $fence . \"\\n\\n\";\n                break;\n\n            case 'blockquote':\n                $quote = trim( (string) preg_replace( \"/\\n{3,}/\", \"\\n\\n\", $wpide_md_convert( $node ) ) );\n                if ( '' !== $quote ) {\n                    $out .= \"\\n\\n\" . preg_replace( '/^/m', '> ', $quote ) . \"\\n\\n\";\n                }\n                break;\n\n            case 'ul':\n            case 'ol':\n                $number = 'ol' === $tag ? max( 1, (int) $node->getAttribute( 'start' ) ) : 0;\n                $items  = array();\n                foreach ( $node->childNodes as $item ) {\n                    if ( XML_ELEMENT_NODE !== $item->nodeType || 'li' !== strtolower( $item->nodeName ) ) {\n                        continue;\n                    }\n                    $text = trim( (string) preg_replace( \"/\\n\\s*\\n/\", \"\\n\", $wpide_md_convert( $item ) ) );\n                    if ( '' === $text ) {\n                        continue;\n                    }\n                    $marker  = $number ? ( $number++ ) . '. ' : '- ';\n                    $items[] = $marker . str_replace( \"\\n\", \"\\n\" . str_repeat( ' ', strlen( $marker ) ), $text );\n                }\n                if ( $items ) {\n                    $out .= \"\\n\\n\" . implode( \"\\n\", $items ) . \"\\n\\n\";\n                }\n                break;\n\n            case 'li':\n                $out .= \"\\n- \" . trim( $wpide_md_convert( $node ) ) . \"\\n\";\n                break;\n\n            case 'a':\n                $href  = trim( $node->getAttribute( 'href' ) );\n                $raw   = $wpide_md_convert( $node );\n                $label = trim( (string) preg_replace( '/\\s+/u', ' ', $raw ) );\n                if ( '' === $label ) {\n                    break;\n                }\n                if ( '' === $href || '#' === $href[0] || 0 === stripos( $href, 'javascript:' ) ) {\n                    $out .= $raw;\n                    break;\n                }\n                $out .= ( preg_match( '/^\\s/u', $raw ) ? ' ' : '' ) . '[' . $label . '](' . $wpide_md_target( $href ) . ')' . ( preg_match( '/\\s$/u', $raw ) ? ' ' : '' );\n                break;\n\n            case 'img':\n                $src = $node->getAttribute( 'src' );\n                foreach ( array( 'data-src', 'data-lazy-src', 'data-original' ) as $attribute ) {\n                    if ( ( '' === $src || 0 === strpos( $src, 'data:' ) ) && $node->getAttribute( $attribute ) ) {\n                        $src = $node->getAttribute( $attribute );\n                    }\n                }\n                if ( '' !== $src && 0 !== strpos( $src, 'data:' ) ) {\n                    $alt  = str_replace( array( '[', ']' ), '', $wpide_md_plain( $node->getAttribute( 'alt' ) ) );\n                    $out .= '![' . $alt . '](' . $wpide_md_target( $src ) . ')';\n                }\n                break;\n\n            case 'iframe':\n            case 'video':\n            case 'audio':\n            case 'embed':\n            case 'object':\n                $src = $node->getAttribute( 'src' ) ? $node->getAttribute( 'src' ) : $node->getAttribute( 'data' );\n                if ( ! $src ) {\n                    foreach ( $node->getElementsByTagName( 'source' ) as $source ) {\n                        $src = $source->getAttribute( 'src' );\n                        if ( $src ) {\n                            break;\n                        }\n                    }\n                }\n                if ( $src && 0 !== strpos( $src, 'data:' ) ) {\n                    $title = $wpide_md_plain( $node->getAttribute( 'title' ) );\n                    $title = '' !== $title ? $title : ( 'iframe' === $tag ? 'Embedded content' : ucfirst( $tag ) );\n                    $out  .= \"\\n\\n[\" . $title . '](' . $wpide_md_target( $src ) . \")\\n\\n\";\n                }\n                break;\n\n            case 'figcaption':\n                $caption = trim( (string) preg_replace( '/\\s+/u', ' ', $wpide_md_convert( $node ) ) );\n                if ( '' !== $caption ) {\n                    $out .= \"\\n\\n*\" . $caption . \"*\\n\\n\";\n                }\n                break;\n\n            case 'summary':\n            case 'dt':\n                $term = trim( (string) preg_replace( '/\\s+/u', ' ', $wpide_md_convert( $node ) ) );\n                if ( '' !== $term ) {\n                    $out .= \"\\n\\n**\" . $term . \"**\\n\\n\";\n                }\n                break;\n\n            case 'table':\n                $rows = array();\n                foreach ( $node->getElementsByTagName( 'tr' ) as $row ) {\n                    $cells = array();\n                    foreach ( $row->childNodes as $cell ) {\n                        if ( XML_ELEMENT_NODE === $cell->nodeType && in_array( strtolower( $cell->nodeName ), array( 'td', 'th' ), true ) ) {\n                            $cells[] = str_replace( '|', '\\|', trim( (string) preg_replace( '/\\s+/u', ' ', $wpide_md_convert( $cell ) ) ) );\n                        }\n                    }\n                    if ( $cells ) {\n                        $rows[] = $cells;\n                    }\n                }\n                if ( $rows ) {\n                    $columns = max( array_map( 'count', $rows ) );\n                    $lines   = array();\n                    foreach ( $rows as $index => $cells ) {\n                        $lines[] = '| ' . implode( ' | ', array_pad( $cells, $columns, '' ) ) . ' |';\n                        if ( 0 === $index ) {\n                            $lines[] = '|' . str_repeat( ' --- |', $columns );\n                        }\n                    }\n                    $out .= \"\\n\\n\" . implode( \"\\n\", $lines ) . \"\\n\\n\";\n                }\n                break;\n\n            case 'div':\n            case 'section':\n            case 'article':\n            case 'aside':\n            case 'header':\n            case 'footer':\n            case 'main':\n            case 'nav':\n            case 'figure':\n            case 'details':\n            case 'dl':\n            case 'dd':\n            case 'address':\n                $out .= \"\\n\\n\" . $wpide_md_convert( $node ) . \"\\n\\n\";\n                break;\n\n            default:\n                $out .= $wpide_md_convert( $node );\n        }\n    }\n\n    return $out;\n};\n\n$wpide_md_from_html = function ( $html ) use ( $wpide_md_convert ) {\n    if ( ! class_exists( 'DOMDocument' ) ) {\n        return trim( wp_strip_all_tags( $html ) );\n    }\n\n    $document = new DOMDocument();\n    $previous = libxml_use_internal_errors( true );\n    $document->loadHTML( '<?xml encoding=\"utf-8\"?><div>' . $html . '</div>', LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD );\n    libxml_clear_errors();\n    libxml_use_internal_errors( $previous );\n\n    $root = $document->getElementsByTagName( 'div' )->item( 0 );\n\n    if ( ! $root ) {\n        return '';\n    }\n\n    $markdown = preg_replace( '/^[ \\t]+$/m', '', $wpide_md_convert( $root ) );\n\n    return trim( (string) preg_replace( \"/\\n{3,}/\", \"\\n\\n\", (string) $markdown ) );\n};\n\n$wpide_md_send = function ( $post ) use ( $wpide_md_post_types, $wpide_md_noindex, $wpide_md_plain, $wpide_md_from_html ) {\n    if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, $wpide_md_post_types, true ) || ! is_post_publicly_viewable( $post ) || post_password_required( $post ) ) {\n        return;\n    }\n\n    $GLOBALS['post'] = $post;\n    setup_postdata( $post );\n    $html = apply_filters( 'the_content', $post->post_content );\n    wp_reset_postdata();\n\n    $json  = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;\n    $title = $wpide_md_plain( get_the_title( $post ) );\n    $meta  = array(\n        '---',\n        'title: ' . wp_json_encode( $title, $json ),\n        'url: ' . get_permalink( $post ),\n        'date: ' . get_the_date( 'Y-m-d', $post ),\n        'modified: ' . get_the_modified_date( 'Y-m-d', $post ),\n    );\n\n    if ( post_type_supports( $post->post_type, 'author' ) ) {\n        $meta[] = 'author: ' . wp_json_encode( $wpide_md_plain( get_the_author_meta( 'display_name', $post->post_author ) ), $json );\n    }\n\n    if ( has_excerpt( $post ) ) {\n        $meta[] = 'description: ' . wp_json_encode( $wpide_md_plain( $post->post_excerpt ), $json );\n    }\n\n    $meta[] = '---';\n\n    status_header( 200 );\n    header( 'Content-Type: text/markdown; charset=utf-8' );\n    header( 'X-Content-Type-Options: nosniff' );\n    header( 'Link: <' . esc_url_raw( get_permalink( $post ) ) . '>; rel=\"canonical\"' );\n\n    if ( $wpide_md_noindex ) {\n        header( 'X-Robots-Tag: noindex' );\n    }\n\n    echo implode( \"\\n\", $meta ) . \"\\n\\n# \" . $title . \"\\n\\n\" . $wpide_md_from_html( $html ) . \"\\n\"; // phpcs:ignore WordPress.Security.EscapeOutput -- Markdown, not HTML.\n    exit;\n};\n\n$wpide_llms_send = function () use ( $wpide_md_post_types, $wpide_llms_posts, $wpide_llms_pages, $wpide_md_plain, $wpide_md_url ) {\n    $item = function ( $post ) use ( $wpide_md_plain, $wpide_md_url ) {\n        $title       = str_replace( array( '[', ']' ), array( '(', ')' ), $wpide_md_plain( get_the_title( $post ) ) );\n        $description = $wpide_md_plain( has_excerpt( $post ) ? $post->post_excerpt : wp_trim_words( strip_shortcodes( $post->post_content ), 25, '...' ) );\n\n        return '- [' . $title . '](' . $wpide_md_url( $post ) . ')' . ( '' !== $description ? ': ' . $description : '' );\n    };\n\n    $out     = '# ' . $wpide_md_plain( get_bloginfo( 'name' ) ) . \"\\n\\n\";\n    $tagline = $wpide_md_plain( get_bloginfo( 'description' ) );\n\n    if ( '' !== $tagline ) {\n        $out .= '> ' . $tagline . \"\\n\\n\";\n    }\n\n    $out .= \"The links below point to Markdown versions of each page.\\n\";\n\n    if ( $wpide_llms_pages > 0 && in_array( 'page', $wpide_md_post_types, true ) ) {\n        $pages = get_posts( array(\n            'post_type'    => 'page',\n            'post_parent'  => 0,\n            'numberposts'  => (int) $wpide_llms_pages,\n            'orderby'      => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n            'has_password' => false,\n        ) );\n\n        if ( $pages ) {\n            $out .= \"\\n## Pages\\n\\n\" . implode( \"\\n\", array_map( $item, $pages ) ) . \"\\n\";\n        }\n    }\n\n    $other_types = array_values( array_diff( $wpide_md_post_types, array( 'page' ) ) );\n\n    if ( $wpide_llms_posts > 0 && $other_types ) {\n        $posts = get_posts( array(\n            'post_type'    => $other_types,\n            'numberposts'  => (int) $wpide_llms_posts,\n            'has_password' => false,\n        ) );\n\n        if ( $posts ) {\n            $out .= \"\\n## Recent posts\\n\\n\" . implode( \"\\n\", array_map( $item, $posts ) ) . \"\\n\";\n        }\n    }\n\n    status_header( 200 );\n    header( 'Content-Type: text/plain; charset=utf-8' );\n    header( 'X-Content-Type-Options: nosniff' );\n\n    echo $out; // phpcs:ignore WordPress.Security.EscapeOutput -- Plain text.\n    exit;\n};\n\n// /llms.txt and /any-post-address.md are answered before WordPress looks for a matching page.\nadd_action( 'parse_request', function ( $wp ) use ( $wpide_md_send, $wpide_llms_send ) {\n    $request = isset( $wp->request ) ? (string) $wp->request : '';\n\n    if ( 'llms.txt' === $request ) {\n        $wpide_llms_send();\n    }\n\n    if ( strlen( $request ) > 3 && '.md' === substr( $request, -3 ) ) {\n        $path    = substr( $request, 0, -3 );\n        $post_id = url_to_postid( home_url( user_trailingslashit( $path ) ) );\n\n        if ( ! $post_id ) {\n            $post_id = url_to_postid( home_url( '/' . $path ) );\n        }\n\n        if ( $post_id ) {\n            $wpide_md_send( get_post( $post_id ) );\n        }\n    }\n} );\n\n// Any post address with ?format=markdown.\nadd_action( 'template_redirect', function () use ( $wpide_md_post_types, $wpide_md_send ) {\n    if ( isset( $_GET['format'] ) && 'markdown' === sanitize_key( wp_unslash( $_GET['format'] ) ) && is_singular( $wpide_md_post_types ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $wpide_md_send( get_queried_object() );\n    }\n}, 1 );\n\n// Point crawlers to the Markdown version from the normal page.\nadd_action( 'wp_head', function () use ( $wpide_md_post_types, $wpide_md_url ) {\n    $post = get_queried_object();\n\n    if ( ! is_singular( $wpide_md_post_types ) || ! $post instanceof WP_Post || ! is_post_publicly_viewable( $post ) || post_password_required( $post ) ) {\n        return;\n    }\n\n    $url = $wpide_md_url( $post );\n\n    if ( $url ) {\n        echo '<link rel=\"alternate\" type=\"text/markdown\" href=\"' . esc_url( $url ) . '\">' . \"\\n\";\n    }\n} );",
            "requires": [],
            "notes": "Try it: open a post and add .md to the end of its address (example.com/my-post.md). With plain permalinks, and for the front page, use ?format=markdown instead. Each page also gets a <link rel=\"alternate\" type=\"text/markdown\"> tag. If a real llms.txt file exists in the site root, the server shows that file instead. If a caching plugin serves the .md addresses as HTML, exclude them from the cache. Uses PHP's DOM extension (standard on almost every host); without it the text is sent unformatted."
        },
        {
            "id": "skip-translations-on-english-sites",
            "version": 1,
            "title": "Skip Loading Translations on English Sites",
            "description": "Stops WordPress, plugins and themes from loading translation files on the front end (or everywhere) to save memory and time on sites whose visitors read English anyway, with a list of text domains to keep.",
            "category": "performance",
            "tags": [
                "performance",
                "translations",
                "memory",
                "i18n"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// 'frontend' skips translations on public pages only. 'everywhere' also skips them in wp-admin.\n$wpide_skip_translations_on = 'frontend';\n\n// Text domains that still load their translations, for example 'default' (WordPress itself) or 'woocommerce'.\n$wpide_keep_text_domains = array();\n\n$wpide_skip_translation = function ( $domain ) use ( $wpide_skip_translations_on, $wpide_keep_text_domains ) {\n    if ( in_array( $domain, $wpide_keep_text_domains, true ) ) {\n        return false;\n    }\n\n    if ( 'everywhere' === $wpide_skip_translations_on ) {\n        return true;\n    }\n\n    if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {\n        return false;\n    }\n\n    if ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {\n        return false;\n    }\n\n    // REST requests are not flagged yet when the first translations load, so check the address too.\n    $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput\n\n    return ! isset( $_GET['rest_route'] ) && false === strpos( $uri, '/' . rest_get_url_prefix() . '/' ); // phpcs:ignore WordPress.Security.NonceVerification\n};\n\n// PHP translation files (.mo and .l10n.php).\nadd_filter( 'override_load_textdomain', function ( $override, $domain ) use ( $wpide_skip_translation ) {\n    return $override || $wpide_skip_translation( $domain );\n}, 10, 2 );\n\n// JavaScript translation files (.json).\nadd_filter( 'pre_load_script_translations', function ( $translations, $file, $handle, $domain ) use ( $wpide_skip_translation ) {\n    return ( null === $translations && $wpide_skip_translation( $domain ) ) ? false : $translations;\n}, 10, 4 );",
            "requires": [],
            "notes": "Only makes a difference when the site or user language is not English (United States), for example English (UK), or when plugins ship translations you never show. With 'frontend', wp-admin, the login page, AJAX, REST, cron and WP-CLI keep their translations. Add 'default' to the keep list to keep WordPress's own strings (dates, comment form) translated. Translations a plugin loads before WPIDE runs snippets are not affected."
        },
        {
            "id": "disable-login-autofocus",
            "version": 1,
            "title": "Turn Off Autofocus on the Login Form",
            "description": "Stops the login, lost password and registration screens from putting the cursor in the first field as soon as they load, so the page does not jump or pop up the on-screen keyboard.",
            "category": "login",
            "tags": [
                "login",
                "ux",
                "mobile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// The main login form has a filter for this.\nadd_filter( 'enable_login_autofocus', '__return_false' );\n\n// The lost password, registration and reset password forms focus their first field without a filter,\n// so take the focus away again right after WordPress sets it.\nadd_action( 'login_footer', function () {\n    wp_print_inline_script_tag( '(function(){var field=document.activeElement;if(field&&field!==document.body&&field.form){field.blur();}})();' );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "link-featured-images-to-posts",
            "version": 1,
            "title": "Link Featured Images to Their Posts",
            "description": "Wraps featured images on blog, archive and search pages in a link to the post, for themes that show the image without one. Works with classic themes and the Featured Image block.",
            "category": "archive",
            "tags": [
                "archive",
                "featured-image",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// The image link repeats the title link, so it is hidden from screen readers and skipped by the Tab key.\n$wpide_featured_link_open = function ( $post_id ) {\n    return '<a href=\"' . esc_url( get_permalink( $post_id ) ) . '\" class=\"wpide-featured-image-link\" aria-hidden=\"true\" tabindex=\"-1\">';\n};\n\n$wpide_featured_link_skip = function ( $post_id ) {\n    return ! $post_id || is_admin() || is_feed() || ( is_singular() && (int) $post_id === get_queried_object_id() );\n};\n\n// Classic themes: the_post_thumbnail() and get_the_post_thumbnail().\nadd_filter( 'post_thumbnail_html', function ( $html, $post_id ) use ( $wpide_featured_link_open, $wpide_featured_link_skip ) {\n    if ( '' === trim( (string) $html ) || wp_is_block_theme() || false !== stripos( $html, '<a ' ) || $wpide_featured_link_skip( $post_id ) ) {\n        return $html;\n    }\n\n    return $wpide_featured_link_open( $post_id ) . $html . '</a>';\n}, 10, 2 );\n\n// Block themes: the Featured Image block when \"Link to post\" is switched off.\nadd_filter( 'render_block_core/post-featured-image', function ( $content, $block, $instance = null ) use ( $wpide_featured_link_open, $wpide_featured_link_skip ) {\n    $post_id = ( $instance instanceof WP_Block && ! empty( $instance->context['postId'] ) ) ? (int) $instance->context['postId'] : 0;\n\n    if ( '' === trim( (string) $content ) || ! empty( $block['attrs']['isLink'] ) || false !== stripos( $content, '<a ' ) || $wpide_featured_link_skip( $post_id ) ) {\n        return $content;\n    }\n\n    return preg_replace_callback( '/<img\\b[^>]*>/i', function ( $match ) use ( $wpide_featured_link_open, $post_id ) {\n        return $wpide_featured_link_open( $post_id ) . $match[0] . '</a>';\n    }, $content, 1 );\n}, 10, 3 );",
            "requires": [],
            "notes": "Images that already have a link are left alone, including Featured Image blocks with \"Link to post\" switched on. If a classic theme puts its own link around the image from outside, turn this off to avoid a link inside a link. The extra link is hidden from screen readers and the Tab key because the post title already links to the same page."
        },
        {
            "id": "rss-summaries-only",
            "version": 1,
            "title": "Summaries Only in RSS Feeds With a Continue Reading Link",
            "description": "Sends only a short summary of each post to feed readers, whatever the Reading setting says, and ends it with a link back to the full post on your site.",
            "category": "rss",
            "tags": [
                "rss",
                "feed",
                "excerpt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of words in each automatic feed summary (posts with a written excerpt use it as is).\n$wpide_feed_summary_words = 55;\n\n// Link added after each summary. %s is replaced with the post title. Leave empty for no link.\n$wpide_feed_continue_text = 'Continue reading: %s';\n\n// Same as choosing \"Excerpt\" under Settings > Reading > \"For each post in a feed, include\".\nadd_filter( 'pre_option_rss_use_excerpt', function () {\n    return '1';\n} );\n\nadd_filter( 'excerpt_length', function ( $length ) use ( $wpide_feed_summary_words ) {\n    return is_feed() ? max( 1, (int) $wpide_feed_summary_words ) : $length;\n}, 999 );\n\nadd_filter( 'excerpt_more', function ( $more ) {\n    return is_feed() ? '&hellip;' : $more;\n}, 999 );\n\nadd_filter( 'the_excerpt_rss', function ( $excerpt ) use ( $wpide_feed_continue_text ) {\n    // The RDF feed prints summaries without CDATA, so it only gets plain text.\n    if ( '' === $wpide_feed_continue_text || 'rdf' === get_query_var( 'feed' ) ) {\n        return $excerpt;\n    }\n\n    $text = sprintf( $wpide_feed_continue_text, wp_strip_all_tags( get_the_title() ) );\n\n    return trim( $excerpt ) . ' <a href=\"' . esc_url( get_permalink() ) . '\">' . esc_html( $text ) . '</a>';\n}, 20 );",
            "requires": [],
            "notes": "Posts with a written excerpt use it in full; others are cut to the word count at the top. Set the link text to an empty string to leave the link out. If the \"Read the Full Article\" Link in Feeds snippet is also active, items get two links."
        },
        {
            "id": "disable-block-inspector-tabs",
            "version": 1,
            "title": "Show All Block Settings in One List Without Tabs",
            "description": "Removes the Settings and Styles tabs from the block sidebar in the editor, so every panel for the selected block is listed together again.",
            "category": "gutenberg",
            "tags": [
                "gutenberg",
                "editor",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Blocks that keep the Settings / Styles tabs, for example 'core/navigation'. Leave empty to turn the tabs off for every block.\n$wpide_blocks_with_inspector_tabs = array();\n\nadd_filter( 'block_editor_settings_all', function ( $settings ) use ( $wpide_blocks_with_inspector_tabs ) {\n    $tabs = array( 'default' => false );\n\n    foreach ( $wpide_blocks_with_inspector_tabs as $block_name ) {\n        $tabs[ $block_name ] = true;\n    }\n\n    $settings['blockInspectorTabs'] = $tabs;\n\n    return $settings;\n} );",
            "requires": [],
            "notes": "Uses the editor's blockInspectorTabs setting (WordPress 6.2 and later). List block names at the top, such as 'core/navigation', to keep the tabs for those blocks only."
        },
        {
            "id": "customize-menu-for-block-themes",
            "version": 1,
            "title": "Bring Back Appearance > Customize on Block Themes",
            "description": "Restores the Customize link under Appearance and in the admin bar on block themes, for quick access to Customizer settings such as Site Identity and Homepage Settings.",
            "category": "admin",
            "tags": [
                "admin",
                "customizer",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// On block themes WordPress shows Appearance > Customize (and the \"Customize\" admin bar link)\n// only when something hooks into the Customizer. An empty callback is enough to bring both back.\nadd_action( 'customize_register', '__return_null' );",
            "requires": [],
            "notes": "The Customizer only shows the panels that still apply to your theme. Colors, fonts and layouts of block themes stay in the Site Editor."
        },
        {
            "id": "post-meta-viewer",
            "version": 1,
            "title": "Read-Only Custom Fields Viewer for Administrators",
            "description": "Adds a box to the post editor that lists every custom field stored for the post, hidden ones included, with serialized arrays and JSON shown in a readable layout. Nothing can be changed from it.",
            "category": "developer",
            "tags": [
                "developer",
                "debugging",
                "custom-fields",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Who can see the box.\n$wpide_meta_viewer_capability = 'manage_options';\n\n// Also list hidden keys that start with an underscore (used by WordPress and plugins).\n$wpide_meta_viewer_show_hidden = true;\n\n// Post types that get the box. Leave empty for every post type with an edit screen.\n$wpide_meta_viewer_post_types = array();\n\n$wpide_meta_viewer_value = function ( $value ) {\n    $value = (string) $value;\n\n    if ( '' === $value ) {\n        return '<em>(empty)</em>';\n    }\n\n    // Serialized data is unpacked for reading only. Objects are never rebuilt.\n    if ( is_serialized( $value ) ) {\n        $data = @unserialize( trim( $value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.PHP.DiscouragedPHPFunctions\n\n        if ( false !== $data || 'b:0;' === trim( $value ) ) {\n            return '<span class=\"wpide-meta-viewer__type\">serialized ' . esc_html( gettype( $data ) ) . '</span><pre>' . esc_html( print_r( $data, true ) ) . '</pre>'; // phpcs:ignore WordPress.PHP.DevelopmentFunctions\n        }\n    }\n\n    $first = substr( ltrim( $value ), 0, 1 );\n\n    if ( '{' === $first || '[' === $first ) {\n        $data = json_decode( $value, true );\n\n        if ( null !== $data ) {\n            return '<span class=\"wpide-meta-viewer__type\">JSON</span><pre>' . esc_html( (string) wp_json_encode( $data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) ) . '</pre>';\n        }\n    }\n\n    return '<pre>' . esc_html( $value ) . '</pre>';\n};\n\n$wpide_meta_viewer_render = function ( $post ) use ( $wpide_meta_viewer_show_hidden, $wpide_meta_viewer_value ) {\n    $meta = get_post_meta( $post->ID );\n    $meta = is_array( $meta ) ? $meta : array();\n\n    if ( ! $wpide_meta_viewer_show_hidden ) {\n        $meta = array_filter( $meta, function ( $key ) {\n            return ! is_protected_meta( $key, 'post' );\n        }, ARRAY_FILTER_USE_KEY );\n    }\n\n    uksort( $meta, 'strnatcasecmp' );\n\n    if ( ! $meta ) {\n        echo '<p>This post has no custom fields.</p>';\n        return;\n    }\n\n    ?>\n    <style>\n        .wpide-meta-viewer { table-layout: fixed; }\n        .wpide-meta-viewer th:first-child { width: 30%; }\n        .wpide-meta-viewer td { vertical-align: top; overflow-wrap: anywhere; }\n        .wpide-meta-viewer pre { margin: 0; max-height: 240px; overflow: auto; white-space: pre-wrap; word-break: break-word; font-size: 12px; }\n        .wpide-meta-viewer__type { display: inline-block; margin-bottom: 4px; padding: 0 6px; border-radius: 3px; background: #f0f0f1; color: #50575e; font-size: 11px; }\n        .wpide-meta-viewer-filter { margin: 4px 0 8px; }\n    </style>\n    <p class=\"wpide-meta-viewer-filter\">\n        <label for=\"wpide-meta-viewer-search\"><?php echo esc_html( sprintf( '%d keys. Filter by key:', count( $meta ) ) ); ?></label>\n        <input type=\"search\" id=\"wpide-meta-viewer-search\" class=\"regular-text\">\n    </p>\n    <table class=\"widefat striped wpide-meta-viewer\">\n        <thead><tr><th scope=\"col\">Key</th><th scope=\"col\">Value</th></tr></thead>\n        <tbody>\n        <?php foreach ( $meta as $key => $values ) : ?>\n            <?php foreach ( (array) $values as $value ) : ?>\n                <tr data-key=\"<?php echo esc_attr( strtolower( $key ) ); ?>\">\n                    <td><code><?php echo esc_html( $key ); ?></code></td>\n                    <td><?php echo $wpide_meta_viewer_value( $value ); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped when built. ?></td>\n                </tr>\n            <?php endforeach; ?>\n        <?php endforeach; ?>\n        </tbody>\n    </table>\n    <script>\n    ( function () {\n        var search = document.getElementById( 'wpide-meta-viewer-search' );\n        if ( ! search ) { return; }\n        search.addEventListener( 'input', function () {\n            var term = search.value.trim().toLowerCase();\n            document.querySelectorAll( '.wpide-meta-viewer tbody tr' ).forEach( function ( row ) {\n                row.hidden = term !== '' && row.getAttribute( 'data-key' ).indexOf( term ) === -1;\n            } );\n        } );\n    } )();\n    </script>\n    <?php\n};\n\nadd_action( 'add_meta_boxes', function ( $post_type, $post ) use ( $wpide_meta_viewer_capability, $wpide_meta_viewer_post_types, $wpide_meta_viewer_render ) {\n    if ( ! $post instanceof WP_Post || ! current_user_can( $wpide_meta_viewer_capability ) ) {\n        return;\n    }\n\n    if ( $wpide_meta_viewer_post_types && ! in_array( $post_type, $wpide_meta_viewer_post_types, true ) ) {\n        return;\n    }\n\n    add_meta_box( 'wpide-post-meta-viewer', 'All Custom Fields (read-only)', $wpide_meta_viewer_render, $post_type, 'normal', 'low' );\n}, 10, 2 );",
            "requires": [],
            "notes": "Only users who can manage options see the box. Serialized objects are displayed without being rebuilt, so viewing them is safe. In the block editor the box is at the bottom of the screen. Type in the filter field to find a key quickly."
        },
        {
            "id": "wp-admin-for-staff-only",
            "version": 1,
            "title": "Restrict wp-admin to Staff With WooCommerce My Account Support",
            "description": "Sends subscribers, customers and other users without editing rights from wp-admin to the WooCommerce My Account page or the homepage, with options to keep the profile screen open and hide the admin bar. AJAX and front-end forms keep working.",
            "category": "redirects",
            "tags": [
                "redirects",
                "admin",
                "users",
                "woocommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Users with this capability keep full access to wp-admin. 'edit_posts' covers contributors and up.\n$wpide_admin_access_capability = 'edit_posts';\n\n// Let everyone else still open Users > Profile to change their name, email or password.\n$wpide_allow_profile_page = false;\n\n// Where to send them. Leave empty for the WooCommerce My Account page (when WooCommerce is active) or the homepage.\n// Must be an address on this site.\n$wpide_admin_redirect_url = '';\n\n// Also hide the admin bar from these users on the front end.\n$wpide_hide_admin_bar_for_them = true;\n\n$wpide_admin_destination = function ( $for_profile = false ) use ( $wpide_admin_redirect_url ) {\n    if ( '' !== $wpide_admin_redirect_url ) {\n        return $wpide_admin_redirect_url;\n    }\n\n    if ( function_exists( 'wc_get_page_id' ) && function_exists( 'wc_get_page_permalink' ) && wc_get_page_id( 'myaccount' ) > 0 ) {\n        return ( $for_profile && function_exists( 'wc_get_account_endpoint_url' ) ) ? wc_get_account_endpoint_url( 'edit-account' ) : wc_get_page_permalink( 'myaccount' );\n    }\n\n    return home_url( '/' );\n};\n\nadd_action( 'admin_init', function () use ( $wpide_admin_access_capability, $wpide_allow_profile_page, $wpide_admin_destination ) {\n    global $pagenow;\n\n    if ( wp_doing_ajax() || wp_doing_cron() || ! is_user_logged_in() || current_user_can( $wpide_admin_access_capability ) ) {\n        return;\n    }\n\n    // Front-end forms post to these files.\n    if ( in_array( $pagenow, array( 'admin-post.php', 'async-upload.php' ), true ) ) {\n        return;\n    }\n\n    $is_profile = 'profile.php' === $pagenow;\n\n    if ( $is_profile && $wpide_allow_profile_page ) {\n        return;\n    }\n\n    wp_safe_redirect( $wpide_admin_destination( $is_profile ) );\n    exit;\n}, 1 );\n\n// WooCommerce blocks wp-admin for customers on its own. Let the profile page through when it is allowed.\nadd_filter( 'woocommerce_prevent_admin_access', function ( $prevent ) use ( $wpide_allow_profile_page ) {\n    return ( $wpide_allow_profile_page && isset( $GLOBALS['pagenow'] ) && 'profile.php' === $GLOBALS['pagenow'] ) ? false : $prevent;\n} );\n\n// \"Edit Profile\" links point to the front-end account page when the profile screen is closed.\nadd_filter( 'edit_profile_url', function ( $url, $user_id ) use ( $wpide_admin_access_capability, $wpide_allow_profile_page, $wpide_admin_destination ) {\n    if ( $wpide_allow_profile_page || user_can( $user_id, $wpide_admin_access_capability ) ) {\n        return $url;\n    }\n\n    return $wpide_admin_destination( true );\n}, 10, 2 );\n\nadd_filter( 'show_admin_bar', function ( $show ) use ( $wpide_hide_admin_bar_for_them, $wpide_admin_access_capability ) {\n    if ( $wpide_hide_admin_bar_for_them && is_user_logged_in() && ! current_user_can( $wpide_admin_access_capability ) ) {\n        return false;\n    }\n\n    return $show;\n} );",
            "requires": [],
            "notes": "A fuller version of \"Keep Subscribers Out of wp-admin\"; use one or the other. Works without WooCommerce. When the profile screen is closed, \"Edit Profile\" links go to My Account > Account details (WooCommerce) or the redirect address."
        },
        {
            "id": "avatar-alt-text-from-name",
            "version": 1,
            "title": "Use the Person's Name as Avatar Alt Text",
            "description": "Fills the empty alt attribute of avatars with the display name of the user or commenter, so accessibility checkers stop reporting images without alt text.",
            "category": "users",
            "tags": [
                "users",
                "avatars",
                "accessibility",
                "alt-text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Alt text for avatars that have none. %s is replaced with the person's display name.\n$wpide_avatar_alt_pattern = '%s';\n\nadd_filter( 'get_avatar_data', function ( $args, $id_or_email ) use ( $wpide_avatar_alt_pattern ) {\n    if ( ! empty( $args['alt'] ) ) {\n        return $args;\n    }\n\n    $name = '';\n\n    if ( $id_or_email instanceof WP_Comment ) {\n        $name = $id_or_email->user_id ? get_the_author_meta( 'display_name', $id_or_email->user_id ) : $id_or_email->comment_author;\n    } elseif ( $id_or_email instanceof WP_User ) {\n        $name = $id_or_email->display_name;\n    } elseif ( $id_or_email instanceof WP_Post ) {\n        $name = get_the_author_meta( 'display_name', $id_or_email->post_author );\n    } elseif ( is_numeric( $id_or_email ) ) {\n        $name = get_the_author_meta( 'display_name', (int) $id_or_email );\n    } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) {\n        $user = get_user_by( 'email', $id_or_email );\n        $name = $user ? $user->display_name : '';\n    }\n\n    $name = trim( wp_strip_all_tags( (string) $name ) );\n\n    if ( '' !== $name ) {\n        $args['alt'] = sprintf( $wpide_avatar_alt_pattern, $name );\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Avatars that already have alt text keep it. Change the pattern to something like 'Photo of %s'. Snippets that replace the whole avatar image (such as Initials Avatars Instead of Gravatar) build their own tag, so this one does not change theirs."
        },
        {
            "id": "edit-links-in-post-listings",
            "version": 1,
            "title": "Edit Links on Posts in Blog and Archive Lists",
            "description": "Adds an Edit link under each post on blog, archive and search pages for logged-in users who are allowed to edit that post. Works with classic themes and Query Loop blocks.",
            "category": "archive",
            "tags": [
                "archive",
                "editing",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Link text. Screen readers also hear the post title.\n$wpide_listing_edit_text = 'Edit';\n\n$wpide_listing_edit_link = function ( $post_id ) use ( $wpide_listing_edit_text ) {\n    $url = get_edit_post_link( $post_id, 'raw' );\n\n    if ( ! $url || ! current_user_can( 'edit_post', $post_id ) ) {\n        return '';\n    }\n\n    $label = $wpide_listing_edit_text . ' “' . wp_strip_all_tags( get_the_title( $post_id ) ) . '”';\n\n    return '<p class=\"wpide-edit-link\"><a class=\"post-edit-link\" href=\"' . esc_url( $url ) . '\" aria-label=\"' . esc_attr( $label ) . '\">' . esc_html( $wpide_listing_edit_text ) . '</a></p>';\n};\n\n// Classic themes: after the excerpt or content of each post on blog, archive and search pages.\n$wpide_listing_edit_append = function ( $content ) use ( $wpide_listing_edit_link ) {\n    if ( is_admin() || is_feed() || is_singular() || wp_is_block_theme() || ! in_the_loop() || doing_filter( 'get_the_excerpt' ) ) {\n        return $content;\n    }\n\n    return $content . $wpide_listing_edit_link( get_the_ID() );\n};\n\nadd_filter( 'the_excerpt', $wpide_listing_edit_append, 20 );\nadd_filter( 'the_content', $wpide_listing_edit_append, 20 );\n\n// Block themes: after the Post Title block inside any Query Loop.\nadd_filter( 'render_block_core/post-title', function ( $content, $block, $instance = null ) use ( $wpide_listing_edit_link ) {\n    if ( is_admin() || ! $instance instanceof WP_Block || empty( $instance->context['postId'] ) || ! isset( $instance->context['queryId'] ) ) {\n        return $content;\n    }\n\n    return $content . $wpide_listing_edit_link( (int) $instance->context['postId'] );\n}, 10, 3 );",
            "requires": [],
            "notes": "Visitors never see the link. In block themes it appears after the Post Title block of each item in a Query Loop. Some classic themes already print an edit link in the post footer; if yours does, you will see both."
        },
        {
            "id": "active-plugins-first",
            "version": 1,
            "title": "List Active Plugins First on the Plugins Screen",
            "description": "Moves active plugins to the top of the Plugins screen, followed by the inactive ones, keeping alphabetical order inside each group.",
            "category": "admin",
            "tags": [
                "admin",
                "plugins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Reorders the rows of the Plugins screen so active plugins come first. Alphabetical order is kept inside each group.\nadd_action( 'admin_footer-plugins.php', function () {\n    ?>\n    <script>\n    ( function () {\n        var list = document.getElementById( 'the-list' );\n        if ( ! list ) {\n            return;\n        }\n\n        // A plugin row can be followed by its update notice and rows added by other plugins.\n        var groups = [];\n        var current = null;\n        Array.prototype.slice.call( list.children ).forEach( function ( row ) {\n            if ( row.hasAttribute( 'data-plugin' ) && ! row.classList.contains( 'plugin-update-tr' ) ) {\n                current = { active: row.classList.contains( 'active' ), rows: [] };\n                groups.push( current );\n            }\n            if ( current ) {\n                current.rows.push( row );\n            }\n        } );\n\n        var ordered = groups.filter( function ( group ) { return group.active; } )\n            .concat( groups.filter( function ( group ) { return ! group.active; } ) );\n\n        ordered.forEach( function ( group ) {\n            group.rows.forEach( function ( row ) {\n                list.appendChild( row );\n            } );\n        } );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "The rows are reordered in the browser as the screen loads. Search, filters and bulk actions work as usual."
        },
        {
            "id": "random-posts-shortcode",
            "version": 1,
            "title": "Random Posts Shortcode",
            "description": "Adds a [random_posts] shortcode that lists a few random posts, optionally from chosen categories or another post type, without running a slow random database query on every page view.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "posts",
                "random"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How long the list of posts to pick from is cached, in minutes.\n$wpide_random_posts_cache_minutes = 60;\n\n// How many of the newest posts the random picks come from.\n$wpide_random_posts_pool_size = 200;\n\nadd_shortcode( 'random_posts', function ( $atts ) use ( $wpide_random_posts_cache_minutes, $wpide_random_posts_pool_size ) {\n    $atts = shortcode_atts( array(\n        'count'     => 5,\n        'post_type' => 'post',\n        'category'  => '',\n    ), $atts, 'random_posts' );\n\n    $count     = max( 1, min( 50, absint( $atts['count'] ) ) );\n    $post_type = sanitize_key( $atts['post_type'] );\n\n    if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) {\n        return '';\n    }\n\n    $query = array(\n        'post_type'           => $post_type,\n        'post_status'         => 'publish',\n        'posts_per_page'      => max( $count, (int) $wpide_random_posts_pool_size ),\n        'fields'              => 'ids',\n        'has_password'        => false,\n        'ignore_sticky_posts' => true,\n        'no_found_rows'       => true,\n        'orderby'             => 'date',\n        'order'               => 'DESC',\n    );\n\n    // category=\"news\" or category=\"news,12\" (slugs or IDs).\n    $categories = array_filter( array_map( 'trim', explode( ',', (string) $atts['category'] ) ) );\n\n    if ( $categories && is_object_in_taxonomy( $post_type, 'category' ) ) {\n        $ids   = array_map( 'intval', array_filter( $categories, 'ctype_digit' ) );\n        $slugs = array_map( 'sanitize_title', array_diff( $categories, array_filter( $categories, 'ctype_digit' ) ) );\n\n        $query['tax_query'] = array( 'relation' => 'OR' );\n\n        if ( $ids ) {\n            $query['tax_query'][] = array( 'taxonomy' => 'category', 'field' => 'term_id', 'terms' => $ids );\n        }\n\n        if ( $slugs ) {\n            $query['tax_query'][] = array( 'taxonomy' => 'category', 'field' => 'slug', 'terms' => array_values( $slugs ) );\n        }\n    }\n\n    // The candidate list is cached, so pages do not run a slow ORDER BY RAND() query.\n    $cache_key = 'wpide_random_posts_' . md5( (string) wp_json_encode( $query ) );\n    $pool      = get_transient( $cache_key );\n\n    if ( ! is_array( $pool ) ) {\n        $pool = array_map( 'intval', get_posts( $query ) );\n        set_transient( $cache_key, $pool, max( 1, (int) $wpide_random_posts_cache_minutes ) * MINUTE_IN_SECONDS );\n    }\n\n    if ( is_singular() ) {\n        $pool = array_diff( $pool, array( get_queried_object_id() ) );\n    }\n\n    if ( ! $pool ) {\n        return '';\n    }\n\n    shuffle( $pool );\n\n    $posts = get_posts( array(\n        'post__in'       => array_slice( $pool, 0, $count ),\n        'post_type'      => $post_type,\n        'posts_per_page' => $count,\n        'orderby'        => 'post__in',\n    ) );\n\n    $items = '';\n\n    foreach ( $posts as $post ) {\n        $items .= '<li><a href=\"' . esc_url( get_permalink( $post ) ) . '\">' . esc_html( get_the_title( $post ) ) . '</a></li>';\n    }\n\n    return '' === $items ? '' : '<ul class=\"wpide-random-posts\">' . $items . '</ul>';\n} );",
            "requires": [],
            "notes": "Examples: [random_posts], [random_posts count=\"3\" category=\"news,reviews\"], [random_posts post_type=\"page\" count=\"4\"]. Categories can be slugs or IDs. The list of candidate posts is cached (60 minutes by default) and a new random pick is made from it on every uncached page view. The post being viewed is never included."
        },
        {
            "id": "time-of-day-greeting-shortcode",
            "version": 1,
            "title": "Time-of-Day Greeting Shortcode",
            "description": "Adds a [greeting] shortcode that says Good morning, Good afternoon or Good evening based on your site's timezone, with the logged-in user's first name when there is one.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "personalization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Greetings and the hour each one starts (24-hour clock, in the timezone set under Settings > General).\n// Between midnight and the first hour, the last greeting is used.\n$wpide_greetings = array(\n    5  => 'Good morning',\n    12 => 'Good afternoon',\n    18 => 'Good evening',\n);\n\nadd_shortcode( 'greeting', function ( $atts ) use ( $wpide_greetings ) {\n    $atts = shortcode_atts( array(\n        'name'  => 'yes', // Add the logged-in user's first name.\n        'guest' => '',    // Name used for visitors who are not logged in, e.g. \"friend\".\n    ), $atts, 'greeting' );\n\n    $greetings = $wpide_greetings;\n    ksort( $greetings );\n\n    $hour     = (int) wp_date( 'G' );\n    $greeting = (string) end( $greetings );\n\n    foreach ( $greetings as $start => $text ) {\n        if ( $hour >= (int) $start ) {\n            $greeting = (string) $text;\n        }\n    }\n\n    $name = '';\n\n    if ( 'yes' === strtolower( (string) $atts['name'] ) && is_user_logged_in() ) {\n        $user = wp_get_current_user();\n        $name = '' !== trim( $user->first_name ) ? $user->first_name : $user->display_name;\n    }\n\n    if ( '' === trim( $name ) ) {\n        $name = (string) $atts['guest'];\n    }\n\n    $name = trim( wp_strip_all_tags( $name ) );\n\n    return '<span class=\"wpide-greeting\">' . esc_html( '' !== $name ? $greeting . ', ' . $name . '!' : $greeting . '!' ) . '</span>';\n} );",
            "requires": [],
            "notes": "Examples: [greeting], [greeting guest=\"friend\"], [greeting name=\"no\"]. Change the texts and start hours at the top. Page caching can show logged-out visitors a greeting from an earlier time of day."
        },
        {
            "id": "collapse-long-comments",
            "version": 1,
            "title": "Collapse Long Comments With a Read More Button",
            "description": "Shortens very long comments to a few lines with a soft fade and a Read more / Show less button. Works with classic themes and the Comments block, and leaves comments whole when JavaScript is off.",
            "category": "comments",
            "tags": [
                "comments",
                "javascript",
                "ux",
                "accessibility"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-comment-collapsed {\n    max-height: var(--wpide-comment-height, 200px);\n    overflow: hidden;\n    -webkit-mask-image: linear-gradient(to bottom, #000 60%, transparent);\n    mask-image: linear-gradient(to bottom, #000 60%, transparent);\n}\n.wpide-comment-toggle {\n    margin: .5em 0 0;\n    padding: 0;\n    border: 0;\n    background: none;\n    color: inherit;\n    font: inherit;\n    font-weight: 600;\n    text-decoration: underline;\n    cursor: pointer;\n}\n.wpide-comment-toggle:focus-visible {\n    outline: 2px solid currentColor;\n    outline-offset: 2px;\n}\n@media print {\n    .wpide-comment-collapsed { max-height: none; -webkit-mask-image: none; mask-image: none; }\n    .wpide-comment-toggle { display: none; }\n}\n</style>\n<script>\n(function () {\n    var COLLAPSED_HEIGHT = 200; // Visible height of a collapsed comment, in pixels.\n    var MIN_HIDDEN = 120;       // Only collapse when at least this much would be hidden.\n    var MORE_TEXT = 'Read more';\n    var LESS_TEXT = 'Show less';\n\n    var bodies = document.querySelectorAll('.comment-content, .wp-block-comment-content');\n\n    Array.prototype.forEach.call(bodies, function (body, index) {\n        if (body.scrollHeight < COLLAPSED_HEIGHT + MIN_HIDDEN) {\n            return;\n        }\n\n        if (!body.id) {\n            body.id = 'wpide-comment-body-' + (index + 1);\n        }\n\n        body.style.setProperty('--wpide-comment-height', COLLAPSED_HEIGHT + 'px');\n\n        var button = document.createElement('button');\n        button.type = 'button';\n        button.className = 'wpide-comment-toggle';\n        button.setAttribute('aria-controls', body.id);\n\n        var setOpen = function (open) {\n            body.classList.toggle('wpide-comment-collapsed', !open);\n            button.setAttribute('aria-expanded', open ? 'true' : 'false');\n            button.textContent = open ? LESS_TEXT : MORE_TEXT;\n        };\n\n        button.addEventListener('click', function () {\n            var open = body.classList.contains('wpide-comment-collapsed');\n            setOpen(open);\n            if (!open && body.getBoundingClientRect().top < 0) {\n                body.scrollIntoView({ block: 'start' });\n            }\n        });\n\n        // Keyboard users who tab into a link in the hidden part get the whole comment.\n        body.addEventListener('focusin', function () {\n            if (body.classList.contains('wpide-comment-collapsed')) {\n                setOpen(true);\n            }\n        });\n\n        body.parentNode.insertBefore(button, body.nextSibling);\n        setOpen(false);\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Change the heights and button texts at the top of the script. Screen readers still read the whole comment, the button reports whether it is expanded, and tabbing into a link in the hidden part opens the comment."
        },
        {
            "id": "fade-in-page-transitions",
            "version": 1,
            "title": "Fade-In Page Transitions",
            "description": "Fades each page in as it loads and gently out when visitors follow a link to another page on your site. Turned off for people who prefer reduced motion, and pages never stay hidden if JavaScript fails.",
            "category": "frontend",
            "tags": [
                "frontend",
                "css",
                "animation",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "<style>\n/* The fade-in is pure CSS, so pages stay visible even when JavaScript is off or fails. */\n@media (prefers-reduced-motion: no-preference) {\n    @keyframes wpide-page-fade-in {\n        from { opacity: 0; }\n        to { opacity: 1; }\n    }\n    body {\n        animation: wpide-page-fade-in .4s ease-out backwards;\n    }\n    html.wpide-page-leaving body {\n        opacity: 0;\n        transition: opacity .2s ease-in;\n    }\n}\n</style>\n<script>\n(function () {\n    var FADE_OUT_MS = 200; // Fade out before following a link. Set to 0 to only fade in.\n\n    if (!FADE_OUT_MS || !window.matchMedia || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n        return;\n    }\n\n    var root = document.documentElement;\n    var skipFiles = /\\.(jpe?g|png|gif|webp|avif|svg|pdf|zip|rar|7z|mp3|mp4|mov|webm|docx?|xlsx?|pptx?|csv|txt)$/i;\n\n    // Listening on window runs after other click handlers, so links they take over are left alone.\n    window.addEventListener('click', function (event) {\n        if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {\n            return;\n        }\n\n        var link = event.target && event.target.closest ? event.target.closest('a[href]') : null;\n\n        if (!link || (link.target && link.target !== '_self') || link.hasAttribute('download') || link.closest('.no-fade, [data-no-fade]')) {\n            return;\n        }\n\n        var url = new URL(link.href, location.href);\n\n        if (url.origin !== location.origin || !/^https?:$/.test(url.protocol)) {\n            return;\n        }\n\n        if (url.hash && url.pathname === location.pathname && url.search === location.search) {\n            return;\n        }\n\n        if (skipFiles.test(url.pathname) || /[?&](add-to-cart|download)=/.test(url.search)) {\n            return;\n        }\n\n        event.preventDefault();\n        root.classList.add('wpide-page-leaving');\n\n        setTimeout(function () {\n            location.href = url.href;\n        }, FADE_OUT_MS);\n\n        // Show the page again if the navigation does not happen.\n        setTimeout(function () {\n            root.classList.remove('wpide-page-leaving');\n        }, 3000);\n    });\n\n    // Pages restored with the Back button come back visible.\n    window.addEventListener('pageshow', function (event) {\n        if (event.persisted) {\n            root.classList.remove('wpide-page-leaving');\n        }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Links that open a new tab, downloads, files, same-page anchors and add-to-cart links are skipped. Add the class no-fade to a link (or a parent element) to skip it. Set FADE_OUT_MS to 0 to keep only the fade-in. The fade-in can delay Largest Contentful Paint slightly; shorten the .4s duration if that matters to you."
        },
        {
            "id": "floating-social-icons-bar",
            "version": 1,
            "title": "Floating Social Media Icons Bar",
            "description": "Shows a slim bar of social media icons fixed to the side of the screen, with built-in SVG icons, accessible labels and an option to hide it on small screens.",
            "category": "frontend",
            "tags": [
                "frontend",
                "social",
                "icons",
                "svg"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your profile links, in the order they appear. Leave a link empty to hide that icon.\n$wpide_social_links = array(\n    'facebook'  => 'https://www.facebook.com/yourpage',\n    'x'         => 'https://x.com/yourname',\n    'instagram' => 'https://www.instagram.com/yourname',\n    'linkedin'  => '',\n    'youtube'   => '',\n    'tiktok'    => '',\n    'pinterest' => '',\n    'bluesky'   => '',\n    'whatsapp'  => '', // e.g. https://wa.me/15551234567\n    'email'     => '', // e.g. mailto:hello@example.com\n    'rss'       => '', // e.g. https://example.com/feed/\n);\n\n// 'left' or 'right' edge of the screen.\n$wpide_social_bar_side = 'left';\n\n// Hide the bar on screens narrower than this, in pixels. 0 shows it everywhere.\n$wpide_social_bar_hide_below = 768;\n\n// Open the profiles in a new tab.\n$wpide_social_bar_new_tab = true;\n\n// Colors (hex).\n$wpide_social_bar_background = '#1e1e1e';\n$wpide_social_bar_color      = '#ffffff';\n\nadd_action( 'wp_footer', function () use ( $wpide_social_links, $wpide_social_bar_side, $wpide_social_bar_hide_below, $wpide_social_bar_new_tab, $wpide_social_bar_background, $wpide_social_bar_color ) {\n    // Simple line icons drawn on a 24 x 24 grid.\n    $icons = array(\n        'facebook'  => array( 'Facebook', '<path d=\"M15.5 3.5H13a4 4 0 0 0-4 4V10H6.5v3.5H9v7h3.5v-7h2.6l.6-3.5h-3.2V8a1 1 0 0 1 1-1h2z\"/>' ),\n        'x'         => array( 'X', '<path d=\"M4 4h4.5L20 20h-4.5z\"/><path d=\"M19.5 4l-6.3 7.2M10.8 12.8 4.5 20\"/>' ),\n        'instagram' => array( 'Instagram', '<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"5\"/><circle cx=\"12\" cy=\"12\" r=\"4\"/><circle cx=\"17.3\" cy=\"6.7\" r=\".8\" fill=\"currentColor\" stroke=\"none\"/>' ),\n        'linkedin'  => array( 'LinkedIn', '<rect x=\"3\" y=\"3\" width=\"18\" height=\"18\" rx=\"3\"/><path d=\"M8 10.5V17M8 7.4v.1M12 17v-6.5M12 13.6a2.6 2.6 0 0 1 5.2 0V17\"/>' ),\n        'youtube'   => array( 'YouTube', '<rect x=\"2.5\" y=\"5.5\" width=\"19\" height=\"13\" rx=\"4\"/><path d=\"M10.2 9.3v5.4l4.6-2.7z\" fill=\"currentColor\"/>' ),\n        'tiktok'    => array( 'TikTok', '<path d=\"M14 3.5v11a3.5 3.5 0 1 1-3.5-3.5\"/><path d=\"M14 3.5c.4 2.6 2.4 4.5 5 4.8\"/>' ),\n        'pinterest' => array( 'Pinterest', '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M11.7 9.4 9.9 20.6\"/><path d=\"M8.6 11.6a4 4 0 1 1 4 3.3c-.8 0-1.5-.3-2-.8\"/>' ),\n        'bluesky'   => array( 'Bluesky', '<path d=\"M12 11.2C10.6 8.3 7.4 5 5 5c-1.3 0-1.6 1.3-1.6 2.2 0 1 .4 4.3 2.4 5 1.8.6 3.4.3 3.4.3s-2.8.8-2.8 2.7c0 1.3 1.3 2.5 2.5 2.5 1.7 0 2.8-2.2 3.1-3.8.3 1.6 1.4 3.8 3.1 3.8 1.2 0 2.5-1.2 2.5-2.5 0-1.9-2.8-2.7-2.8-2.7s1.6.3 3.4-.3c2-.7 2.4-4 2.4-5 0-.9-.3-2.2-1.6-2.2-2.4 0-5.6 3.3-7 6.2z\"/>' ),\n        'whatsapp'  => array( 'WhatsApp', '<path d=\"M4.5 19.5l1.2-3.7a8.2 8.2 0 1 1 2.7 2.6z\"/><path d=\"M9.3 8.6c-.2 2.9 2.6 5.8 5.6 5.9l.9-1.5-1.8-1-.9.8a3.6 3.6 0 0 1-1.9-1.9l.8-.9-1-1.8z\"/>' ),\n        'email'     => array( 'Email', '<rect x=\"3\" y=\"5\" width=\"18\" height=\"14\" rx=\"2\"/><path d=\"m3.6 6.6 8.4 6.4 8.4-6.4\"/>' ),\n        'rss'       => array( 'RSS feed', '<path d=\"M5 11a8 8 0 0 1 8 8M5 5a14 14 0 0 1 14 14\"/><circle cx=\"6\" cy=\"18\" r=\"1.2\" fill=\"currentColor\" stroke=\"none\"/>' ),\n    );\n\n    $items = '';\n\n    foreach ( $wpide_social_links as $network => $url ) {\n        $url = trim( (string) $url );\n\n        if ( '' === $url || ! isset( $icons[ $network ] ) ) {\n            continue;\n        }\n\n        $external = $wpide_social_bar_new_tab && 0 !== strpos( $url, 'mailto:' );\n        $label    = $icons[ $network ][0] . ( $external ? ' (opens in a new tab)' : '' );\n\n        $items .= sprintf(\n            '<li><a href=\"%1$s\" aria-label=\"%2$s\"%3$s><svg viewBox=\"0 0 24 24\" width=\"22\" height=\"22\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\">%4$s</svg></a></li>',\n            esc_url( $url ),\n            esc_attr( $label ),\n            $external ? ' target=\"_blank\" rel=\"me noopener\"' : ' rel=\"me\"',\n            $icons[ $network ][1]\n        );\n    }\n\n    if ( '' === $items ) {\n        return;\n    }\n\n    $side       = 'right' === $wpide_social_bar_side ? 'right' : 'left';\n    $inner      = 'right' === $side ? 'left' : 'right';\n    $background = sanitize_hex_color( $wpide_social_bar_background ) ? sanitize_hex_color( $wpide_social_bar_background ) : '#1e1e1e';\n    $color      = sanitize_hex_color( $wpide_social_bar_color ) ? sanitize_hex_color( $wpide_social_bar_color ) : '#ffffff';\n    $hide_below = absint( $wpide_social_bar_hide_below );\n    ?>\n    <nav class=\"wpide-social-bar\" aria-label=\"Social media\"><ul><?php echo $items; // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above. ?></ul></nav>\n    <style>\n        .wpide-social-bar { position: fixed; top: 50%; <?php echo esc_html( $side ); ?>: 0; z-index: 9990; transform: translateY(-50%); }\n        .wpide-social-bar ul { display: flex; flex-direction: column; margin: 0; padding: 0; list-style: none; }\n        .wpide-social-bar li { margin: 0; padding: 0; }\n        .wpide-social-bar a { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; background: <?php echo esc_html( $background ); ?>; color: <?php echo esc_html( $color ); ?>; text-decoration: none; transition: filter .15s ease; }\n        .wpide-social-bar li:first-child a { border-top-<?php echo esc_html( $inner ); ?>-radius: 6px; }\n        .wpide-social-bar li:last-child a { border-bottom-<?php echo esc_html( $inner ); ?>-radius: 6px; }\n        .wpide-social-bar a:hover { filter: brightness(1.35); }\n        .wpide-social-bar a:focus-visible { outline: 3px solid <?php echo esc_html( $color ); ?>; outline-offset: -5px; }\n        .wpide-social-bar svg { display: block; width: 22px; height: 22px; }\n        @media (prefers-reduced-motion: reduce) { .wpide-social-bar a { transition: none; } }\n        <?php if ( $hide_below > 0 ) : ?>\n        @media (max-width: <?php echo (int) ( $hide_below - 1 ); ?>px) { .wpide-social-bar { display: none; } }\n        <?php endif; ?>\n        @media print { .wpide-social-bar { display: none; } }\n    </style>\n    <?php\n} );",
            "requires": [],
            "notes": "Fill in your profile links at the top; empty ones are skipped. Icons: Facebook, X, Instagram, LinkedIn, YouTube, TikTok, Pinterest, Bluesky, WhatsApp, email and RSS. They are simple outline drawings, not the official brand logos."
        },
        {
            "id": "image-magnifier-lens",
            "version": 1,
            "title": "Image Magnifier Lens on Hover",
            "description": "Shows a round magnifying lens over chosen images as visitors move the mouse across them, using the largest version of the image available. Opt in per image with a CSS class.",
            "category": "media",
            "tags": [
                "media",
                "images",
                "zoom",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-magnifier-lens {\n    position: fixed;\n    z-index: 99999;\n    display: none;\n    border: 3px solid #fff;\n    border-radius: 50%;\n    background-color: #fff;\n    background-repeat: no-repeat;\n    box-shadow: 0 4px 16px rgba(0, 0, 0, .35);\n    pointer-events: none;\n}\n@media (hover: hover) and (pointer: fine) {\n    .wpide-magnify img,\n    img.wpide-magnify {\n        cursor: zoom-in;\n    }\n}\n</style>\n<script>\n(function () {\n    var ZOOM = 2.5;       // Magnification.\n    var LENS_SIZE = 180;  // Lens diameter, in pixels.\n\n    var images = document.querySelectorAll('.wpide-magnify img, img.wpide-magnify');\n    if (!images.length || !window.PointerEvent) {\n        return;\n    }\n\n    var lens = document.createElement('div');\n    lens.className = 'wpide-magnifier-lens';\n    lens.setAttribute('aria-hidden', 'true');\n    lens.style.width = LENS_SIZE + 'px';\n    lens.style.height = LENS_SIZE + 'px';\n    document.body.appendChild(lens);\n\n    function hide() {\n        lens.style.display = 'none';\n    }\n\n    // Use the biggest version available: a link to the image file, data-full-url or the largest srcset entry.\n    function fullSource(img) {\n        var link = img.closest('a[href]');\n        if (link && /\\.(jpe?g|png|gif|webp|avif)(\\?.*)?$/i.test(link.href)) {\n            return link.href;\n        }\n        if (img.getAttribute('data-full-url')) {\n            return img.getAttribute('data-full-url');\n        }\n        var best = img.currentSrc || img.src;\n        var bestWidth = 0;\n        (img.getAttribute('srcset') || '').split(',').forEach(function (candidate) {\n            var parts = candidate.trim().split(/\\s+/);\n            var width = parseInt(parts[1], 10) || 0;\n            if (parts[0] && width > bestWidth) {\n                bestWidth = width;\n                best = parts[0];\n            }\n        });\n        return best;\n    }\n\n    Array.prototype.forEach.call(images, function (img) {\n        var source = '';\n\n        function move(event) {\n            var rect = img.getBoundingClientRect();\n            var x = event.clientX - rect.left;\n            var y = event.clientY - rect.top;\n            var half = LENS_SIZE / 2;\n\n            if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n                hide();\n                return;\n            }\n\n            lens.style.left = (event.clientX - half) + 'px';\n            lens.style.top = (event.clientY - half) + 'px';\n            lens.style.backgroundSize = (rect.width * ZOOM) + 'px ' + (rect.height * ZOOM) + 'px';\n            lens.style.backgroundPosition = (half - x * ZOOM) + 'px ' + (half - y * ZOOM) + 'px';\n            lens.style.display = 'block';\n        }\n\n        // Only a mouse shows the lens. Touch, pen and keyboard use the image as normal.\n        img.addEventListener('pointerenter', function (event) {\n            if (event.pointerType !== 'mouse') {\n                return;\n            }\n            if (!source) {\n                source = fullSource(img);\n            }\n            lens.style.backgroundImage = 'url(\"' + source.replace(/\"/g, '%22') + '\")';\n            move(event);\n        });\n\n        img.addEventListener('pointermove', function (event) {\n            if (event.pointerType === 'mouse') {\n                move(event);\n            }\n        });\n\n        img.addEventListener('pointerleave', hide);\n    });\n\n    window.addEventListener('scroll', hide, { passive: true });\n    document.addEventListener('keydown', function (event) {\n        if (event.key === 'Escape') {\n            hide();\n        }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Add wpide-magnify under Advanced > Additional CSS class(es) of an Image or Gallery block, or to any img tag. The lens only appears for a mouse; on touch screens and with the keyboard the image behaves as usual, and a link to the full-size file still works. Change ZOOM and LENS_SIZE at the top of the script."
        },
        {
            "id": "spoiler-shortcode",
            "version": 1,
            "title": "Inline Spoiler Shortcode",
            "description": "Adds a [spoiler] shortcode that blurs a word or sentence until the reader clicks it or presses Enter or Space, and keeps the hidden text from screen readers until it is revealed.",
            "category": "content",
            "tags": [
                "content",
                "shortcode",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// What screen readers announce (and the tooltip shows) before the spoiler is revealed.\n$wpide_spoiler_label = 'Spoiler, select to reveal';\n\n$wpide_spoiler_script = function () {\n    ?>\n    <script>\n    ( function () {\n        function reveal( spoiler ) {\n            spoiler.classList.add( 'is-revealed' );\n            [ 'role', 'aria-label', 'aria-expanded', 'title' ].forEach( function ( name ) {\n                spoiler.removeAttribute( name );\n            } );\n            spoiler.setAttribute( 'tabindex', '-1' );\n            var text = spoiler.querySelector( '.wpide-spoiler__text' );\n            if ( text ) {\n                text.removeAttribute( 'inert' );\n            }\n        }\n\n        function find( target ) {\n            return target && target.closest ? target.closest( '.wpide-spoiler:not(.is-revealed)' ) : null;\n        }\n\n        document.addEventListener( 'click', function ( event ) {\n            var spoiler = find( event.target );\n            if ( spoiler ) {\n                event.preventDefault();\n                reveal( spoiler );\n            }\n        } );\n\n        document.addEventListener( 'keydown', function ( event ) {\n            var spoiler = find( event.target );\n            if ( spoiler && ( event.key === 'Enter' || event.key === ' ' ) ) {\n                event.preventDefault();\n                reveal( spoiler );\n            }\n        } );\n    } )();\n    </script>\n    <?php\n};\n\nadd_shortcode( 'spoiler', function ( $atts, $content = '' ) use ( $wpide_spoiler_label, $wpide_spoiler_script ) {\n    static $assets_added = false;\n\n    $atts    = shortcode_atts( array( 'label' => $wpide_spoiler_label ), $atts, 'spoiler' );\n    $content = trim( do_shortcode( wp_kses_post( (string) $content ) ) );\n\n    if ( '' === $content ) {\n        return '';\n    }\n\n    // Feed readers do not run scripts or keep styles, so feeds only get the label.\n    if ( is_feed() ) {\n        return '[' . esc_html( $atts['label'] ) . ']';\n    }\n\n    $style = '';\n\n    if ( ! $assets_added ) {\n        $assets_added = true;\n        add_action( 'wp_footer', $wpide_spoiler_script );\n\n        // Printed with the first spoiler so the text is never visible before the styles load.\n        $style = '<style>'\n            . '.wpide-spoiler{border-radius:3px;background:rgba(128,128,128,.25);cursor:pointer;}'\n            . '.wpide-spoiler__text{filter:blur(.3em);-webkit-user-select:none;user-select:none;transition:filter .2s ease;}'\n            . '.wpide-spoiler:focus-visible{outline:2px solid currentColor;outline-offset:2px;}'\n            . '.wpide-spoiler:focus .wpide-spoiler__text,.wpide-spoiler.is-revealed .wpide-spoiler__text{filter:none;-webkit-user-select:auto;user-select:auto;}'\n            . '.wpide-spoiler.is-revealed{background:none;cursor:auto;outline:none;}'\n            . '@media (prefers-reduced-motion:reduce){.wpide-spoiler__text{transition:none;}}'\n            . '</style>';\n    }\n\n    return $style . sprintf(\n        '<span class=\"wpide-spoiler\" role=\"button\" tabindex=\"0\" aria-expanded=\"false\" aria-label=\"%1$s\" title=\"%1$s\"><span class=\"wpide-spoiler__text\" inert>%2$s</span></span>',\n        esc_attr( $atts['label'] ),\n        $content\n    );\n} );",
            "requires": [],
            "notes": "Example: The killer was [spoiler]the butler[/spoiler]. Change what screen readers announce with [spoiler label=\"Ending spoiler\"]. Feeds show only the label. Without JavaScript the text shows while the spoiler has keyboard focus."
        },
        {
            "id": "custom-logo-per-page",
            "version": 1,
            "title": "Different Logo on Selected Pages",
            "description": "Adds a Page Logo box to the page editor so you can pick a different logo from the Media Library for that page. It replaces the site logo wherever the theme shows it, including the Site Logo block.",
            "category": "frontend",
            "tags": [
                "frontend",
                "logo",
                "branding",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the \"Page Logo\" box.\n$wpide_page_logo_post_types = array( 'page' );\n\n// Where the chosen image ID is stored.\n$wpide_page_logo_meta_key = '_wpide_page_logo_id';\n\n// Registered for the REST API so the block editor saves it with the page.\nadd_action( 'init', function () use ( $wpide_page_logo_post_types, $wpide_page_logo_meta_key ) {\n    foreach ( $wpide_page_logo_post_types as $post_type ) {\n        register_post_meta( $post_type, $wpide_page_logo_meta_key, array(\n            'type'              => 'integer',\n            'single'            => true,\n            'default'           => 0,\n            'show_in_rest'      => true,\n            'sanitize_callback' => 'absint',\n            'auth_callback'     => function ( $allowed, $meta_key, $post_id ) {\n                return current_user_can( 'edit_post', $post_id );\n            },\n        ) );\n    }\n} );\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_page_logo_post_types, $wpide_page_logo_meta_key ) {\n    add_meta_box( 'wpide-page-logo', 'Page Logo', function ( $post ) use ( $wpide_page_logo_meta_key ) {\n        $logo_id = (int) get_post_meta( $post->ID, $wpide_page_logo_meta_key, true );\n        $preview = $logo_id ? wp_get_attachment_image( $logo_id, 'medium', false, array( 'style' => 'max-width:100%;height:auto;' ) ) : '';\n\n        wp_nonce_field( 'wpide_page_logo_save', 'wpide_page_logo_nonce' );\n        ?>\n        <div class=\"wpide-page-logo\" data-meta-key=\"<?php echo esc_attr( $wpide_page_logo_meta_key ); ?>\">\n            <div class=\"wpide-page-logo__preview\"><?php echo wp_kses_post( $preview ); ?></div>\n            <input type=\"hidden\" name=\"wpide_page_logo_id\" value=\"<?php echo esc_attr( $logo_id ? (string) $logo_id : '' ); ?>\">\n            <p>\n                <button type=\"button\" class=\"button wpide-page-logo__choose\">Choose logo</button>\n                <button type=\"button\" class=\"button-link button-link-delete wpide-page-logo__remove\"<?php echo $logo_id ? '' : ' hidden'; ?>>Remove</button>\n            </p>\n            <p class=\"description\">Shown instead of the site logo on this page only.</p>\n        </div>\n        <?php\n    }, $wpide_page_logo_post_types, 'side', 'low' );\n} );\n\nadd_action( 'admin_enqueue_scripts', function ( $hook ) use ( $wpide_page_logo_post_types ) {\n    $screen = get_current_screen();\n\n    if ( in_array( $hook, array( 'post.php', 'post-new.php' ), true ) && $screen && in_array( $screen->post_type, $wpide_page_logo_post_types, true ) ) {\n        wp_enqueue_media();\n    }\n} );\n\n$wpide_page_logo_script = function () use ( $wpide_page_logo_post_types ) {\n    $screen = get_current_screen();\n\n    if ( ! $screen || ! in_array( $screen->post_type, $wpide_page_logo_post_types, true ) ) {\n        return;\n    }\n    ?>\n    <script>\n    ( function () {\n        var box = document.querySelector( '.wpide-page-logo' );\n        if ( ! box || ! window.wp || ! wp.media ) {\n            return;\n        }\n\n        var input = box.querySelector( 'input[name=\"wpide_page_logo_id\"]' );\n        var preview = box.querySelector( '.wpide-page-logo__preview' );\n        var remove = box.querySelector( '.wpide-page-logo__remove' );\n        var metaKey = box.getAttribute( 'data-meta-key' );\n        var frame = null;\n\n        // In the block editor, also store the value in the page itself so \"Save\" picks up the change.\n        function setValue( id ) {\n            input.value = id ? String( id ) : '';\n            remove.hidden = ! id;\n            if ( wp.data && wp.data.select( 'core/editor' ) ) {\n                var meta = wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' );\n                if ( meta && Object.prototype.hasOwnProperty.call( meta, metaKey ) ) {\n                    var change = {};\n                    change[ metaKey ] = id || 0;\n                    wp.data.dispatch( 'core/editor' ).editPost( { meta: change } );\n                }\n            }\n        }\n\n        box.querySelector( '.wpide-page-logo__choose' ).addEventListener( 'click', function () {\n            if ( ! frame ) {\n                frame = wp.media( {\n                    title: 'Choose a logo for this page',\n                    button: { text: 'Use as page logo' },\n                    library: { type: 'image' },\n                    multiple: false\n                } );\n\n                frame.on( 'select', function () {\n                    var image = frame.state().get( 'selection' ).first().toJSON();\n                    var img = document.createElement( 'img' );\n                    img.src = image.sizes && image.sizes.medium ? image.sizes.medium.url : image.url;\n                    img.alt = '';\n                    img.style.maxWidth = '100%';\n                    img.style.height = 'auto';\n                    preview.textContent = '';\n                    preview.appendChild( img );\n                    setValue( image.id );\n                } );\n            }\n            frame.open();\n        } );\n\n        remove.addEventListener( 'click', function () {\n            preview.textContent = '';\n            setValue( 0 );\n        } );\n    } )();\n    </script>\n    <?php\n};\n\nadd_action( 'admin_footer-post.php', $wpide_page_logo_script );\nadd_action( 'admin_footer-post-new.php', $wpide_page_logo_script );\n\n// Classic editor (and the meta box form the block editor sends after saving).\nadd_action( 'save_post', function ( $post_id, $post ) use ( $wpide_page_logo_post_types, $wpide_page_logo_meta_key ) {\n    if ( ! isset( $_POST['wpide_page_logo_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['wpide_page_logo_nonce'] ) ), 'wpide_page_logo_save' ) ) {\n        return;\n    }\n\n    if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_revision( $post_id ) || ! in_array( $post->post_type, $wpide_page_logo_post_types, true ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $logo_id = isset( $_POST['wpide_page_logo_id'] ) ? absint( $_POST['wpide_page_logo_id'] ) : 0;\n\n    if ( $logo_id && wp_attachment_is_image( $logo_id ) ) {\n        update_post_meta( $post_id, $wpide_page_logo_meta_key, $logo_id );\n    } else {\n        delete_post_meta( $post_id, $wpide_page_logo_meta_key );\n    }\n}, 10, 2 );\n\n// Swap the logo on the front end. This covers get_custom_logo(), the_custom_logo() and the Site Logo block.\nadd_filter( 'theme_mod_custom_logo', function ( $logo_id ) use ( $wpide_page_logo_post_types, $wpide_page_logo_meta_key ) {\n    if ( is_admin() || ! did_action( 'wp' ) || ! is_singular( $wpide_page_logo_post_types ) ) {\n        return $logo_id;\n    }\n\n    $page_logo = (int) get_post_meta( get_queried_object_id(), $wpide_page_logo_meta_key, true );\n\n    return ( $page_logo && wp_attachment_is_image( $page_logo ) ) ? $page_logo : $logo_id;\n}, 20 );",
            "requires": [],
            "notes": "Add more post types at the top, for example array( 'page', 'post' ). Remove the page logo to go back to the site logo. The logo is shown at the size your theme or Site Logo block uses."
        },
        {
            "id": "ms-sites-last-updated-column",
            "version": 1,
            "title": "\"Last Updated\" Column in the Network Sites List",
            "description": "Adds a sortable-looking \"Last updated\" column to Network Admin > Sites so you can spot sites nobody has touched in months.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_last_updated'] = __( 'Last updated' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_last_updated' !== $column ) {\n        return;\n    }\n\n    $site = get_site( $blog_id );\n\n    if ( ! $site || '0000-00-00 00:00:00' === $site->last_updated ) {\n        echo '&mdash;';\n        return;\n    }\n\n    $time = strtotime( $site->last_updated . ' UTC' );\n\n    printf(\n        '<span title=\"%1$s\">%2$s</span>',\n        esc_attr( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $time ) ),\n        /* translators: %s: human time difference */\n        esc_html( sprintf( __( '%s ago' ), human_time_diff( $time ) ) )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "The date comes from the site's last_updated field, which WordPress refreshes when content is published."
        },
        {
            "id": "ms-sites-post-count-column",
            "version": 1,
            "title": "Published Posts Column in the Network Sites List",
            "description": "Shows how many published posts each site has in Network Admin > Sites, cached for twelve hours so the list stays fast.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "columns",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_post_type = 'post'; // Post type to count.\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_post_count'] = __( 'Posts' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) use ( $wpide_post_type ) {\n    if ( 'wpide_post_count' !== $column ) {\n        return;\n    }\n\n    $key   = 'wpide_post_count_' . (int) $blog_id . '_' . $wpide_post_type;\n    $count = get_site_transient( $key );\n\n    if ( false === $count ) {\n        switch_to_blog( $blog_id );\n        $counts = wp_count_posts( $wpide_post_type );\n        $count  = isset( $counts->publish ) ? (int) $counts->publish : 0;\n        restore_current_blog();\n        set_site_transient( $key, $count, 12 * HOUR_IN_SECONDS );\n    }\n\n    echo esc_html( number_format_i18n( (int) $count ) );\n}, 10, 2 );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) use ( $wpide_post_type ) {\n    if ( $post->post_type === $wpide_post_type && ( 'publish' === $new_status || 'publish' === $old_status ) ) {\n        delete_site_transient( 'wpide_post_count_' . get_current_blog_id() . '_' . $wpide_post_type );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Change $wpide_post_type to count pages or a custom post type instead."
        },
        {
            "id": "ms-sites-id-column",
            "version": 1,
            "title": "Site ID Column in the Network Sites List",
            "description": "Adds a narrow \"ID\" column to Network Admin > Sites, handy when a plugin, WP-CLI command or support request asks for a blog ID.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "columns",
                "id"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    return array_slice( $columns, 0, 1, true ) + array( 'wpide_site_id' => 'ID' ) + array_slice( $columns, 1, null, true );\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_site_id' === $column ) {\n        echo (int) $blog_id;\n    }\n}, 10, 2 );\n\nadd_action( 'admin_head-sites.php', function () {\n    echo '<style>.column-wpide_site_id{width:4em}</style>';\n} );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-sites-active-theme-column",
            "version": 1,
            "title": "Active Theme Column in the Network Sites List",
            "description": "Shows the theme each site uses in Network Admin > Sites, so you know which sites a theme update or removal affects.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "themes",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_theme'] = __( 'Theme' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_theme' !== $column ) {\n        return;\n    }\n\n    $stylesheet = get_blog_option( $blog_id, 'stylesheet' );\n    $theme      = $stylesheet ? wp_get_theme( $stylesheet ) : null;\n\n    if ( ! $theme || ! $theme->exists() ) {\n        echo esc_html( $stylesheet ? $stylesheet : '—' );\n        return;\n    }\n\n    echo esc_html( $theme->get( 'Name' ) );\n\n    if ( $theme->parent() ) {\n        echo '<br><small>' . esc_html( sprintf( 'child of %s', $theme->parent()->get( 'Name' ) ) ) . '</small>';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Reads the stylesheet option of each site directly instead of switching blogs, so it costs one cached query per row."
        },
        {
            "id": "ms-sites-storage-column",
            "version": 1,
            "title": "Storage Used Column in the Network Sites List",
            "description": "Shows how much upload space each site uses next to its quota in Network Admin > Sites, highlighting sites that are close to the limit.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "storage",
                "quota"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_warn_at = 80; // Highlight sites using more than this percentage of their quota.\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_storage'] = __( 'Storage' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) use ( $wpide_warn_at ) {\n    if ( 'wpide_storage' !== $column ) {\n        return;\n    }\n\n    switch_to_blog( $blog_id );\n    $used    = (float) get_space_used();\n    $allowed = (float) get_space_allowed();\n    restore_current_blog();\n\n    if ( get_site_option( 'upload_space_check_disabled' ) || $allowed <= 0 ) {\n        echo esc_html( size_format( (int) ( $used * MB_IN_BYTES ), 1 ) );\n        return;\n    }\n\n    $percent = min( 100, round( $used / $allowed * 100 ) );\n    $style   = $percent >= $wpide_warn_at ? ' style=\"color:#b32d2e;font-weight:600\"' : '';\n\n    printf(\n        '<span%1$s>%2$s / %3$s (%4$d%%)</span>',\n        $style, // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.\n        esc_html( size_format( (int) ( $used * MB_IN_BYTES ), 1 ) ),\n        esc_html( size_format( (int) ( $allowed * MB_IN_BYTES ) ) ),\n        (int) $percent\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "Disk usage is computed by WordPress and cached per site; sites with uploads unlimited show only the used size."
        },
        {
            "id": "ms-sites-admin-email-column",
            "version": 1,
            "title": "Admin Email Column in the Network Sites List",
            "description": "Lists each site's administration email address in Network Admin > Sites so you can contact site owners without opening every site.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "email",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_admin_email'] = __( 'Admin email' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_admin_email' !== $column ) {\n        return;\n    }\n\n    $email = get_blog_option( $blog_id, 'admin_email' );\n\n    if ( is_email( $email ) ) {\n        printf( '<a href=\"mailto:%1$s\">%2$s</a>', esc_attr( $email ), esc_html( $email ) );\n    } else {\n        echo '&mdash;';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-sites-active-plugins-column",
            "version": 1,
            "title": "Active Plugins Column in the Network Sites List",
            "description": "Shows how many plugins each site has activated on its own, with the plugin names in a tooltip, in Network Admin > Sites.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "plugins",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_plugins'] = __( 'Plugins' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_plugins' !== $column ) {\n        return;\n    }\n\n    $active = array_filter( (array) get_blog_option( $blog_id, 'active_plugins', array() ) );\n\n    if ( ! $active ) {\n        echo '0';\n        return;\n    }\n\n    $names = array_map( function ( $file ) {\n        return dirname( $file ) === '.' ? basename( $file, '.php' ) : dirname( $file );\n    }, $active );\n\n    printf( '<span title=\"%1$s\">%2$d</span>', esc_attr( implode( ', ', $names ) ), count( $active ) );\n}, 10, 2 );",
            "requires": [],
            "notes": "Network-activated plugins are not counted because they run on every site."
        },
        {
            "id": "ms-sites-visibility-column",
            "version": 1,
            "title": "Search Engine Visibility Column for Network Sites",
            "description": "Adds a column to Network Admin > Sites that flags sites set to \"Discourage search engines\", a common leftover after a site launches.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "seo",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_public'] = __( 'Search engines' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_public' !== $column ) {\n        return;\n    }\n\n    $site = get_site( $blog_id );\n\n    if ( $site && '1' === (string) $site->public ) {\n        echo '<span style=\"color:#008a20\">' . esc_html__( 'Visible' ) . '</span>';\n    } else {\n        echo '<span style=\"color:#b32d2e;font-weight:600\">' . esc_html__( 'Discouraged' ) . '</span>';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-sites-extra-row-actions",
            "version": 1,
            "title": "Extra Quick Links on Network Sites Rows",
            "description": "Adds Plugins, Users and Settings links to each row's hover actions in Network Admin > Sites, so you can jump straight to the screen you need on that site.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "row-actions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_links = array( // Label => wp-admin path.\n    'Plugins'  => 'plugins.php',\n    'Users'    => 'users.php',\n    'Settings' => 'options-general.php',\n);\n\nadd_filter( 'manage_sites_action_links', function ( $actions, $blog_id ) use ( $wpide_links ) {\n    foreach ( $wpide_links as $label => $path ) {\n        $actions[ 'wpide_' . sanitize_key( $label ) ] = sprintf(\n            '<a href=\"%1$s\">%2$s</a>',\n            esc_url( get_admin_url( $blog_id, $path ) ),\n            esc_html( $label )\n        );\n    }\n\n    return $actions;\n}, 10, 2 );",
            "requires": [],
            "notes": "Edit $wpide_links to add or remove screens; paths are relative to each site's wp-admin."
        },
        {
            "id": "ms-users-site-count-column",
            "version": 1,
            "title": "Site Count Column in the Network Users List",
            "description": "Adds a column to Network Admin > Users showing how many sites each person belongs to, with the site names in a tooltip.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "users",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_users_columns', function ( $columns ) {\n    $columns['wpide_site_count'] = __( 'Site count' );\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_site_count' !== $column ) {\n        return $output;\n    }\n\n    $blogs = get_blogs_of_user( $user_id );\n    $names = wp_list_pluck( $blogs, 'blogname' );\n\n    return sprintf( '<span title=\"%1$s\">%2$d</span>', esc_attr( implode( ', ', $names ) ), count( $blogs ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-users-last-login-column",
            "version": 1,
            "title": "Last Login Column Across the Network",
            "description": "Records when each user last signed in anywhere on the network and shows it in Network Admin > Users, making inactive accounts easy to find.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "users",
                "login",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_login', function ( $login, $user ) {\n    update_user_meta( $user->ID, 'wpide_network_last_login', time() );\n}, 10, 2 );\n\nif ( ! is_admin() ) {\n    return;\n}\n\nadd_filter( 'wpmu_users_columns', function ( $columns ) {\n    $columns['wpide_last_login'] = __( 'Last login' );\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_last_login' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_network_last_login', true );\n\n    if ( ! $time ) {\n        return '&mdash;';\n    }\n\n    return sprintf(\n        '<span title=\"%1$s\">%2$s</span>',\n        esc_attr( wp_date( 'Y-m-d H:i', $time ) ),\n        /* translators: %s: human time difference */\n        esc_html( sprintf( __( '%s ago' ), human_time_diff( $time ) ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "The column fills in as people log in after you activate the snippet; earlier logins are not known."
        },
        {
            "id": "ms-users-sites-on-profile",
            "version": 1,
            "title": "List a User's Sites on Their Profile",
            "description": "Shows super admins every site a user belongs to, with their role on each, at the bottom of the user's profile screen.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "users",
                "profile",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_user_sites = function ( $user ) {\n    if ( ! is_super_admin() ) {\n        return;\n    }\n\n    $blogs = get_blogs_of_user( $user->ID );\n    ?>\n    <h2><?php esc_html_e( 'Sites' ); ?></h2>\n    <table class=\"widefat striped\" style=\"max-width:720px\">\n        <thead><tr><th><?php esc_html_e( 'Site' ); ?></th><th><?php esc_html_e( 'Role' ); ?></th></tr></thead>\n        <tbody>\n        <?php if ( ! $blogs ) : ?>\n            <tr><td colspan=\"2\"><?php esc_html_e( 'This user does not belong to any site.' ); ?></td></tr>\n        <?php endif; ?>\n        <?php foreach ( $blogs as $blog ) :\n            $caps  = get_user_meta( $user->ID, $GLOBALS['wpdb']->get_blog_prefix( $blog->userblog_id ) . 'capabilities', true );\n            $roles = is_array( $caps ) ? implode( ', ', array_keys( array_filter( $caps ) ) ) : '';\n            ?>\n            <tr>\n                <td><a href=\"<?php echo esc_url( get_admin_url( $blog->userblog_id ) ); ?>\"><?php echo esc_html( $blog->blogname ); ?></a><br><small><?php echo esc_html( $blog->siteurl ); ?></small></td>\n                <td><?php echo esc_html( $roles ? $roles : '—' ); ?></td>\n            </tr>\n        <?php endforeach; ?>\n        </tbody>\n    </table>\n    <?php\n};\n\nadd_action( 'show_user_profile', $wpide_user_sites );\nadd_action( 'edit_user_profile', $wpide_user_sites );",
            "requires": [],
            "notes": "Only super admins see the list."
        },
        {
            "id": "ms-users-extra-row-actions",
            "version": 1,
            "title": "Primary Site and Profile Links on Network Users Rows",
            "description": "Adds two hover links under each user in Network Admin > Users, one to the dashboard of their first site (with their site count) and one straight to their profile.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "users",
                "row-actions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'ms_user_row_actions', function ( $actions, $user ) {\n    $blogs = get_blogs_of_user( $user->ID );\n\n    if ( $blogs ) {\n        $first = reset( $blogs );\n        $actions['wpide_dashboard'] = sprintf(\n            '<a href=\"%1$s\">%2$s</a>',\n            esc_url( get_admin_url( $first->userblog_id ) ),\n            /* translators: %d: number of sites */\n            esc_html( sprintf( _n( 'Primary site (%d site)', 'Primary site (%d sites)', count( $blogs ) ), count( $blogs ) ) )\n        );\n    }\n\n    $actions['wpide_profile'] = sprintf( '<a href=\"%1$s\">%2$s</a>', esc_url( network_admin_url( 'user-edit.php?user_id=' . (int) $user->ID ) ), esc_html__( 'Profile' ) );\n\n    return $actions;\n}, 10, 2 );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-new-site-default-settings",
            "version": 1,
            "title": "Default Settings for New Network Sites",
            "description": "Applies your preferred timezone, date and time formats, week start, permalink structure and language to every site created on the network.",
            "category": "multisite",
            "tags": [
                "new-site",
                "defaults",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_defaults = array( // Option name => value applied to each new site.\n    'timezone_string'     => 'Europe/London',\n    'date_format'         => 'j F Y',\n    'time_format'         => 'H:i',\n    'start_of_week'       => 1,\n    'permalink_structure' => '/%postname%/',\n    'WPLANG'              => '', // Empty = English (US), or a locale such as fr_FR.\n);\n\nadd_action( 'wp_initialize_site', function ( $site ) use ( $wpide_defaults ) {\n    switch_to_blog( $site->blog_id );\n\n    foreach ( $wpide_defaults as $option => $value ) {\n        update_option( $option, $value );\n    }\n\n    if ( isset( $wpide_defaults['permalink_structure'] ) ) {\n        delete_option( 'rewrite_rules' ); // Rebuilt on the site's first request.\n    }\n\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "Edit $wpide_defaults; any option name works. Existing sites are not changed."
        },
        {
            "id": "ms-new-site-remove-sample-content",
            "version": 1,
            "title": "Remove Sample Content From New Network Sites",
            "description": "Deletes the \"Hello world!\" post, its sample comment and the \"Sample Page\" from each newly created site so site owners start with a clean slate.",
            "category": "multisite",
            "tags": [
                "new-site",
                "defaults",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_initialize_site', function ( $site ) {\n    switch_to_blog( $site->blog_id );\n\n    foreach ( array( 'hello-world' => 'post', 'sample-page' => 'page' ) as $slug => $type ) {\n        $found = get_posts( array(\n            'name'           => $slug,\n            'post_type'      => $type,\n            'post_status'    => 'any',\n            'posts_per_page' => 1,\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) );\n\n        if ( $found ) {\n            wp_delete_post( $found[0], true ); // Also removes its comments.\n        }\n    }\n\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "Only runs once, right after a site is created; it matches the sample items by their default slugs."
        },
        {
            "id": "ms-new-site-default-theme",
            "version": 1,
            "title": "Activate a Default Theme on New Network Sites",
            "description": "Switches every newly created site to the theme you choose instead of the latest default theme.",
            "category": "multisite",
            "tags": [
                "new-site",
                "themes",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_theme = 'twentytwentyfour'; // Theme folder (stylesheet) name.\n\nadd_action( 'wp_initialize_site', function ( $site ) use ( $wpide_theme ) {\n    $theme = wp_get_theme( $wpide_theme );\n\n    if ( ! $theme->exists() ) {\n        return;\n    }\n\n    switch_to_blog( $site->blog_id );\n    switch_theme( $theme->get_stylesheet() );\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "Use the theme's folder name; the theme must be installed. Network-enable it too if site owners should be able to switch back to it."
        },
        {
            "id": "ms-new-site-default-plugins",
            "version": 1,
            "title": "Activate Default Plugins on New Network Sites",
            "description": "Activates a list of plugins on each new site as it is created, so every site starts with the same toolkit without network-activating it everywhere.",
            "category": "multisite",
            "tags": [
                "new-site",
                "plugins",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_plugins = array( // Plugin basenames, e.g. 'akismet/akismet.php'.\n);\n\nadd_action( 'wp_initialize_site', function ( $site ) use ( $wpide_plugins ) {\n    if ( ! $wpide_plugins ) {\n        return;\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/plugin.php';\n\n    switch_to_blog( $site->blog_id );\n\n    foreach ( $wpide_plugins as $plugin ) {\n        if ( file_exists( WP_PLUGIN_DIR . '/' . $plugin ) && ! is_plugin_active_for_network( $plugin ) ) {\n            activate_plugin( $plugin, '', false, true );\n        }\n    }\n\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "List plugin paths as folder/main-file.php. Plugins that are missing or already network-active are skipped."
        },
        {
            "id": "ms-new-site-starter-pages",
            "version": 1,
            "title": "Create Starter Pages on New Network Sites",
            "description": "Creates a set of pages such as About, Contact and Privacy on every new site, and can set one of them as the static front page.",
            "category": "multisite",
            "tags": [
                "new-site",
                "pages",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_pages = array( // Title => content.\n    'Home'    => '<!-- wp:paragraph --><p>Welcome to our new site.</p><!-- /wp:paragraph -->',\n    'About'   => '<!-- wp:paragraph --><p>Tell visitors who you are.</p><!-- /wp:paragraph -->',\n    'Contact' => '<!-- wp:paragraph --><p>How to reach us.</p><!-- /wp:paragraph -->',\n);\n$wpide_front_page = 'Home'; // One of the titles above, or ''.\n\nadd_action( 'wp_initialize_site', function ( $site, $args ) use ( $wpide_pages, $wpide_front_page ) {\n    switch_to_blog( $site->blog_id );\n\n    $author = ! empty( $args['user_id'] ) ? (int) $args['user_id'] : get_current_user_id();\n\n    foreach ( $wpide_pages as $title => $content ) {\n        $id = wp_insert_post( array(\n            'post_type'    => 'page',\n            'post_status'  => 'publish',\n            'post_title'   => $title,\n            'post_content' => $content,\n            'post_author'  => $author,\n        ) );\n\n        if ( $id && ! is_wp_error( $id ) && $title === $wpide_front_page ) {\n            update_option( 'show_on_front', 'page' );\n            update_option( 'page_on_front', $id );\n        }\n    }\n\n    restore_current_blog();\n}, 210, 2 );",
            "requires": [],
            "notes": "Edit $wpide_pages; set $wpide_front_page to one of the titles or leave it empty to keep the latest-posts homepage."
        },
        {
            "id": "ms-new-site-discourage-search",
            "version": 1,
            "title": "Hide New Network Sites From Search Engines",
            "description": "Sets every newly created site to \"Discourage search engines\" so half-built sites are not indexed; site owners switch it off when they launch.",
            "category": "multisite",
            "tags": [
                "new-site",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_initialize_site', function ( $site ) {\n    update_blog_status( $site->blog_id, 'public', 0 );\n\n    switch_to_blog( $site->blog_id );\n    update_option( 'blog_public', 0 );\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "Pair it with the \"Search engines\" column snippet to see which sites still need switching on."
        },
        {
            "id": "ms-new-site-add-team-user",
            "version": 1,
            "title": "Add a Team Member to Every New Site",
            "description": "Adds a chosen user, such as your support or editorial account, to each newly created site with the role you pick.",
            "category": "multisite",
            "tags": [
                "new-site",
                "users",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_login = 'support';  // Existing username to add.\n$wpide_role  = 'editor';   // Role on the new site.\n\nadd_action( 'wp_initialize_site', function ( $site ) use ( $wpide_login, $wpide_role ) {\n    $user = get_user_by( 'login', $wpide_login );\n\n    if ( $user && ! is_user_member_of_blog( $user->ID, $site->blog_id ) ) {\n        add_user_to_blog( $site->blog_id, $user->ID, $wpide_role );\n    }\n}, 200 );",
            "requires": [],
            "notes": "Set $wpide_login to an existing username and $wpide_role to any role that exists on new sites."
        },
        {
            "id": "ms-new-site-close-comments",
            "version": 1,
            "title": "Turn Off Comments on New Network Sites",
            "description": "Creates every new site with comments, pingbacks and trackbacks switched off by default, for networks of brochure or business sites.",
            "category": "multisite",
            "tags": [
                "new-site",
                "comments",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_initialize_site', function ( $site ) {\n    switch_to_blog( $site->blog_id );\n    update_option( 'default_comment_status', 'closed' );\n    update_option( 'default_ping_status', 'closed' );\n    update_option( 'default_pingback_flag', 0 );\n    restore_current_blog();\n}, 200 );",
            "requires": [],
            "notes": "Site owners can still turn comments on in Settings > Discussion."
        },
        {
            "id": "ms-new-site-clone-template-options",
            "version": 1,
            "title": "Copy Settings From a Template Site to New Sites",
            "description": "Copies chosen options, such as Customizer settings, the site icon and reading settings, from a template site onto every new site.",
            "category": "multisite",
            "tags": [
                "new-site",
                "template",
                "clone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_template_id = 0; // Blog ID of the template site (0 = off until you set it).\n$wpide_options     = array( 'blogdescription', 'posts_per_page', 'date_format', 'time_format', 'timezone_string', 'permalink_structure', 'default_comment_status' );\n\nadd_action( 'wp_initialize_site', function ( $site ) use ( $wpide_template_id, $wpide_options ) {\n    if ( ! $wpide_template_id || (int) $site->blog_id === (int) $wpide_template_id || ! get_site( $wpide_template_id ) ) {\n        return;\n    }\n\n    switch_to_blog( $wpide_template_id );\n    $values     = array();\n    foreach ( $wpide_options as $option ) {\n        $values[ $option ] = get_option( $option );\n    }\n    $stylesheet = get_option( 'stylesheet' );\n    $template   = get_option( 'template' );\n    $mods       = get_option( 'theme_mods_' . $stylesheet );\n    restore_current_blog();\n\n    switch_to_blog( $site->blog_id );\n    foreach ( $values as $option => $value ) {\n        if ( false !== $value ) {\n            update_option( $option, $value );\n        }\n    }\n    if ( $stylesheet && wp_get_theme( $stylesheet )->exists() ) {\n        update_option( 'template', $template );\n        update_option( 'stylesheet', $stylesheet );\n        if ( is_array( $mods ) ) {\n            unset( $mods['custom_logo'], $mods['nav_menu_locations'], $mods['custom_css_post_id'] );\n            update_option( 'theme_mods_' . $stylesheet, $mods );\n        }\n    }\n    delete_option( 'rewrite_rules' );\n    restore_current_blog();\n}, 220 );",
            "requires": [],
            "notes": "Set $wpide_template_id to the blog ID of your template site. Options that point to posts or media (like page_on_front) are not copied because IDs differ between sites."
        },
        {
            "id": "ms-new-site-notify-email",
            "version": 1,
            "title": "Email a Custom Address When a Site Is Created",
            "description": "Sends a short notice with the new site's name, address and creator to an address you choose, in addition to WordPress's own network admin email.",
            "category": "multisite",
            "tags": [
                "new-site",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_notify_to = 'team@example.com'; // Recipient(s).\n\nadd_action( 'wp_initialize_site', function ( $site, $args ) use ( $wpide_notify_to ) {\n    $user  = ! empty( $args['user_id'] ) ? get_userdata( $args['user_id'] ) : wp_get_current_user();\n    $title = ! empty( $args['title'] ) ? $args['title'] : $site->domain . $site->path;\n    $url   = set_url_scheme( 'http://' . $site->domain . $site->path );\n\n    $body = sprintf(\n        \"A new site was created on %1\\$s.\\n\\nName: %2\\$s\\nAddress: %3\\$s\\nCreated by: %4\\$s\\nManage: %5\\$s\\n\",\n        get_network()->site_name,\n        $title,\n        $url,\n        $user && $user->exists() ? $user->user_login . ' <' . $user->user_email . '>' : 'unknown',\n        network_admin_url( 'site-info.php?id=' . (int) $site->blog_id )\n    );\n\n    wp_mail( $wpide_notify_to, sprintf( '[%s] New site: %s', get_network()->site_name, $title ), $body );\n}, 300, 2 );",
            "requires": [],
            "notes": "Set $wpide_notify_to; separate several addresses with commas."
        },
        {
            "id": "ms-stop-new-site-admin-email",
            "version": 1,
            "title": "Stop New Site and Signup Emails to the Network Admin",
            "description": "Stops WordPress from emailing the network admin every time a site is created or someone activates a new site or account, useful on networks that create many sites automatically.",
            "category": "multisite",
            "tags": [
                "new-site",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n// Sites created from Network Admin or WP-CLI (WordPress 5.6+).\nadd_filter( 'send_new_site_email', '__return_false' );\n\n// Sites and users activated through wp-signup.php.\nadd_filter( 'site_option_registrationnotification', function () {\n    return 'no';\n} );",
            "requires": [],
            "notes": "Site owners still get their own welcome emails; the \"Registration notification\" network setting is overridden while this is active."
        },
        {
            "id": "ms-disable-welcome-emails",
            "version": 1,
            "title": "Disable Welcome Emails for New Sites and Users",
            "description": "Stops the \"Your new site\" and \"New user account\" welcome emails WordPress multisite sends after activation, for networks that send their own onboarding.",
            "category": "multisite",
            "tags": [
                "email",
                "welcome",
                "signup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_welcome_notification', '__return_false' );\nadd_filter( 'wpmu_welcome_user_notification', '__return_false' );",
            "requires": [],
            "notes": "Activation emails sent before an account exists are not affected."
        },
        {
            "id": "ms-custom-welcome-email",
            "version": 1,
            "title": "Custom Welcome Email Text for New Network Sites",
            "description": "Replaces the network's welcome email for new sites with your own wording while keeping the site address, login and dashboard links filled in.",
            "category": "multisite",
            "tags": [
                "email",
                "welcome",
                "new-site"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'update_welcome_email', function () {\n    return \"Hi USERNAME,\\n\\n\"\n        . \"Your new site on SITE_NAME is ready.\\n\\n\"\n        . \"Visit it: BLOG_URL\\n\"\n        . \"Log in: LOGINLINK\\n\\n\"\n        . \"If you need a hand getting started, just reply to this email.\\n\\n\"\n        . \"--The SITE_NAME team\";\n} );\n\nadd_filter( 'update_welcome_subject', function () {\n    return 'Your new site on ' . get_network()->site_name . ' is ready';\n} );",
            "requires": [],
            "notes": "Available placeholders: SITE_NAME, BLOG_URL, USERNAME, LOGINLINK. WordPress replaces them before sending; the password is no longer included."
        },
        {
            "id": "ms-limit-sites-per-user",
            "version": 1,
            "title": "Limit How Many Sites Each User Can Create",
            "description": "Stops a user from signing up for another site once they already own the number you set; super admins are never limited.",
            "category": "multisite",
            "tags": [
                "signup",
                "limits",
                "sites"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_max_sites = 1; // Sites a user may administer.\n\nadd_filter( 'wpmu_validate_blog_signup', function ( $result ) use ( $wpide_max_sites ) {\n    $user = isset( $result['user'] ) && $result['user'] instanceof WP_User ? $result['user'] : wp_get_current_user();\n\n    if ( ! $user || ! $user->exists() || is_super_admin( $user->ID ) ) {\n        return $result;\n    }\n\n    $owned = 0;\n    foreach ( get_blogs_of_user( $user->ID ) as $blog ) {\n        $caps = get_user_meta( $user->ID, $GLOBALS['wpdb']->get_blog_prefix( $blog->userblog_id ) . 'capabilities', true );\n        if ( is_array( $caps ) && ! empty( $caps['administrator'] ) ) {\n            $owned++;\n        }\n    }\n\n    if ( $owned >= $wpide_max_sites ) {\n        $result['errors']->add( 'blogname', sprintf( 'You can have up to %d site(s) on this network.', $wpide_max_sites ) );\n    }\n\n    return $result;\n} );",
            "requires": [],
            "notes": "Counts sites where the user is an administrator. Applies to the front-end signup form and the \"Add new site\" screen for non-super-admins."
        },
        {
            "id": "ms-reserved-site-names",
            "version": 1,
            "title": "Reserve Site Names on the Network",
            "description": "Blocks signups for site names you want to keep, such as shop, support or your brand names, on top of WordPress's built-in reserved list.",
            "category": "multisite",
            "tags": [
                "signup",
                "site-names",
                "reserved"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_reserved = array( 'shop', 'store', 'support', 'help', 'admin', 'login', 'api', 'docs', 'status', 'billing' ); // Lower case.\n\nadd_filter( 'subdirectory_reserved_names', function ( $names ) use ( $wpide_reserved ) {\n    return array_merge( $names, $wpide_reserved );\n} );\n\nadd_filter( 'wpmu_validate_blog_signup', function ( $result ) use ( $wpide_reserved ) {\n    if ( ! is_super_admin() && in_array( strtolower( (string) $result['blogname'] ), $wpide_reserved, true ) ) {\n        $result['errors']->add( 'blogname', 'That site name is reserved. Please choose another one.' );\n    }\n\n    return $result;\n} );",
            "requires": [],
            "notes": "Names are compared in lower case; add as many as you need."
        },
        {
            "id": "ms-min-site-name-length",
            "version": 1,
            "title": "Minimum Site Name Length for Signups",
            "description": "Requires new site names to be at least a set number of characters (WordPress only asks for four), cutting down on throwaway and squatted names.",
            "category": "multisite",
            "tags": [
                "signup",
                "site-names",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_min_length = 6; // Minimum characters.\n\nadd_filter( 'wpmu_validate_blog_signup', function ( $result ) use ( $wpide_min_length ) {\n    if ( ! is_super_admin() && strlen( (string) $result['blogname'] ) < $wpide_min_length ) {\n        $result['errors']->add( 'blogname', sprintf( 'Site names must be at least %d characters long.', $wpide_min_length ) );\n    }\n\n    return $result;\n} );",
            "requires": [],
            "notes": "Change $wpide_min_length. Super admins creating sites are not restricted."
        },
        {
            "id": "ms-signup-allowed-email-domains",
            "version": 1,
            "title": "Allow Network Signups Only From Certain Email Domains",
            "description": "Rejects user signups on the network unless the email address belongs to one of your domains, such as a school or company network.",
            "category": "multisite",
            "tags": [
                "signup",
                "email",
                "domains"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_domains = array( 'example.com', 'example.org' ); // Allowed email domains.\n\nadd_filter( 'wpmu_validate_user_signup', function ( $result ) use ( $wpide_domains ) {\n    $email  = isset( $result['user_email'] ) ? strtolower( $result['user_email'] ) : '';\n    $domain = substr( strrchr( $email, '@' ), 1 );\n\n    if ( $email && ! in_array( $domain, array_map( 'strtolower', $wpide_domains ), true ) ) {\n        $result['errors']->add( 'user_email', 'Please sign up with your organisation email address.' );\n    }\n\n    return $result;\n} );",
            "requires": [],
            "notes": "This works alongside the \"Limited Email Registrations\" field in Network Settings; use either one."
        },
        {
            "id": "ms-signup-closed-redirect",
            "version": 1,
            "title": "Redirect wp-signup.php When Registration Is Closed",
            "description": "Sends visitors who land on wp-signup.php to a page you choose (or the homepage) when network registration is turned off, instead of showing a bare \"Registration has been disabled\" screen.",
            "category": "multisite",
            "tags": [
                "signup",
                "redirect",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_target = '/'; // Path on the main site or full URL.\n\nadd_action( 'before_signup_header', function () use ( $wpide_target ) {\n    if ( 'none' !== get_site_option( 'registration', 'none' ) || is_super_admin() ) {\n        return;\n    }\n\n    $url = wp_http_validate_url( $wpide_target ) ? $wpide_target : network_home_url( $wpide_target );\n    wp_safe_redirect( $url, 302 );\n    exit;\n} );\n\nadd_filter( 'allowed_redirect_hosts', function ( $hosts ) use ( $wpide_target ) {\n    $host = wp_parse_url( $wpide_target, PHP_URL_HOST );\n    if ( $host ) {\n        $hosts[] = $host;\n    }\n    return $hosts;\n} );",
            "requires": [],
            "notes": "Set $wpide_target to a full URL or a path on the main site."
        },
        {
            "id": "ms-network-announcement",
            "version": 1,
            "title": "Network-Wide Dashboard Announcement",
            "description": "Adds an \"Announcement\" box to Network Admin > Settings; whatever a super admin writes there appears as a dismissible notice on every site's dashboard.",
            "category": "multisite",
            "tags": [
                "network-settings",
                "notices",
                "announcement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wpmu_options', function () {\n    ?>\n    <h2><?php esc_html_e( 'Network announcement' ); ?></h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\"><label for=\"wpide_network_announcement\"><?php esc_html_e( 'Announcement' ); ?></label></th>\n            <td>\n                <textarea name=\"wpide_network_announcement\" id=\"wpide_network_announcement\" rows=\"3\" class=\"large-text\"><?php echo esc_textarea( get_site_option( 'wpide_network_announcement', '' ) ); ?></textarea>\n                <p class=\"description\"><?php esc_html_e( 'Shown on the dashboard of every site to users who can edit posts.' ); ?></p>\n            </td>\n        </tr>\n    </table>\n    <?php\n} );\n\n// Network Settings already verifies its own nonce and capability before this runs.\nadd_action( 'update_wpmu_options', function () {\n    if ( isset( $_POST['wpide_network_announcement'] ) && current_user_can( 'manage_network_options' ) ) {\n        update_site_option( 'wpide_network_announcement', wp_kses_post( wp_unslash( $_POST['wpide_network_announcement'] ) ) );\n    }\n} );\n\nadd_action( 'admin_notices', function () {\n    $message = get_site_option( 'wpide_network_announcement', '' );\n    $screen  = get_current_screen();\n\n    if ( '' === trim( $message ) || ! current_user_can( 'edit_posts' ) || ! $screen || 'dashboard' !== $screen->id ) {\n        return;\n    }\n\n    echo '<div class=\"notice notice-info is-dismissible\"><p>' . wp_kses_post( $message ) . '</p></div>';\n} );",
            "requires": [],
            "notes": "Basic HTML (links, bold) is allowed. Clear the field to remove the notice."
        },
        {
            "id": "ms-network-tracking-code",
            "version": 1,
            "title": "One Analytics Tag for Every Site on the Network",
            "description": "Adds a Google Analytics 4 measurement ID field to Network Admin > Settings and prints the tag on every site, so new sites are tracked automatically.",
            "category": "multisite",
            "tags": [
                "network-settings",
                "analytics",
                "ga4"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wpmu_options', function () {\n    ?>\n    <h2><?php esc_html_e( 'Network analytics' ); ?></h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\"><label for=\"wpide_network_ga4\"><?php esc_html_e( 'GA4 measurement ID' ); ?></label></th>\n            <td><input type=\"text\" name=\"wpide_network_ga4\" id=\"wpide_network_ga4\" class=\"regular-text\" placeholder=\"G-XXXXXXXXXX\" value=\"<?php echo esc_attr( get_site_option( 'wpide_network_ga4', '' ) ); ?>\"></td>\n        </tr>\n    </table>\n    <?php\n} );\n\nadd_action( 'update_wpmu_options', function () {\n    if ( isset( $_POST['wpide_network_ga4'] ) && current_user_can( 'manage_network_options' ) ) {\n        $id = strtoupper( sanitize_text_field( wp_unslash( $_POST['wpide_network_ga4'] ) ) );\n        update_site_option( 'wpide_network_ga4', preg_match( '/^G-[A-Z0-9]+$/', $id ) ? $id : '' );\n    }\n} );\n\nadd_action( 'wp_head', function () {\n    $id = get_site_option( 'wpide_network_ga4', '' );\n\n    if ( ! $id || current_user_can( 'edit_posts' ) || is_preview() ) {\n        return;\n    }\n    ?>\n<script async src=\"https://www.googletagmanager.com/gtag/js?id=<?php echo esc_attr( $id ); ?>\"></script>\n<script>\nwindow.dataLayer = window.dataLayer || [];\nfunction gtag(){dataLayer.push(arguments);}\ngtag('js', new Date());\ngtag('config', <?php echo wp_json_encode( $id ); ?>, { site_id: <?php echo (int) get_current_blog_id(); ?> });\n</script>\n    <?php\n}, 5 );",
            "requires": [],
            "notes": "Logged-in users who can edit posts are not tracked. Leave the field empty to switch tracking off."
        },
        {
            "id": "ms-network-footer-credit",
            "version": 1,
            "title": "Network-Wide Footer Notice",
            "description": "Lets super admins set one line of text in Network Admin > Settings, such as a hosting credit or legal link, that appears in the footer of every site.",
            "category": "multisite",
            "tags": [
                "network-settings",
                "footer",
                "credit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wpmu_options', function () {\n    ?>\n    <h2><?php esc_html_e( 'Footer notice' ); ?></h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\"><label for=\"wpide_network_footer\"><?php esc_html_e( 'Text' ); ?></label></th>\n            <td><input type=\"text\" name=\"wpide_network_footer\" id=\"wpide_network_footer\" class=\"large-text\" value=\"<?php echo esc_attr( get_site_option( 'wpide_network_footer', '' ) ); ?>\">\n            <p class=\"description\"><?php esc_html_e( 'Links are allowed.' ); ?></p></td>\n        </tr>\n    </table>\n    <?php\n} );\n\nadd_action( 'update_wpmu_options', function () {\n    if ( isset( $_POST['wpide_network_footer'] ) && current_user_can( 'manage_network_options' ) ) {\n        update_site_option( 'wpide_network_footer', wp_kses( wp_unslash( $_POST['wpide_network_footer'] ), array( 'a' => array( 'href' => true, 'rel' => true, 'target' => true ), 'strong' => array(), 'em' => array() ) ) );\n    }\n} );\n\nadd_action( 'wp_footer', function () {\n    $text = get_site_option( 'wpide_network_footer', '' );\n\n    if ( '' !== trim( $text ) ) {\n        echo '<div class=\"wpide-network-footer\" style=\"text-align:center;font-size:13px;padding:12px 16px;opacity:.8\">' . wp_kses_post( $text ) . '</div>';\n    }\n} );",
            "requires": [],
            "notes": "Leave the field empty to hide it. Styled lightly so it fits most themes; adjust the inline CSS to match yours."
        },
        {
            "id": "ms-network-maintenance-mode",
            "version": 1,
            "title": "Network-Wide Maintenance Mode Switch",
            "description": "Adds a checkbox to Network Admin > Settings that puts every site on the network into maintenance mode at once; super admins can still browse and work normally.",
            "category": "multisite",
            "tags": [
                "network-settings",
                "maintenance",
                "downtime"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wpmu_options', function () {\n    ?>\n    <h2><?php esc_html_e( 'Maintenance' ); ?></h2>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\"><?php esc_html_e( 'Maintenance mode' ); ?></th>\n            <td><label><input type=\"checkbox\" name=\"wpide_network_maintenance\" value=\"1\" <?php checked( get_site_option( 'wpide_network_maintenance' ) ); ?>> <?php esc_html_e( 'Show a maintenance page on every site to everyone except super admins' ); ?></label></td>\n        </tr>\n    </table>\n    <?php\n} );\n\nadd_action( 'update_wpmu_options', function () {\n    if ( current_user_can( 'manage_network_options' ) ) {\n        update_site_option( 'wpide_network_maintenance', empty( $_POST['wpide_network_maintenance'] ) ? 0 : 1 );\n    }\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( ! get_site_option( 'wpide_network_maintenance' ) || is_super_admin() ) {\n        return;\n    }\n\n    header( 'Retry-After: 3600' );\n    wp_die(\n        '<h1>' . esc_html__( 'Down for maintenance' ) . '</h1><p>' . esc_html__( 'We are making some improvements and will be back shortly.' ) . '</p>',\n        esc_html__( 'Maintenance' ),\n        array( 'response' => 503 )\n    );\n} );\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( get_site_option( 'wpide_network_maintenance' ) && is_super_admin() ) {\n        $bar->add_node( array(\n            'id'    => 'wpide-network-maintenance',\n            'title' => '<span style=\"color:#f0b849\">' . esc_html__( 'Network maintenance ON' ) . '</span>',\n            'href'  => network_admin_url( 'settings.php' ),\n        ) );\n    }\n}, 100 );",
            "requires": [],
            "notes": "Visitors get a 503 response with a Retry-After header, which tells search engines the downtime is temporary."
        },
        {
            "id": "ms-network-dashboard-stats",
            "version": 1,
            "title": "Network Overview Widget in the Network Dashboard",
            "description": "Adds a widget to the Network Admin dashboard with totals for sites, users, archived, spam and deleted sites, and the five newest sites.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "dashboard",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_network_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_network_overview', __( 'Network overview' ), function () {\n        $rows = array(\n            __( 'Sites' )    => get_blog_count(),\n            __( 'Users' )    => get_user_count(),\n            __( 'Archived' ) => get_sites( array( 'archived' => 1, 'count' => true ) ),\n            __( 'Spam' )     => get_sites( array( 'spam' => 1, 'count' => true ) ),\n            __( 'Deleted' )  => get_sites( array( 'deleted' => 1, 'count' => true ) ),\n        );\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $rows as $label => $value ) {\n            echo '<tr><td>' . esc_html( $label ) . '</td><td style=\"text-align:right\"><strong>' . esc_html( number_format_i18n( (int) $value ) ) . '</strong></td></tr>';\n        }\n        echo '</tbody></table>';\n\n        $newest = get_sites( array( 'number' => 5, 'orderby' => 'registered', 'order' => 'DESC' ) );\n\n        if ( $newest ) {\n            echo '<h3 style=\"margin-top:1em\">' . esc_html__( 'Newest sites' ) . '</h3><ul>';\n            foreach ( $newest as $site ) {\n                printf(\n                    '<li><a href=\"%1$s\">%2$s</a> <span style=\"color:#646970\">%3$s</span></li>',\n                    esc_url( network_admin_url( 'site-info.php?id=' . (int) $site->blog_id ) ),\n                    esc_html( $site->blogname ? $site->blogname : $site->domain . $site->path ),\n                    esc_html( mysql2date( get_option( 'date_format' ), $site->registered ) )\n                );\n            }\n            echo '</ul>';\n        }\n    } );\n} );",
            "requires": [],
            "notes": "Counts come from WordPress's own cached network counts and a single sites query."
        },
        {
            "id": "ms-admin-bar-site-id",
            "version": 1,
            "title": "Show the Current Site ID in the Admin Bar",
            "description": "Shows super admins the blog ID of the site they are on in the toolbar, with links to that site's Network Admin edit screens.",
            "category": "multisite",
            "tags": [
                "admin-bar",
                "site-id",
                "super-admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_super_admin() ) {\n        return;\n    }\n\n    $id = get_current_blog_id();\n\n    $bar->add_node( array(\n        'id'    => 'wpide-site-id',\n        'title' => esc_html( sprintf( 'Site #%d', $id ) ),\n        'href'  => network_admin_url( 'site-info.php?id=' . $id ),\n        'meta'  => array( 'title' => __( 'Edit this site in Network Admin' ) ),\n    ) );\n\n    foreach ( array( 'site-info.php' => 'Info', 'site-users.php' => 'Users', 'site-themes.php' => 'Themes', 'site-settings.php' => 'Settings' ) as $path => $label ) {\n        $bar->add_node( array(\n            'parent' => 'wpide-site-id',\n            'id'     => 'wpide-site-id-' . sanitize_key( $label ),\n            'title'  => esc_html( $label ),\n            'href'   => network_admin_url( $path . '?id=' . $id ),\n        ) );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Only visible to super admins."
        },
        {
            "id": "ms-admin-bar-sort-my-sites",
            "version": 1,
            "title": "Sort \"My Sites\" Alphabetically in the Admin Bar",
            "description": "Orders the sites in the toolbar's My Sites menu by name instead of by creation date, which makes long lists much easier to scan.",
            "category": "multisite",
            "tags": [
                "admin-bar",
                "my-sites",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( empty( $bar->user->blogs ) || count( $bar->user->blogs ) < 3 ) {\n        return;\n    }\n\n    $main  = get_main_site_id();\n    $blogs = $bar->user->blogs;\n\n    uasort( $blogs, function ( $a, $b ) use ( $main ) {\n        if ( (int) $a->userblog_id === $main ) {\n            return -1;\n        }\n        if ( (int) $b->userblog_id === $main ) {\n            return 1;\n        }\n        return strcasecmp( $a->blogname, $b->blogname );\n    } );\n\n    $bar->user->blogs = $blogs;\n}, 19 ); // My Sites is built at priority 20.",
            "requires": [],
            "notes": "The main site stays first; change the comparison to sort by address instead."
        },
        {
            "id": "ms-admin-bar-my-sites-filter",
            "version": 1,
            "title": "Search Box for the \"My Sites\" Admin Bar Menu",
            "description": "Adds a filter field to the top of the toolbar's My Sites menu so super admins on large networks can type part of a name to find a site instantly.",
            "category": "multisite",
            "tags": [
                "admin-bar",
                "my-sites",
                "search"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_min_sites = 10; // Show the filter above this many sites.\n\nadd_action( 'admin_bar_menu', function ( $bar ) use ( $wpide_min_sites ) {\n    if ( empty( $bar->user->blogs ) || count( $bar->user->blogs ) <= $wpide_min_sites ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'parent' => 'my-sites-list',\n        'id'     => 'wpide-my-sites-filter',\n        'title'  => '<label class=\"screen-reader-text\" for=\"wpide-my-sites-q\">' . esc_html__( 'Filter sites' ) . '</label><input type=\"search\" id=\"wpide-my-sites-q\" placeholder=\"' . esc_attr__( 'Filter sites…' ) . '\" style=\"width:94%;margin:4px 3%;padding:2px 6px;height:26px;line-height:1;color:#1d2327\">',\n        'meta'   => array( 'html' => '' ),\n    ) );\n\n    $filter = function () {\n        ?>\n<script>\ndocument.addEventListener('input', function (e) {\n    if (e.target.id !== 'wpide-my-sites-q') { return; }\n    var q = e.target.value.toLowerCase();\n    document.querySelectorAll('#wp-admin-bar-my-sites-list > li').forEach(function (li) {\n        if (li.id === 'wp-admin-bar-wpide-my-sites-filter') { return; }\n        li.style.display = li.textContent.toLowerCase().indexOf(q) === -1 ? 'none' : '';\n    });\n});\n</script>\n        <?php\n    };\n\n    add_action( 'wp_footer', $filter, 99 );\n    add_action( 'admin_footer', $filter, 99 );\n}, 19 ); // Before the list is built, so the field comes first.",
            "requires": [],
            "notes": "Appears only when you belong to more than ten sites; change $wpide_min_sites to show it sooner."
        },
        {
            "id": "ms-hide-my-sites-for-non-super-admins",
            "version": 1,
            "title": "Hide \"My Sites\" From Regular Users",
            "description": "Removes the My Sites menu from the toolbar for everyone except super admins, so members of several sites are not distracted by the network structure.",
            "category": "multisite",
            "tags": [
                "admin-bar",
                "my-sites",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_super_admin() ) {\n        $bar->remove_node( 'my-sites' );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Users can still reach their other sites by address."
        },
        {
            "id": "ms-hide-plugins-menu-site-admins",
            "version": 1,
            "title": "Hide the Plugins Screen From Site Admins",
            "description": "Removes the Plugins menu from subsite dashboards for everyone except super admins and blocks direct visits, even if \"Enable administration menus\" is on in Network Settings.",
            "category": "multisite",
            "tags": [
                "plugins",
                "site-admins",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_menu', function () {\n    if ( ! is_super_admin() ) {\n        remove_menu_page( 'plugins.php' );\n    }\n}, 999 );\n\nadd_action( 'load-plugins.php', function () {\n    if ( ! is_super_admin() ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to manage plugins on this network.' ), 403 );\n    }\n} );",
            "requires": [],
            "notes": "Network-activated plugins keep running; only the screen is hidden."
        },
        {
            "id": "ms-prevent-site-deletion",
            "version": 1,
            "title": "Stop Site Admins From Deleting Their Own Site",
            "description": "Removes Tools > Delete Site from subsite dashboards and blocks the screen, so only super admins can delete sites from Network Admin.",
            "category": "multisite",
            "tags": [
                "site-admins",
                "delete-site",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_menu', function () {\n    if ( ! is_super_admin() ) {\n        remove_submenu_page( 'tools.php', 'ms-delete-site.php' );\n    }\n}, 999 );\n\nadd_action( 'load-ms-delete-site.php', function () {\n    if ( ! is_super_admin() ) {\n        wp_die( esc_html__( 'Please contact the network administrator to delete this site.' ), 403 );\n    }\n} );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-lock-site-admin-email",
            "version": 1,
            "title": "Lock the Admin Email on Subsites",
            "description": "Makes the Administration Email Address field on each site's General Settings read-only for site admins, so network notices keep reaching the right person.",
            "category": "multisite",
            "tags": [
                "site-admins",
                "email",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'pre_update_option_new_admin_email', function ( $value, $old_value ) {\n    return is_super_admin() ? $value : get_option( 'admin_email' );\n}, 10, 2 );\n\nadd_action( 'admin_footer-options-general.php', function () {\n    if ( is_super_admin() ) {\n        return;\n    }\n    ?>\n<script>\n(function () {\n    var field = document.getElementById('new_admin_email');\n    if (field) { field.readOnly = true; field.insertAdjacentHTML('afterend', '<p class=\"description\">Managed by the network administrator.</p>'); }\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Super admins can still change it here or from Network Admin > Sites > Edit > Settings."
        },
        {
            "id": "ms-hide-tools-import-export",
            "version": 1,
            "title": "Hide Import and Export From Site Admins",
            "description": "Removes Tools > Import and Tools > Export from subsite dashboards for non-super-admins and blocks the screens, keeping content moves in the network team's hands.",
            "category": "multisite",
            "tags": [
                "site-admins",
                "import",
                "export"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_menu', function () {\n    if ( ! is_super_admin() ) {\n        remove_submenu_page( 'tools.php', 'import.php' );\n        remove_submenu_page( 'tools.php', 'export.php' );\n    }\n}, 999 );\n\n$wpide_block = function () {\n    if ( ! is_super_admin() ) {\n        wp_die( esc_html__( 'Importing and exporting is handled by the network administrator.' ), 403 );\n    }\n};\n\nadd_action( 'load-import.php', $wpide_block );\nadd_action( 'load-export.php', $wpide_block );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-hide-permalink-settings-site-admins",
            "version": 1,
            "title": "Hide Permalink Settings From Site Admins",
            "description": "Removes Settings > Permalinks from subsite dashboards for non-super-admins so URL structures stay consistent across the network.",
            "category": "multisite",
            "tags": [
                "site-admins",
                "permalinks",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_menu', function () {\n    if ( ! is_super_admin() ) {\n        remove_submenu_page( 'options-general.php', 'options-permalink.php' );\n    }\n}, 999 );\n\nadd_action( 'load-options-permalink.php', function () {\n    if ( ! is_super_admin() ) {\n        wp_die( esc_html__( 'Permalink settings are managed by the network administrator.' ), 403 );\n    }\n} );",
            "requires": [],
            "notes": "Combine with the new-site defaults snippet to set the structure for every new site."
        },
        {
            "id": "ms-show-network-active-plugins",
            "version": 1,
            "title": "Show Network-Active Plugins on Subsite Plugin Screens",
            "description": "Lists network-activated and must-use plugins on each site's Plugins screen for site admins, so they can see what is running without asking.",
            "category": "multisite",
            "tags": [
                "plugins",
                "site-admins",
                "transparency"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'show_network_active_plugins', '__return_true' );\nadd_filter( 'show_advanced_plugins', function ( $show, $type ) {\n    return 'mustuse' === $type ? true : $show;\n}, 10, 2 );",
            "requires": [],
            "notes": "Site admins can see these plugins but cannot deactivate them."
        },
        {
            "id": "ms-hide-add-existing-user",
            "version": 1,
            "title": "Hide \"Add Existing User\" From Site Admins",
            "description": "Removes the \"Add Existing User\" form on subsite Users > Add New for site admins, so they cannot pull people from other sites on the network.",
            "category": "multisite",
            "tags": [
                "users",
                "site-admins",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'show_network_site_users_add_existing_form', function ( $show ) {\n    return is_super_admin() ? $show : false;\n} );",
            "requires": [],
            "notes": "Super admins still see the form."
        },
        {
            "id": "ms-site-admins-edit-users",
            "version": 1,
            "title": "Let Site Admins Edit Their Own Site's Users",
            "description": "Allows site administrators to edit the profiles of users who belong to their site, which multisite normally reserves for super admins.",
            "category": "multisite",
            "tags": [
                "users",
                "site-admins",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) {\n    if ( 'edit_user' !== $cap || empty( $args[0] ) || is_super_admin( $user_id ) ) {\n        return $caps;\n    }\n\n    $target = (int) $args[0];\n    $blog   = get_current_blog_id();\n\n    if ( $target === (int) $user_id || is_super_admin( $target ) || ! user_can( $user_id, 'promote_users' ) ) {\n        return $caps;\n    }\n\n    if ( is_user_member_of_blog( $target, $blog ) && 1 === count( get_blogs_of_user( $target ) ) ) {\n        return array( 'edit_users' );\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Site admins can never edit super admins or users who belong to other sites as well, because profile changes are network-wide."
        },
        {
            "id": "ms-add-new-users-to-main-site",
            "version": 1,
            "title": "Add New Network Users to the Main Site",
            "description": "Adds every newly registered user to the main site with a role you choose, so members of any subsite also have an account on your hub site.",
            "category": "multisite",
            "tags": [
                "users",
                "registration",
                "main-site"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_role = 'subscriber'; // Role on the main site.\n\n$wpide_add = function ( $user_id ) use ( $wpide_role ) {\n    $main = get_main_site_id();\n\n    if ( ! is_user_member_of_blog( $user_id, $main ) ) {\n        add_user_to_blog( $main, $user_id, $wpide_role );\n    }\n};\n\nadd_action( 'wpmu_new_user', $wpide_add );\nadd_action( 'wpmu_activate_user', $wpide_add );",
            "requires": [],
            "notes": "Change $wpide_role. Users who are already members of the main site are left alone."
        },
        {
            "id": "ms-login-redirect-primary-site",
            "version": 1,
            "title": "Send Users to Their Own Site After Login",
            "description": "When someone logs in on a site where they are not a member, sends them to the dashboard of their primary site instead of an error page.",
            "category": "multisite",
            "tags": [
                "login",
                "redirect",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'login_redirect', function ( $redirect_to, $requested, $user ) {\n    if ( ! $user instanceof WP_User || is_super_admin( $user->ID ) || is_user_member_of_blog( $user->ID ) ) {\n        return $redirect_to;\n    }\n\n    $primary = get_active_blog_for_user( $user->ID );\n\n    return $primary ? get_admin_url( $primary->blog_id ) : $redirect_to;\n}, 10, 3 );",
            "requires": [],
            "notes": "Users who belong to the site they log in on are redirected as usual."
        },
        {
            "id": "ms-logout-to-main-site",
            "version": 1,
            "title": "Return to the Main Site After Logging Out",
            "description": "Sends users to the network's main homepage when they log out from any site, giving the whole network one consistent exit point.",
            "category": "multisite",
            "tags": [
                "logout",
                "redirect",
                "main-site"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'logout_redirect', function ( $redirect_to, $requested ) {\n    return $requested ? $redirect_to : network_home_url( '/' );\n}, 10, 2 );",
            "requires": [],
            "notes": "Change network_home_url( '/' ) to a specific page path if you prefer."
        },
        {
            "id": "ms-lock-super-admin-list",
            "version": 1,
            "title": "Lock the List of Super Admins",
            "description": "Blocks anyone from being granted super admin rights unless their username is on your approved list, protecting the network if a super admin account is compromised.",
            "category": "multisite",
            "tags": [
                "super-admin",
                "security",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_allowed = array( 'admin' ); // Usernames allowed to be super admins.\n\nadd_action( 'grant_super_admin', function ( $user_id ) use ( $wpide_allowed ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user || ! in_array( $user->user_login, $wpide_allowed, true ) ) {\n        wp_die( esc_html__( 'This user is not on the approved super admin list.' ), 403 );\n    }\n} );",
            "requires": [],
            "notes": "Add every legitimate super admin to $wpide_allowed before activating, or new grants will be refused."
        },
        {
            "id": "ms-super-admin-change-alert",
            "version": 1,
            "title": "Email Alert When Super Admin Rights Change",
            "description": "Emails the network admin address whenever someone is granted or loses super admin rights, with who made the change.",
            "category": "multisite",
            "tags": [
                "super-admin",
                "security",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_alert_to = get_site_option( 'admin_email' ); // Recipient.\n\n$wpide_alert = function ( $user_id, $granted ) use ( $wpide_alert_to ) {\n    $user  = get_userdata( $user_id );\n    $actor = wp_get_current_user();\n\n    if ( ! $user || ! $wpide_alert_to ) {\n        return;\n    }\n\n    wp_mail(\n        $wpide_alert_to,\n        sprintf( '[%s] Super admin %s: %s', get_network()->site_name, $granted ? 'granted' : 'revoked', $user->user_login ),\n        sprintf(\n            \"%1\\$s was %2\\$s super admin rights by %3\\$s on %4\\$s.\\nIP: %5\\$s\\n\",\n            $user->user_login,\n            $granted ? 'given' : 'removed from',\n            $actor->exists() ? $actor->user_login : 'a system process',\n            wp_date( 'Y-m-d H:i' ),\n            isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'n/a'\n        )\n    );\n};\n\nadd_action( 'granted_super_admin', function ( $user_id ) use ( $wpide_alert ) {\n    $wpide_alert( $user_id, true );\n} );\nadd_action( 'revoked_super_admin', function ( $user_id ) use ( $wpide_alert ) {\n    $wpide_alert( $user_id, false );\n} );",
            "requires": [],
            "notes": "Change $wpide_alert_to to send the alert somewhere else."
        },
        {
            "id": "ms-plugin-usage-report",
            "version": 1,
            "title": "Plugin Usage Report for the Network",
            "description": "Adds Network Admin > Plugins > Usage, a table of every installed plugin with how many sites activate it and which ones, so you can safely remove unused plugins.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "plugins",
                "report"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'network_admin_menu', function () {\n    add_submenu_page( 'plugins.php', __( 'Plugin usage' ), __( 'Usage' ), 'manage_network_plugins', 'wpide-plugin-usage', function () {\n        if ( ! current_user_can( 'manage_network_plugins' ) ) {\n            return;\n        }\n\n        require_once ABSPATH . 'wp-admin/includes/plugin.php';\n\n        $usage = get_site_transient( 'wpide_plugin_usage' );\n\n        if ( false === $usage ) {\n            $usage = array();\n            foreach ( get_sites( array( 'number' => 0, 'fields' => 'ids', 'deleted' => 0 ) ) as $blog_id ) {\n                foreach ( (array) get_blog_option( $blog_id, 'active_plugins', array() ) as $file ) {\n                    $usage[ $file ][] = (int) $blog_id;\n                }\n            }\n            set_site_transient( 'wpide_plugin_usage', $usage, HOUR_IN_SECONDS );\n        }\n\n        $network = (array) get_site_option( 'active_sitewide_plugins', array() );\n\n        echo '<div class=\"wrap\"><h1>' . esc_html__( 'Plugin usage' ) . '</h1>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>' . esc_html__( 'Plugin' ) . '</th><th>' . esc_html__( 'Sites' ) . '</th><th>' . esc_html__( 'Where' ) . '</th></tr></thead><tbody>';\n\n        foreach ( get_plugins() as $file => $data ) {\n            $sites = isset( $usage[ $file ] ) ? $usage[ $file ] : array();\n\n            if ( isset( $network[ $file ] ) ) {\n                $where = '<strong>' . esc_html__( 'Network active' ) . '</strong>';\n                $count = esc_html__( 'All' );\n            } else {\n                $count = count( $sites );\n                $links = array();\n                foreach ( array_slice( $sites, 0, 20 ) as $id ) {\n                    $links[] = '<a href=\"' . esc_url( get_admin_url( $id, 'plugins.php' ) ) . '\">#' . (int) $id . '</a>';\n                }\n                $where = $links ? implode( ' ', $links ) . ( count( $sites ) > 20 ? ' …' : '' ) : '<span style=\"color:#b32d2e\">' . esc_html__( 'Unused' ) . '</span>';\n            }\n\n            printf( '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td></tr>', esc_html( $data['Name'] ), esc_html( (string) $count ), $where ); // phpcs:ignore WordPress.Security.EscapeOutput -- $where is built from escaped parts.\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );\n\nadd_action( 'activated_plugin', function () {\n    delete_site_transient( 'wpide_plugin_usage' );\n} );\nadd_action( 'deactivated_plugin', function () {\n    delete_site_transient( 'wpide_plugin_usage' );\n} );",
            "requires": [],
            "notes": "Reads one option per site; on networks with thousands of sites, open it at a quiet time. Results are cached for an hour."
        },
        {
            "id": "ms-theme-usage-report",
            "version": 1,
            "title": "Theme Usage Report for the Network",
            "description": "Adds Network Admin > Themes > Usage, listing every installed theme with the sites that use it (directly or as a parent theme), so unused themes are easy to spot.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "themes",
                "report"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'network_admin_menu', function () {\n    add_submenu_page( 'themes.php', __( 'Theme usage' ), __( 'Usage' ), 'manage_network_themes', 'wpide-theme-usage', function () {\n        if ( ! current_user_can( 'manage_network_themes' ) ) {\n            return;\n        }\n\n        $usage = get_site_transient( 'wpide_theme_usage' );\n\n        if ( false === $usage ) {\n            $usage = array();\n            foreach ( get_sites( array( 'number' => 0, 'fields' => 'ids', 'deleted' => 0 ) ) as $blog_id ) {\n                $stylesheet = get_blog_option( $blog_id, 'stylesheet' );\n                $template   = get_blog_option( $blog_id, 'template' );\n                $usage[ $stylesheet ]['direct'][] = (int) $blog_id;\n                if ( $template && $template !== $stylesheet ) {\n                    $usage[ $template ]['parent'][] = (int) $blog_id;\n                }\n            }\n            set_site_transient( 'wpide_theme_usage', $usage, HOUR_IN_SECONDS );\n        }\n\n        $allowed = WP_Theme::get_allowed_on_network();\n\n        echo '<div class=\"wrap\"><h1>' . esc_html__( 'Theme usage' ) . '</h1><table class=\"widefat striped\"><thead><tr><th>' . esc_html__( 'Theme' ) . '</th><th>' . esc_html__( 'Network enabled' ) . '</th><th>' . esc_html__( 'Active on' ) . '</th><th>' . esc_html__( 'Parent of' ) . '</th></tr></thead><tbody>';\n\n        foreach ( wp_get_themes() as $slug => $theme ) {\n            $direct = isset( $usage[ $slug ]['direct'] ) ? count( $usage[ $slug ]['direct'] ) : 0;\n            $parent = isset( $usage[ $slug ]['parent'] ) ? count( $usage[ $slug ]['parent'] ) : 0;\n\n            printf(\n                '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td><td>%4$s</td></tr>',\n                esc_html( $theme->get( 'Name' ) ),\n                isset( $allowed[ $slug ] ) ? '&#10003;' : '&mdash;',\n                $direct || $parent ? esc_html( (string) $direct ) : '<span style=\"color:#b32d2e\">' . esc_html__( 'Unused' ) . '</span>',\n                esc_html( (string) $parent )\n            );\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );\n\nadd_action( 'switch_theme', function () {\n    delete_site_transient( 'wpide_theme_usage' );\n} );",
            "requires": [],
            "notes": "Cached for an hour and refreshed whenever a site switches theme."
        },
        {
            "id": "ms-site-deleted-alert",
            "version": 1,
            "title": "Email Alert When a Network Site Is Deleted",
            "description": "Sends an email with the site's name, address and the person responsible whenever a site is permanently deleted from the network.",
            "category": "multisite",
            "tags": [
                "delete-site",
                "email",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_alert_to = get_site_option( 'admin_email' ); // Recipient.\n\nadd_action( 'wp_uninitialize_site', function ( $site ) use ( $wpide_alert_to ) {\n    if ( ! $wpide_alert_to ) {\n        return;\n    }\n\n    $name  = get_blog_option( $site->blog_id, 'blogname' );\n    $actor = wp_get_current_user();\n\n    wp_mail(\n        $wpide_alert_to,\n        sprintf( '[%s] Site deleted: %s', get_network()->site_name, $name ),\n        sprintf(\n            \"Site: %1\\$s\\nAddress: %2\\$s\\nID: %3\\$d\\nDeleted by: %4\\$s\\nWhen: %5\\$s\\n\",\n            $name,\n            $site->domain . $site->path,\n            $site->blog_id,\n            $actor->exists() ? $actor->user_login : 'system / WP-CLI',\n            wp_date( 'Y-m-d H:i' )\n        )\n    );\n}, 1 );",
            "requires": [],
            "notes": "Sent just before the site's tables are removed, so the site name is still available."
        },
        {
            "id": "ms-shared-main-site-menu",
            "version": 1,
            "title": "Shared Navigation Menu From the Main Site",
            "description": "Adds a [wpide_network_menu] shortcode that displays a menu from the main site on any site in the network, so one menu can be maintained in one place.",
            "category": "multisite",
            "tags": [
                "menus",
                "shared",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_shortcode( 'wpide_network_menu', function ( $atts ) {\n    $atts = shortcode_atts( array( 'menu' => '', 'class' => 'wpide-network-menu' ), $atts, 'wpide_network_menu' );\n\n    if ( '' === $atts['menu'] ) {\n        return '';\n    }\n\n    $key  = 'wpide_net_menu_' . md5( $atts['menu'] . '|' . $atts['class'] );\n    $html = get_site_transient( $key );\n\n    if ( false === $html ) {\n        switch_to_blog( get_main_site_id() );\n        $html = wp_nav_menu( array(\n            'menu'            => $atts['menu'],\n            'container'       => 'nav',\n            'container_class' => sanitize_html_class( $atts['class'] ),\n            'container_aria_label' => __( 'Network' ),\n            'fallback_cb'     => false,\n            'echo'            => false,\n        ) );\n        restore_current_blog();\n        $html = (string) $html;\n        set_site_transient( $key, $html, HOUR_IN_SECONDS );\n        $keys   = (array) get_site_option( 'wpide_net_menu_keys', array() );\n        $keys[] = $key;\n        update_site_option( 'wpide_net_menu_keys', array_unique( $keys ) );\n    }\n\n    return $html;\n} );\n\nadd_action( 'wp_update_nav_menu', function () {\n    if ( ! is_main_site() ) {\n        return;\n    }\n    foreach ( (array) get_site_option( 'wpide_net_menu_keys', array() ) as $key ) {\n        delete_site_transient( $key );\n    }\n    delete_site_option( 'wpide_net_menu_keys' );\n} );",
            "requires": [],
            "notes": "Use [wpide_network_menu menu=\"Main Menu\"] with the menu's name, slug or ID on the main site. The HTML is cached for an hour and cleared when a menu on the main site is saved."
        },
        {
            "id": "ms-main-site-latest-posts",
            "version": 1,
            "title": "Latest Posts From the Main Site on Any Subsite",
            "description": "Adds a [wpide_main_site_posts] shortcode that lists the newest posts from the network's main site, perfect for a \"News from HQ\" box on every subsite.",
            "category": "multisite",
            "tags": [
                "shared-content",
                "posts",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_shortcode( 'wpide_main_site_posts', function ( $atts ) {\n    $atts = shortcode_atts( array( 'number' => 5, 'category' => '' ), $atts, 'wpide_main_site_posts' );\n    $key  = 'wpide_main_posts_' . md5( absint( $atts['number'] ) . '|' . sanitize_title( $atts['category'] ) ) . '_' . (int) get_site_option( 'wpide_main_posts_ver', 1 );\n    $html = get_site_transient( $key );\n\n    if ( false === $html ) {\n        switch_to_blog( get_main_site_id() );\n\n        $posts = get_posts( array(\n            'numberposts'      => min( 20, max( 1, absint( $atts['number'] ) ) ),\n            'category_name'    => sanitize_title( $atts['category'] ),\n            'no_found_rows'    => true,\n            'suppress_filters' => false,\n        ) );\n\n        $html = '';\n        if ( $posts ) {\n            $html = '<ul class=\"wpide-main-site-posts\">';\n            foreach ( $posts as $post ) {\n                $html .= sprintf(\n                    '<li><a href=\"%1$s\">%2$s</a> <time datetime=\"%3$s\">%4$s</time></li>',\n                    esc_url( get_permalink( $post ) ),\n                    esc_html( get_the_title( $post ) ),\n                    esc_attr( get_the_date( 'c', $post ) ),\n                    esc_html( get_the_date( '', $post ) )\n                );\n            }\n            $html .= '</ul>';\n        }\n\n        restore_current_blog();\n        set_site_transient( $key, $html, 15 * MINUTE_IN_SECONDS );\n    }\n\n    return $html;\n} );\n\nadd_action( 'transition_post_status', function ( $new, $old, $post ) {\n    if ( is_main_site() && 'post' === $post->post_type && ( 'publish' === $new || 'publish' === $old ) ) {\n        update_site_option( 'wpide_main_posts_ver', (int) get_site_option( 'wpide_main_posts_ver', 1 ) + 1 );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Attributes: number (default 5) and category (a category slug on the main site). Cached for 15 minutes and refreshed when the main site publishes."
        },
        {
            "id": "ms-network-recent-posts",
            "version": 1,
            "title": "Recent Posts From Across the Network",
            "description": "Adds a [wpide_network_posts] shortcode that shows the newest posts from every public site on the network in one list, with the site name next to each post.",
            "category": "multisite",
            "tags": [
                "shared-content",
                "posts",
                "network"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_shortcode( 'wpide_network_posts', function ( $atts ) {\n    $atts  = shortcode_atts( array( 'number' => 10, 'sites' => '' ), $atts, 'wpide_network_posts' );\n    $limit = min( 30, max( 1, absint( $atts['number'] ) ) );\n    $ids   = array_filter( array_map( 'absint', explode( ',', $atts['sites'] ) ) );\n    $key   = 'wpide_network_posts_' . md5( $limit . '|' . implode( ',', $ids ) );\n    $items = get_site_transient( $key );\n\n    if ( false === $items ) {\n        $items = array();\n        $sites = get_sites( array(\n            'site__in' => $ids,\n            'public'   => 1,\n            'archived' => 0,\n            'spam'     => 0,\n            'deleted'  => 0,\n            'number'   => 50,\n            'orderby'  => 'last_updated',\n            'order'    => 'DESC',\n            'fields'   => 'ids',\n        ) );\n\n        foreach ( $sites as $blog_id ) {\n            switch_to_blog( $blog_id );\n            $name = get_bloginfo( 'name' );\n            foreach ( get_posts( array( 'numberposts' => $limit, 'no_found_rows' => true ) ) as $post ) {\n                $items[] = array(\n                    'time'  => strtotime( $post->post_date_gmt ),\n                    'title' => get_the_title( $post ),\n                    'url'   => get_permalink( $post ),\n                    'site'  => $name,\n                );\n            }\n            restore_current_blog();\n        }\n\n        usort( $items, function ( $a, $b ) {\n            return $b['time'] <=> $a['time'];\n        } );\n        $items = array_slice( $items, 0, $limit );\n        set_site_transient( $key, $items, 30 * MINUTE_IN_SECONDS );\n    }\n\n    if ( ! $items ) {\n        return '';\n    }\n\n    $html = '<ul class=\"wpide-network-posts\">';\n    foreach ( $items as $item ) {\n        $html .= sprintf(\n            '<li><a href=\"%1$s\">%2$s</a> <small>%3$s &middot; %4$s</small></li>',\n            esc_url( $item['url'] ),\n            esc_html( $item['title'] ),\n            esc_html( $item['site'] ),\n            esc_html( wp_date( get_option( 'date_format' ), $item['time'] ) )\n        );\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [],
            "notes": "Looks at up to 50 of the most recently updated public sites and caches the result for 30 minutes; attributes are number and sites (comma-separated blog IDs to limit the list)."
        },
        {
            "id": "ms-sites-directory-shortcode",
            "version": 1,
            "title": "Directory of Network Sites",
            "description": "Adds a [wpide_sites_directory] shortcode that lists the public sites on the network with their names, taglines and links, cached for an hour.",
            "category": "multisite",
            "tags": [
                "sites",
                "directory",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_shortcode( 'wpide_sites_directory', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'exclude' => '' ), $atts, 'wpide_sites_directory' );\n    $exclude = array_filter( array_map( 'absint', explode( ',', $atts['exclude'] ) ) );\n    $key     = 'wpide_sites_dir_' . md5( implode( ',', $exclude ) );\n    $html    = get_site_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $sites = get_sites( array(\n        'public'         => 1,\n        'archived'       => 0,\n        'spam'           => 0,\n        'deleted'        => 0,\n        'site__not_in'   => $exclude,\n        'number'         => 500,\n        'update_site_meta_cache' => false,\n    ) );\n\n    $html = '<ul class=\"wpide-sites-directory\">';\n    foreach ( $sites as $site ) {\n        $tagline = get_blog_option( $site->blog_id, 'blogdescription' );\n        $html   .= sprintf(\n            '<li><a href=\"%1$s\">%2$s</a>%3$s</li>',\n            esc_url( $site->home ),\n            esc_html( $site->blogname ),\n            $tagline ? ' &ndash; <span>' . esc_html( $tagline ) . '</span>' : ''\n        );\n    }\n    $html .= '</ul>';\n\n    set_site_transient( $key, $html, HOUR_IN_SECONDS );\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Archived, spam, deleted and search-engine-hidden sites are left out. Set exclude=\"1,4\" to hide particular blog IDs."
        },
        {
            "id": "ms-copy-post-to-site",
            "version": 1,
            "title": "Copy Posts to Another Network Site",
            "description": "Adds a \"Copy to site\" bulk action on the Posts screen that copies the selected posts (title, content, excerpt and featured image URL as a note) as drafts to another site you are an editor on.",
            "category": "multisite",
            "tags": [
                "copy",
                "posts",
                "bulk-actions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'bulk_actions-edit-post', function ( $actions ) {\n    foreach ( get_blogs_of_user( get_current_user_id() ) as $blog ) {\n        if ( (int) $blog->userblog_id !== get_current_blog_id() ) {\n            /* translators: %s: site name */\n            $actions[ 'wpide_copy_to_' . (int) $blog->userblog_id ] = sprintf( __( 'Copy to: %s' ), $blog->blogname );\n        }\n    }\n\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-edit-post', function ( $redirect, $action, $post_ids ) {\n    if ( 0 !== strpos( $action, 'wpide_copy_to_' ) ) {\n        return $redirect;\n    }\n\n    $target = absint( substr( $action, strlen( 'wpide_copy_to_' ) ) );\n\n    if ( ! $target || ! is_user_member_of_blog( get_current_user_id(), $target ) ) {\n        return $redirect;\n    }\n\n    $copies = array();\n    foreach ( array_map( 'absint', $post_ids ) as $post_id ) {\n        $post = get_post( $post_id );\n        if ( $post && current_user_can( 'edit_post', $post_id ) ) {\n            $copies[] = array(\n                'post_title'   => $post->post_title,\n                'post_content' => $post->post_content,\n                'post_excerpt' => $post->post_excerpt,\n                'source'       => get_permalink( $post ),\n            );\n        }\n    }\n\n    $done = 0;\n    switch_to_blog( $target );\n    if ( current_user_can( 'edit_posts' ) ) {\n        foreach ( $copies as $copy ) {\n            $new = wp_insert_post( array(\n                'post_title'   => $copy['post_title'],\n                'post_content' => $copy['post_content'],\n                'post_excerpt' => $copy['post_excerpt'],\n                'post_status'  => 'draft',\n                'post_author'  => get_current_user_id(),\n            ) );\n            if ( $new && ! is_wp_error( $new ) ) {\n                update_post_meta( $new, '_wpide_copied_from', esc_url_raw( $copy['source'] ) );\n                $done++;\n            }\n        }\n    }\n    restore_current_blog();\n\n    return add_query_arg( 'wpide_copied', $done, $redirect );\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_copied'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $count = absint( $_GET['wpide_copied'] ); // phpcs:ignore WordPress.Security.NonceVerification\n        /* translators: %d: number of posts */\n        echo '<div class=\"notice notice-success is-dismissible\"><p>' . esc_html( sprintf( _n( '%d post copied as a draft.', '%d posts copied as drafts.', $count ), $count ) ) . '</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Copies are created as drafts. Categories, tags and media are not copied because their IDs differ between sites; the original post's link is saved in a custom field."
        },
        {
            "id": "ms-site-icon-fallback",
            "version": 1,
            "title": "Use the Main Site's Icon on Sites Without One",
            "description": "Makes subsites that have no Site Icon of their own show the main site's favicon and app icon, so every browser tab carries your brand.",
            "category": "multisite",
            "tags": [
                "site-icon",
                "favicon",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'get_site_icon_url', function ( $url, $size, $blog_id ) {\n    $main = get_main_site_id();\n\n    if ( $url || (int) $blog_id === $main || ( ! $blog_id && is_main_site() ) ) {\n        return $url;\n    }\n\n    $key    = 'wpide_main_icon_' . (int) $size;\n    $cached = wp_cache_get( $key, 'wpide' );\n\n    if ( false === $cached ) {\n        $icon_id = (int) get_blog_option( $main, 'site_icon' );\n        $cached  = '';\n        if ( $icon_id ) {\n            switch_to_blog( $main );\n            $cached = (string) wp_get_attachment_image_url( $icon_id, array( $size, $size ) );\n            restore_current_blog();\n        }\n        wp_cache_set( $key, $cached, 'wpide', HOUR_IN_SECONDS );\n    }\n\n    return $cached ? $cached : $url;\n}, 10, 3 );",
            "requires": [],
            "notes": "Sites that upload their own icon keep it."
        },
        {
            "id": "ms-network-login-logo",
            "version": 1,
            "title": "Network-Wide Login Logo From the Main Site",
            "description": "Shows the main site's custom logo on the login screen of every site in the network, linked to the site being logged in to.",
            "category": "multisite",
            "tags": [
                "login",
                "logo",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'login_enqueue_scripts', function () {\n    $main    = get_main_site_id();\n    $mods    = get_blog_option( $main, 'theme_mods_' . get_blog_option( $main, 'stylesheet' ) );\n    $logo_id = is_array( $mods ) && ! empty( $mods['custom_logo'] ) ? (int) $mods['custom_logo'] : 0;\n\n    if ( ! $logo_id ) {\n        return;\n    }\n\n    switch_to_blog( $main );\n    $src = wp_get_attachment_image_src( $logo_id, 'medium' );\n    restore_current_blog();\n\n    if ( ! $src ) {\n        return;\n    }\n\n    $width  = min( 320, (int) $src[1] );\n    $height = $src[1] ? (int) round( $src[2] * $width / $src[1] ) : 84;\n\n    printf(\n        '<style>.login h1 a{background-image:url(%1$s);background-size:contain;background-position:center;width:%2$dpx;height:%3$dpx;max-width:100%%}</style>',\n        esc_url( $src[0] ),\n        (int) $width,\n        (int) $height\n    );\n} );\n\nadd_filter( 'login_headerurl', function () {\n    return home_url( '/' );\n} );\n\nadd_filter( 'login_headertext', function () {\n    return get_bloginfo( 'name' );\n} );",
            "requires": [],
            "notes": "Uses the main site's Customizer logo (custom_logo); set one there first."
        },
        {
            "id": "ms-private-subsites",
            "version": 1,
            "title": "Members-Only Subsites",
            "description": "Makes the subsites you list visible only to logged-in members of that site; everyone else is sent to the login page and non-members see a friendly notice.",
            "category": "multisite",
            "tags": [
                "private",
                "members",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_private_sites = array(); // Blog IDs that only members can view, e.g. array( 3, 7 ).\n\nif ( ! in_array( get_current_blog_id(), $wpide_private_sites, true ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( is_user_logged_in() && ( is_user_member_of_blog() || is_super_admin() ) ) {\n        return;\n    }\n\n    if ( ! is_user_logged_in() ) {\n        auth_redirect();\n    }\n\n    wp_die( esc_html__( 'This site is only open to its members.' ), esc_html__( 'Members only' ), array( 'response' => 403 ) );\n}, 1 );\n\nadd_filter( 'rest_authentication_errors', function ( $result ) {\n    if ( ! empty( $result ) || ( is_user_logged_in() && ( is_user_member_of_blog() || is_super_admin() ) ) ) {\n        return $result;\n    }\n\n    return new WP_Error( 'wpide_members_only', __( 'This site is only open to its members.' ), array( 'status' => 401 ) );\n} );",
            "requires": [],
            "notes": "List blog IDs in $wpide_private_sites. Feeds and the REST API are covered too; super admins always have access."
        },
        {
            "id": "ms-per-site-upload-quota",
            "version": 1,
            "title": "Custom Upload Quotas for Individual Sites",
            "description": "Gives chosen sites a bigger (or smaller) upload quota than the network default, without editing each site in Network Admin.",
            "category": "multisite",
            "tags": [
                "upload-quota",
                "storage",
                "sites"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_quotas = array( // Blog ID => quota in MB, e.g. 2 => 1000.\n);\n\nadd_filter( 'get_space_allowed', function ( $space ) use ( $wpide_quotas ) {\n    $id = get_current_blog_id();\n\n    return isset( $wpide_quotas[ $id ] ) ? (int) $wpide_quotas[ $id ] : $space;\n} );",
            "requires": [],
            "notes": "Values are in megabytes. Sites not listed keep the network's \"Site upload space\" setting."
        },
        {
            "id": "ms-storage-in-admin-bar",
            "version": 1,
            "title": "Show Upload Space Used in the Admin Bar",
            "description": "Shows site admins how much of their upload quota they have used, as a percentage in the toolbar, turning amber at 75% and red at 90%.",
            "category": "multisite",
            "tags": [
                "upload-quota",
                "storage",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_admin() || ! current_user_can( 'upload_files' ) || get_site_option( 'upload_space_check_disabled' ) ) {\n        return;\n    }\n\n    $allowed = (float) get_space_allowed();\n\n    if ( $allowed <= 0 ) {\n        return;\n    }\n\n    $used    = (float) get_space_used();\n    $percent = min( 100, (int) round( $used / $allowed * 100 ) );\n    $color   = $percent >= 90 ? '#f86368' : ( $percent >= 75 ? '#f0b849' : 'inherit' );\n\n    $bar->add_node( array(\n        'id'    => 'wpide-storage',\n        'title' => sprintf( '<span style=\"color:%1$s\">%2$s</span>', esc_attr( $color ), esc_html( sprintf( 'Storage %d%%', $percent ) ) ),\n        'href'  => admin_url( 'upload.php' ),\n        'meta'  => array(\n            'title' => sprintf( '%1$s of %2$s used', size_format( (int) ( $used * MB_IN_BYTES ), 1 ), size_format( (int) ( $allowed * MB_IN_BYTES ) ) ),\n        ),\n    ) );\n}, 90 );",
            "requires": [],
            "notes": "Hidden when the network has upload space checks turned off."
        },
        {
            "id": "ms-storage-warning-notice",
            "version": 1,
            "title": "Warn Site Admins Before They Run Out of Upload Space",
            "description": "Shows an admin notice on the Media screens and the dashboard once a site has used more than a set share of its upload quota, before uploads start failing.",
            "category": "multisite",
            "tags": [
                "upload-quota",
                "storage",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_threshold = 80; // Warn above this percentage.\n\nadd_action( 'admin_notices', function () use ( $wpide_threshold ) {\n    $screen = get_current_screen();\n\n    if ( ! $screen || ! in_array( $screen->id, array( 'dashboard', 'upload', 'media' ), true ) || ! current_user_can( 'upload_files' ) || get_site_option( 'upload_space_check_disabled' ) ) {\n        return;\n    }\n\n    $allowed = (float) get_space_allowed();\n    $used    = (float) get_space_used();\n\n    if ( $allowed <= 0 || ( $used / $allowed * 100 ) < $wpide_threshold ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-warning\"><p>%s</p></div>',\n        esc_html( sprintf(\n            'This site has used %1$d%% of its upload space (%2$s left). Delete unused media or ask the network administrator for more space.',\n            min( 100, round( $used / $allowed * 100 ) ),\n            size_format( (int) ( max( 0, $allowed - $used ) * MB_IN_BYTES ), 1 )\n        ) )\n    );\n} );",
            "requires": [],
            "notes": "Change $wpide_threshold (percent). The message includes the remaining space."
        },
        {
            "id": "ms-network-upload-file-types",
            "version": 1,
            "title": "Add Modern Image Formats to Network Upload Types",
            "description": "Adds WebP and AVIF to the file types every site on the network may upload, without editing the \"Upload file types\" list in Network Settings.",
            "category": "multisite",
            "tags": [
                "uploads",
                "file-types",
                "webp"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_extra = array( 'webp', 'avif' ); // Extensions to allow.\n\nadd_filter( 'site_option_upload_filetypes', function ( $types ) use ( $wpide_extra ) {\n    $list = array_filter( explode( ' ', strtolower( (string) $types ) ) );\n\n    return implode( ' ', array_unique( array_merge( $list, $wpide_extra ) ) );\n} );",
            "requires": [],
            "notes": "Add other extensions to $wpide_extra; the server must also allow their MIME types."
        },
        {
            "id": "ms-network-max-upload-size",
            "version": 1,
            "title": "Set the Maximum Upload Size for the Whole Network",
            "description": "Overrides the network's \"Max upload file size\" setting from code, so every site gets the same per-file limit regardless of what is stored in the database.",
            "category": "multisite",
            "tags": [
                "uploads",
                "file-size",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_max_kb = 20480; // 20 MB.\n\nadd_filter( 'site_option_fileupload_maxk', function () use ( $wpide_max_kb ) {\n    return (int) $wpide_max_kb;\n} );",
            "requires": [],
            "notes": "Value in kilobytes. PHP's upload_max_filesize and post_max_size still cap the real limit."
        },
        {
            "id": "ms-fix-mapped-domain-urls",
            "version": 1,
            "title": "Fix Old Subsite URLs After Domain Mapping",
            "description": "Rewrites links and image URLs that still point at a site's old network address (like example.com/shop/) to its mapped domain when content is displayed.",
            "category": "multisite",
            "tags": [
                "domain-mapping",
                "urls",
                "mixed-content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_old_urls = array( // Blog ID => old address (without trailing slash).\n    // 2 => 'https://network.example.com/shop',\n);\n\n$wpide_id = get_current_blog_id();\n\nif ( empty( $wpide_old_urls[ $wpide_id ] ) ) {\n    return;\n}\n\n$wpide_old = untrailingslashit( $wpide_old_urls[ $wpide_id ] );\n$wpide_new = untrailingslashit( home_url() );\n\nif ( $wpide_old === $wpide_new ) {\n    return;\n}\n\n$wpide_fix = function ( $content ) use ( $wpide_old, $wpide_new ) {\n    if ( ! is_string( $content ) || false === strpos( $content, $wpide_old ) ) {\n        return $content;\n    }\n\n    $variants = array( $wpide_old, set_url_scheme( $wpide_old, 'http' ), str_replace( '/', '\\/', $wpide_old ) );\n\n    return str_replace( $variants, array( $wpide_new, $wpide_new, str_replace( '/', '\\/', $wpide_new ) ), $content );\n};\n\nforeach ( array( 'the_content', 'the_excerpt', 'widget_text', 'widget_block_content', 'wp_get_attachment_url' ) as $wpide_hook ) {\n    add_filter( $wpide_hook, $wpide_fix, 99 );\n}",
            "requires": [],
            "notes": "Set $wpide_old_urls per blog ID. This fixes display only; run a search-replace later to update the database permanently."
        },
        {
            "id": "ms-mapped-domain-column",
            "version": 1,
            "title": "Mapped Domain Column in the Network Sites List",
            "description": "Shows each site's public address next to its network address in Network Admin > Sites and flags sites whose Home and Site URLs do not match, a common domain-mapping mistake.",
            "category": "multisite",
            "tags": [
                "domain-mapping",
                "network-admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_domain'] = __( 'Public address' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_domain' !== $column ) {\n        return;\n    }\n\n    $home    = untrailingslashit( (string) get_blog_option( $blog_id, 'home' ) );\n    $siteurl = untrailingslashit( (string) get_blog_option( $blog_id, 'siteurl' ) );\n\n    printf( '<a href=\"%1$s\">%2$s</a>', esc_url( $home ), esc_html( wp_parse_url( $home, PHP_URL_HOST ) . wp_parse_url( $home, PHP_URL_PATH ) ) );\n\n    if ( wp_parse_url( $home, PHP_URL_HOST ) !== wp_parse_url( $siteurl, PHP_URL_HOST ) ) {\n        echo '<br><span style=\"color:#b32d2e\">' . esc_html( sprintf( 'Site URL differs: %s', $siteurl ) ) . '</span>';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Fix mismatches in Network Admin > Sites > Edit > Settings (home and siteurl)."
        },
        {
            "id": "ms-body-class-site",
            "version": 1,
            "title": "Site ID and Path Classes on the Body Tag",
            "description": "Adds classes such as site-3 and site-shop to the body tag on every network site, so one shared stylesheet can target individual sites.",
            "category": "multisite",
            "tags": [
                "body-class",
                "css",
                "sites"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    $site      = get_site();\n    $classes[] = 'site-' . get_current_blog_id();\n    $classes[] = is_main_site() ? 'site-main' : 'site-sub';\n\n    $slug = trim( (string) ( $site ? $site->path : '' ), '/' );\n    if ( '' === $slug && $site ) {\n        $slug = strtok( $site->domain, '.' );\n    }\n    if ( '' !== $slug ) {\n        $classes[] = 'site-' . sanitize_html_class( $slug );\n    }\n\n    return $classes;\n} );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "ms-staging-subsites-noindex",
            "version": 1,
            "title": "Noindex Subsites With a Staging Path",
            "description": "Adds a noindex robots directive to any subsite whose address contains \"staging\", \"dev\" or \"test\", so preview copies inside a network never show up in search results.",
            "category": "multisite",
            "tags": [
                "seo",
                "staging",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_markers = array( 'staging', 'dev', 'test' ); // Words that mark a preview site.\n\n$wpide_site = get_site();\n$wpide_addr = $wpide_site ? strtolower( $wpide_site->domain . $wpide_site->path ) : '';\n$wpide_hit  = false;\n\nforeach ( $wpide_markers as $wpide_marker ) {\n    if ( preg_match( '/(^|[.\\/-])' . preg_quote( $wpide_marker, '/' ) . '([.\\/-]|$)/', $wpide_addr ) ) {\n        $wpide_hit = true;\n        break;\n    }\n}\n\nif ( ! $wpide_hit ) {\n    return;\n}\n\nadd_filter( 'wp_robots', 'wp_robots_no_robots' );\nadd_filter( 'robots_txt', function () {\n    return \"User-agent: *\\nDisallow: /\\n\";\n}, 99 );",
            "requires": [],
            "notes": "Edit $wpide_markers to match how you name preview sites."
        },
        {
            "id": "ms-network-admin-quick-search",
            "version": 1,
            "title": "Jump to Any Site From the Network Admin Toolbar",
            "description": "Adds a small \"Find site\" search box to the toolbar in Network Admin that searches the sites list by name or address, saving clicks on networks with hundreds of sites.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "search",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_network_admin() || ! current_user_can( 'manage_sites' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-find-site',\n        'title' => '<form action=\"' . esc_url( network_admin_url( 'sites.php' ) ) . '\" method=\"get\" style=\"display:inline-flex;align-items:center;height:32px\"><label class=\"screen-reader-text\" for=\"wpide-find-site-q\">' . esc_html__( 'Find site' ) . '</label><input type=\"search\" name=\"s\" id=\"wpide-find-site-q\" placeholder=\"' . esc_attr__( 'Find site…' ) . '\" style=\"height:24px;line-height:1;padding:0 6px;min-height:0;width:160px;color:#1d2327\"></form>',\n        'meta'  => array( 'html' => '' ),\n    ) );\n}, 100 );",
            "requires": [],
            "notes": "Uses WordPress's own sites search; press Enter to search."
        },
        {
            "id": "ms-sites-registered-by-column",
            "version": 1,
            "title": "\"Created By\" Column in the Network Sites List",
            "description": "Records which user created each new site and shows it in a column in Network Admin > Sites, which helps on networks where staff or customers create their own sites.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "columns",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'wp_initialize_site', function ( $site, $args ) {\n    $user_id = ! empty( $args['user_id'] ) ? (int) $args['user_id'] : get_current_user_id();\n\n    if ( $user_id ) {\n        update_site_meta( $site->blog_id, 'wpide_created_by', $user_id );\n    }\n}, 300, 2 );\n\nif ( ! is_admin() ) {\n    return;\n}\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_created_by'] = __( 'Created by' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_created_by' !== $column ) {\n        return;\n    }\n\n    $user = get_userdata( (int) get_site_meta( $blog_id, 'wpide_created_by', true ) );\n\n    if ( $user ) {\n        printf( '<a href=\"%1$s\">%2$s</a>', esc_url( network_admin_url( 'user-edit.php?user_id=' . (int) $user->ID ) ), esc_html( $user->user_login ) );\n    } else {\n        echo '&mdash;';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Only sites created after activation are recorded; earlier ones show a dash."
        },
        {
            "id": "ms-site-notes",
            "version": 1,
            "title": "Private Notes on Each Network Site",
            "description": "Adds a \"Notes\" box to Network Admin > Sites > Edit Site where super admins can record the client, contract details or quirks of a site, and shows the note in the sites list.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'network_site_info_form', function ( $blog_id ) {\n    ?>\n    <table class=\"form-table\" role=\"presentation\">\n        <tr>\n            <th scope=\"row\"><label for=\"wpide_site_note\"><?php esc_html_e( 'Notes' ); ?></label></th>\n            <td><textarea name=\"wpide_site_note\" id=\"wpide_site_note\" rows=\"4\" class=\"large-text\"><?php echo esc_textarea( (string) get_site_meta( $blog_id, 'wpide_site_note', true ) ); ?></textarea></td>\n        </tr>\n    </table>\n    <?php\n} );\n\n// Runs inside site-info.php after WordPress has checked the edit-site nonce and the manage_sites capability.\nadd_action( 'wp_update_site', function ( $new_site ) {\n    if ( ! isset( $_POST['wpide_site_note'] ) || ! current_user_can( 'manage_sites' ) || ! is_network_admin() ) {\n        return;\n    }\n\n    check_admin_referer( 'edit-site' );\n    update_site_meta( $new_site->blog_id, 'wpide_site_note', sanitize_textarea_field( wp_unslash( $_POST['wpide_site_note'] ) ) );\n} );\n\nadd_filter( 'wpmu_blogs_columns', function ( $columns ) {\n    $columns['wpide_site_note'] = __( 'Notes' );\n\n    return $columns;\n} );\n\nadd_action( 'manage_sites_custom_column', function ( $column, $blog_id ) {\n    if ( 'wpide_site_note' === $column ) {\n        $note = (string) get_site_meta( $blog_id, 'wpide_site_note', true );\n        echo $note ? '<span title=\"' . esc_attr( $note ) . '\">' . esc_html( wp_trim_words( $note, 12 ) ) . '</span>' : '&mdash;';\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Notes are stored in site meta and only visible in Network Admin."
        },
        {
            "id": "ms-super-admin-login-notice",
            "version": 1,
            "title": "Remind Super Admins Which Site They Are Editing",
            "description": "Shows a slim coloured bar at the top of every subsite dashboard for super admins with the site's name and address, so changes are never made on the wrong site by mistake.",
            "category": "multisite",
            "tags": [
                "super-admin",
                "admin",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'in_admin_header', function () {\n    if ( ! is_super_admin() || is_network_admin() ) {\n        return;\n    }\n\n    $main  = is_main_site();\n    $color = $main ? '#d63638' : '#2271b1';\n\n    printf(\n        '<div class=\"wpide-site-reminder\" style=\"background:%1$s;color:#fff;padding:4px 12px;margin:0 0 0 -20px;font-size:12px\">%2$s <strong>%3$s</strong> &middot; %4$s</div>',\n        esc_attr( $color ),\n        esc_html( $main ? 'Main site:' : 'Site:' ),\n        esc_html( get_bloginfo( 'name' ) ),\n        esc_html( preg_replace( '#^https?://#', '', home_url() ) )\n    );\n} );",
            "requires": [],
            "notes": "The main site uses a different colour so it stands out."
        },
        {
            "id": "ms-redirect-main-signup-to-page",
            "version": 1,
            "title": "Point \"Create a Site\" Links to Your Own Signup Page",
            "description": "Replaces the network's default wp-signup.php address in links and redirects with a custom page, such as a pricing or onboarding page, while keeping the real signup form available.",
            "category": "multisite",
            "tags": [
                "signup",
                "redirect",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\n$wpide_signup_url = network_home_url( '/get-started/' ); // Your signup landing page.\n\nadd_filter( 'wp_signup_location', function () use ( $wpide_signup_url ) {\n    return $wpide_signup_url;\n} );\n\nadd_filter( 'register_url', function ( $url ) use ( $wpide_signup_url ) {\n    return false !== strpos( $url, 'wp-signup.php' ) ? $wpide_signup_url : $url;\n} );",
            "requires": [],
            "notes": "Set $wpide_signup_url. Visits to wp-signup.php itself still work so your custom page can link to it."
        },
        {
            "id": "ms-network-users-csv-export",
            "version": 1,
            "title": "Export Network Users to CSV",
            "description": "Adds an \"Export CSV\" button to Network Admin > Users that downloads every network user with their email, registration date, number of sites and super admin status.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "users",
                "export"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_action( 'network_admin_notices', function () {\n    $screen = get_current_screen();\n\n    if ( ! $screen || 'users-network' !== $screen->id || ! current_user_can( 'manage_network_users' ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-info\"><p><a class=\"button\" href=\"%1$s\">%2$s</a></p></div>',\n        esc_url( wp_nonce_url( network_admin_url( 'edit.php?action=wpide_export_users' ), 'wpide_export_users' ) ),\n        esc_html__( 'Export all users as CSV' )\n    );\n} );\n\nadd_action( 'network_admin_edit_wpide_export_users', function () {\n    check_admin_referer( 'wpide_export_users' );\n\n    if ( ! current_user_can( 'manage_network_users' ) ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to export users.' ), 403 );\n    }\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=network-users-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out    = fopen( 'php://output', 'w' );\n    $supers = get_super_admins();\n    fputcsv( $out, array( 'ID', 'Username', 'Email', 'Display name', 'Registered', 'Sites', 'Super admin' ) );\n\n    $paged = 1;\n    do {\n        $users = get_users( array( 'blog_id' => 0, 'number' => 500, 'paged' => $paged, 'orderby' => 'ID', 'fields' => array( 'ID', 'user_login', 'user_email', 'display_name', 'user_registered' ) ) );\n        foreach ( $users as $user ) {\n            $row = array( $user->ID, $user->user_login, $user->user_email, $user->display_name, $user->user_registered, count( get_blogs_of_user( $user->ID ) ), in_array( $user->user_login, $supers, true ) ? 'yes' : 'no' );\n            fputcsv( $out, array_map( function ( $cell ) {\n                $cell = (string) $cell;\n                return preg_match( '/^[=+\\-@]/', $cell ) ? \"'\" . $cell : $cell; // Avoid spreadsheet formula injection.\n            }, $row ) );\n        }\n        $paged++;\n    } while ( count( $users ) === 500 );\n\n    fclose( $out ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n    exit;\n} );",
            "requires": [],
            "notes": "Protected by a nonce and the manage_network_users capability. Very large networks are exported in batches of 500 users to keep memory low."
        },
        {
            "id": "ms-sites-list-newest-activity-first",
            "version": 1,
            "title": "Sort the Network Sites List by Recent Activity",
            "description": "Opens Network Admin > Sites sorted by \"last updated\" with the most recently active sites first, instead of by ID, so the sites people are working on are always at the top.",
            "category": "multisite",
            "tags": [
                "network-admin",
                "sites",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'ms_sites_list_table_query_args', function ( $args ) {\n    if ( empty( $_GET['orderby'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $args['orderby'] = 'last_updated';\n        $args['order']   = 'DESC';\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Clicking a column header still sorts by that column."
        },
        {
            "id": "ms-site-signups-members-only",
            "version": 1,
            "title": "Only Logged-In Members Can Create New Sites",
            "description": "Keeps the network's signup form open for new user accounts but only lets people who are already logged in create a site, cutting down on spam sites while staying open to new members.",
            "category": "multisite",
            "tags": [
                "signup",
                "sites",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! is_multisite() ) {\n    return;\n}\n\nadd_filter( 'wpmu_active_signup', function ( $mode ) {\n    if ( is_user_logged_in() || is_super_admin() ) {\n        return $mode;\n    }\n\n    return 'all' === $mode ? 'user' : $mode; // Visitors may still create an account, but not a site.\n} );",
            "requires": [],
            "notes": "Applies when Network Settings > Registration is set to allow both user accounts and sites. Super admins are never restricted."
        },
        {
            "id": "i18n-english-admin-for-admins",
            "version": 1,
            "title": "English Dashboard for Administrators Only",
            "description": "Keeps the dashboard in English for administrators while the site and everyone else's dashboard stay in the site language, handy for developers supporting non-English sites.",
            "category": "multilingual",
            "tags": [
                "admin-language",
                "locale",
                "administrators"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_locale = 'en_US'; // Admin language for administrators.\n\nadd_filter( 'determine_locale', function ( $locale ) use ( $wpide_locale ) {\n    if ( ! is_admin() || ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {\n        return $locale;\n    }\n\n    $own = get_user_meta( get_current_user_id(), 'locale', true );\n\n    return $own ? $own : $wpide_locale;\n} );",
            "requires": [],
            "notes": "A language chosen in the administrator's own profile still wins. Change $wpide_locale to use another admin language."
        },
        {
            "id": "i18n-lang-query-switcher",
            "version": 1,
            "title": "Switch the Front-End Language With ?lang=",
            "description": "Lets visitors switch the theme and plugin interface language with a ?lang=fr_FR link; the choice is remembered in a cookie and limited to languages you allow.",
            "category": "multilingual",
            "tags": [
                "locale",
                "language-switcher",
                "cookie"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_allowed = array( 'en_US', 'fr_FR', 'de_DE', 'es_ES' ); // Installed locales visitors may pick.\n\nadd_filter( 'determine_locale', function ( $locale ) use ( $wpide_allowed ) {\n    if ( is_admin() ) {\n        return $locale;\n    }\n\n    $pick = '';\n    if ( isset( $_GET['lang'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $pick = sanitize_text_field( wp_unslash( $_GET['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    } elseif ( isset( $_COOKIE['wpide_lang'] ) ) {\n        $pick = sanitize_text_field( wp_unslash( $_COOKIE['wpide_lang'] ) );\n    }\n\n    return in_array( $pick, $wpide_allowed, true ) ? $pick : $locale;\n}, 20 );\n\nadd_action( 'init', function () use ( $wpide_allowed ) {\n    if ( is_admin() || ! isset( $_GET['lang'] ) || headers_sent() ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $pick = sanitize_text_field( wp_unslash( $_GET['lang'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n\n    if ( in_array( $pick, $wpide_allowed, true ) ) {\n        setcookie( 'wpide_lang', $pick, time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );\n    }\n} );",
            "requires": [],
            "notes": "Only switches interface strings that have installed translations, not your post content. Install the languages in Settings > General first."
        },
        {
            "id": "i18n-date-format-per-locale",
            "version": 1,
            "title": "Date and Time Formats Per Language",
            "description": "Uses a different date and time format for each language, such as 27 October 2026 in British English and 27. Oktober 2026 in German, whenever the site or user language changes.",
            "category": "multilingual",
            "tags": [
                "date-format",
                "locale",
                "time-format"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_formats = array( // Locale => array( date format, time format ).\n    'en_GB' => array( 'j F Y', 'H:i' ),\n    'de_DE' => array( 'j. F Y', 'H:i' ),\n    'fr_FR' => array( 'j F Y', 'H\\hi' ),\n    'ja'    => array( 'Y年n月j日', 'H:i' ),\n);\n\nadd_filter( 'option_date_format', function ( $format ) use ( $wpide_formats ) {\n    $locale = determine_locale();\n\n    return isset( $wpide_formats[ $locale ] ) ? $wpide_formats[ $locale ][0] : $format;\n} );\n\nadd_filter( 'option_time_format', function ( $format ) use ( $wpide_formats ) {\n    $locale = determine_locale();\n\n    return isset( $wpide_formats[ $locale ] ) ? $wpide_formats[ $locale ][1] : $format;\n} );",
            "requires": [],
            "notes": "Add locales to $wpide_formats; languages not listed fall back to the formats in Settings > General. Works with any plugin that switches the locale."
        },
        {
            "id": "i18n-week-start-per-locale",
            "version": 1,
            "title": "First Day of the Week Per Language",
            "description": "Starts calendars and date pickers on Sunday or Monday depending on the current language, instead of one fixed setting for every visitor.",
            "category": "multilingual",
            "tags": [
                "calendar",
                "locale",
                "week-start"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_week_start = array( // Locale => first day of week.\n    'en_GB' => 1,\n    'de_DE' => 1,\n    'fr_FR' => 1,\n    'ar'    => 6,\n);\n\nadd_filter( 'option_start_of_week', function ( $day ) use ( $wpide_week_start ) {\n    $locale = determine_locale();\n\n    return isset( $wpide_week_start[ $locale ] ) ? $wpide_week_start[ $locale ] : $day;\n} );",
            "requires": [],
            "notes": "0 = Sunday, 1 = Monday, 6 = Saturday. Locales not listed use Settings > General."
        },
        {
            "id": "i18n-localized-number-shortcode",
            "version": 1,
            "title": "Localized Number Shortcode",
            "description": "Adds a [wpide_number] shortcode that prints a number with the thousands and decimal separators of the current language, for example 1,234.5 or 1.234,5.",
            "category": "multilingual",
            "tags": [
                "numbers",
                "locale",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_number', function ( $atts ) {\n    $atts = shortcode_atts( array( 'value' => 0, 'decimals' => 0 ), $atts, 'wpide_number' );\n\n    return esc_html( number_format_i18n( (float) $atts['value'], min( 6, absint( $atts['decimals'] ) ) ) );\n} );",
            "requires": [],
            "notes": "Use [wpide_number value=\"1234.5\" decimals=\"1\"]."
        },
        {
            "id": "i18n-localized-price-shortcode",
            "version": 1,
            "title": "Localized Currency Amount Shortcode",
            "description": "Adds a [wpide_money] shortcode that formats an amount in a currency using the conventions of the current language, like $1,234.50, 1.234,50 € or ¥1,235.",
            "category": "multilingual",
            "tags": [
                "currency",
                "locale",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_money', function ( $atts ) {\n    $atts     = shortcode_atts( array( 'amount' => 0, 'currency' => 'USD' ), $atts, 'wpide_money' );\n    $amount   = (float) $atts['amount'];\n    $currency = strtoupper( preg_replace( '/[^A-Za-z]/', '', $atts['currency'] ) );\n\n    if ( class_exists( 'NumberFormatter' ) ) {\n        $formatter = new NumberFormatter( determine_locale(), NumberFormatter::CURRENCY );\n        $output    = $formatter->formatCurrency( $amount, $currency );\n        if ( false !== $output ) {\n            return esc_html( $output );\n        }\n    }\n\n    return esc_html( $currency . ' ' . number_format_i18n( $amount, 2 ) );\n} );",
            "requires": [],
            "notes": "Use [wpide_money amount=\"1234.5\" currency=\"EUR\"]. Needs PHP's intl extension for full formatting; without it a simple fallback is used."
        },
        {
            "id": "i18n-rtl-preview-toggle",
            "version": 1,
            "title": "Preview the Site in Right-to-Left Mode",
            "description": "Lets administrators add ?wpide_rtl=1 to any URL to see the site and dashboard laid out right-to-left, so themes can be checked for Arabic or Hebrew without switching the site language.",
            "category": "multilingual",
            "tags": [
                "rtl",
                "testing",
                "preview"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( empty( $_GET['wpide_rtl'] ) || ! current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    global $wp_locale;\n\n    if ( $wp_locale instanceof WP_Locale ) {\n        $wp_locale->text_direction = 'rtl';\n    }\n} );\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( empty( $_GET['wpide_rtl'] ) || ! current_user_can( 'manage_options' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-rtl',\n        'title' => 'RTL preview: on',\n        'href'  => remove_query_arg( 'wpide_rtl' ),\n    ) );\n}, 100 );",
            "requires": [],
            "notes": "Only affects the logged-in administrator making the request. Add ?wpide_rtl=0 or remove the parameter to go back."
        },
        {
            "id": "i18n-rtl-icon-flip",
            "version": 1,
            "title": "Flip Directional Icons in Right-to-Left Languages",
            "description": "Mirrors arrows, chevrons and other directional icons when the page is right-to-left, so \"next\" and \"back\" point the right way in Arabic, Hebrew and Persian.",
            "category": "multilingual",
            "tags": [
                "rtl",
                "icons",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "[dir=\"rtl\"] .icon-arrow-right,\n[dir=\"rtl\"] .icon-arrow-left,\n[dir=\"rtl\"] .icon-chevron-right,\n[dir=\"rtl\"] .icon-chevron-left,\n[dir=\"rtl\"] .wp-block-post-navigation-link .wp-block-post-navigation-link__arrow-next,\n[dir=\"rtl\"] .wp-block-post-navigation-link .wp-block-post-navigation-link__arrow-previous,\n[dir=\"rtl\"] .wp-block-query-pagination-next-arrow,\n[dir=\"rtl\"] .wp-block-query-pagination-previous-arrow,\n[dir=\"rtl\"] .flip-in-rtl {\n    display: inline-block;\n    transform: scaleX(-1);\n}",
            "requires": [],
            "notes": "Add your theme's icon classes to the selector list; logos and clocks should not be flipped."
        },
        {
            "id": "i18n-rtl-logical-spacing",
            "version": 1,
            "title": "Right-to-Left Fixes for Lists, Quotes and Tables",
            "description": "Corrects common right-to-left layout problems in post content, such as list indents, blockquote borders, table alignment and image floats, using logical CSS properties.",
            "category": "multilingual",
            "tags": [
                "rtl",
                "css",
                "typography"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "[dir=\"rtl\"] .entry-content ul,\n[dir=\"rtl\"] .entry-content ol {\n    padding-inline-start: 1.5em;\n    padding-left: 0;\n}\n\n[dir=\"rtl\"] .entry-content blockquote,\n[dir=\"rtl\"] .wp-block-quote {\n    border-left: 0;\n    border-inline-start: 4px solid currentColor;\n    padding-inline-start: 1em;\n}\n\n[dir=\"rtl\"] .entry-content th,\n[dir=\"rtl\"] .entry-content td {\n    text-align: start;\n}\n\n[dir=\"rtl\"] .alignleft {\n    float: right;\n    margin: 0 0 1em 1.5em;\n}\n\n[dir=\"rtl\"] .alignright {\n    float: left;\n    margin: 0 1.5em 1em 0;\n}\n\n[dir=\"rtl\"] code,\n[dir=\"rtl\"] pre {\n    direction: ltr;\n    text-align: left;\n    unicode-bidi: embed;\n}",
            "requires": [],
            "notes": "Rules only apply when the page direction is rtl, so it is safe on multilingual sites."
        },
        {
            "id": "i18n-bidi-isolate-names",
            "version": 1,
            "title": "Keep Mixed-Direction Names Readable",
            "description": "Wraps comment author names and post author names in a bidi-isolate element so a Hebrew or Arabic name does not scramble the punctuation around it on a left-to-right page (and vice versa).",
            "category": "multilingual",
            "tags": [
                "rtl",
                "bidi",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'get_comment_author', function ( $author ) {\n    return is_admin() || '' === $author ? $author : '<bdi>' . $author . '</bdi>';\n}, 99 );\n\nadd_filter( 'the_author', function ( $author ) {\n    return is_admin() || is_feed() || '' === (string) $author ? $author : '<bdi>' . $author . '</bdi>';\n}, 99 );",
            "requires": [],
            "notes": "Uses the HTML <bdi> element, supported by all current browsers."
        },
        {
            "id": "i18n-page-language-attribute",
            "version": 1,
            "title": "Set a Different Language for Individual Pages",
            "description": "Adds a \"Page language\" box to the editor so a single post or page written in another language gets the correct lang attribute (and text direction) for screen readers, spell checkers and search engines.",
            "category": "multilingual",
            "tags": [
                "lang-attribute",
                "accessibility",
                "meta-box"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rtl_langs = array( 'ar', 'he', 'fa', 'ur', 'ps', 'yi', 'dv', 'ckb' ); // Codes written right-to-left.\n\nadd_action( 'init', function () {\n    foreach ( array( 'post', 'page' ) as $type ) {\n        register_post_meta( $type, '_wpide_lang', array(\n            'type'              => 'string',\n            'single'            => true,\n            'show_in_rest'      => true,\n            'sanitize_callback' => function ( $value ) {\n                return preg_match( '/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/', (string) $value ) ? $value : '';\n            },\n            'auth_callback'     => function ( $allowed, $key, $post_id ) {\n                return current_user_can( 'edit_post', $post_id );\n            },\n        ) );\n    }\n} );\n\nadd_action( 'add_meta_boxes', function () {\n    add_meta_box( 'wpide-lang', __( 'Page language' ), function ( $post ) {\n        wp_nonce_field( 'wpide_lang_save', 'wpide_lang_nonce' );\n        printf(\n            '<label for=\"wpide_lang\" class=\"screen-reader-text\">%1$s</label><input type=\"text\" id=\"wpide_lang\" name=\"wpide_lang\" value=\"%2$s\" placeholder=\"%3$s\" style=\"width:100%%\"><p class=\"description\">%4$s</p>',\n            esc_html__( 'Language code' ),\n            esc_attr( get_post_meta( $post->ID, '_wpide_lang', true ) ),\n            esc_attr( str_replace( '_', '-', get_locale() ) ),\n            esc_html__( 'Leave empty to use the site language.' )\n        );\n    }, array( 'post', 'page' ), 'side' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_lang_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_lang_nonce'] ), 'wpide_lang_save' ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $value = isset( $_POST['wpide_lang'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_lang'] ) ) : '';\n\n    if ( '' === $value ) {\n        delete_post_meta( $post_id, '_wpide_lang' );\n    } else {\n        update_post_meta( $post_id, '_wpide_lang', $value );\n    }\n} );\n\nadd_filter( 'language_attributes', function ( $output ) use ( $wpide_rtl_langs ) {\n    if ( is_admin() || ! is_singular() ) {\n        return $output;\n    }\n\n    $lang = get_post_meta( get_queried_object_id(), '_wpide_lang', true );\n\n    if ( ! $lang ) {\n        return $output;\n    }\n\n    $dir = in_array( strtolower( strtok( $lang, '-' ) ), $wpide_rtl_langs, true ) ? 'rtl' : 'ltr';\n\n    return sprintf( 'dir=\"%1$s\" lang=\"%2$s\"', esc_attr( $dir ), esc_attr( $lang ) );\n} );",
            "requires": [],
            "notes": "Enter a language code such as fr, de-CH or ar. RTL languages also set dir=\"rtl\"."
        },
        {
            "id": "i18n-manual-hreflang",
            "version": 1,
            "title": "Manual hreflang Links for Translated Pages",
            "description": "Adds a box to the editor where you list the URLs of a page's translations; the matching hreflang tags (plus x-default) are printed in the page head, for multilingual sites that do not use a translation plugin.",
            "category": "multilingual",
            "tags": [
                "hreflang",
                "seo",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function () {\n    add_meta_box( 'wpide-hreflang', __( 'Translations (hreflang)' ), function ( $post ) {\n        wp_nonce_field( 'wpide_hreflang_save', 'wpide_hreflang_nonce' );\n        printf(\n            '<textarea name=\"wpide_hreflang\" rows=\"4\" style=\"width:100%%\" placeholder=\"en https://example.com/page/&#10;fr https://example.com/fr/page/\">%s</textarea>',\n            esc_textarea( get_post_meta( $post->ID, '_wpide_hreflang', true ) )\n        );\n    }, array( 'post', 'page' ), 'side' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_hreflang_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_hreflang_nonce'] ), 'wpide_hreflang_save' ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $lines = array();\n    foreach ( preg_split( '/\\R/', (string) wp_unslash( $_POST['wpide_hreflang'] ?? '' ) ) as $line ) {\n        $parts = preg_split( '/\\s+/', trim( $line ), 2 );\n        if ( 2 === count( $parts ) && preg_match( '/^([a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?|x-default)$/', $parts[0] ) && wp_http_validate_url( $parts[1] ) ) {\n            $lines[] = $parts[0] . ' ' . esc_url_raw( $parts[1] );\n        }\n    }\n\n    update_post_meta( $post_id, '_wpide_hreflang', implode( \"\\n\", $lines ) );\n} );\n\nadd_action( 'wp_head', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $raw = get_post_meta( get_queried_object_id(), '_wpide_hreflang', true );\n\n    foreach ( array_filter( explode( \"\\n\", (string) $raw ) ) as $line ) {\n        list( $lang, $url ) = array_pad( explode( ' ', $line, 2 ), 2, '' );\n        if ( $url ) {\n            printf( '<link rel=\"alternate\" hreflang=\"%1$s\" href=\"%2$s\">' . \"\\n\", esc_attr( $lang ), esc_url( $url ) );\n        }\n    }\n}, 2 );",
            "requires": [],
            "notes": "One per line, as language code, a space, then the URL (e.g. \"fr https://example.com/fr/page/\"). Add \"x-default URL\" for the fallback version. List the page itself too."
        },
        {
            "id": "i18n-custom-translation-overrides",
            "version": 1,
            "title": "Load Your Own Translation Files First",
            "description": "Looks for custom .mo files in wp-content/languages/custom/ before the official ones, so your edited translations of a plugin or theme survive updates.",
            "category": "multilingual",
            "tags": [
                "translations",
                "mo-files",
                "overrides"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'load_textdomain_mofile', function ( $mofile, $domain ) {\n    static $loading = array();\n\n    if ( isset( $loading[ $domain ] ) ) {\n        return $mofile;\n    }\n\n    $custom = WP_LANG_DIR . '/custom/' . $domain . '-' . determine_locale() . '.mo';\n\n    if ( is_readable( $custom ) ) {\n        $loading[ $domain ] = true;\n        load_textdomain( $domain, $custom ); // Loaded first, so its strings take priority.\n        unset( $loading[ $domain ] );\n    }\n\n    return $mofile;\n}, 10, 2 );",
            "requires": [],
            "notes": "Name files like plugin-slug-fr_FR.mo (or theme-slug-de_DE.mo) and upload them to wp-content/languages/custom/. Strings missing from your file still come from the official translation."
        },
        {
            "id": "i18n-string-overrides-per-locale",
            "version": 1,
            "title": "Replace Interface Text Per Language",
            "description": "Changes specific interface strings (button labels, notices, form text) differently for each language, without editing translation files.",
            "category": "multilingual",
            "tags": [
                "gettext",
                "strings",
                "locale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_overrides = array( // Locale => array( original => replacement ).\n    'en_US' => array(\n        'Leave a Reply' => 'Join the conversation',\n        'Post Comment'  => 'Send comment',\n    ),\n    'fr_FR' => array(\n        'Leave a Reply' => 'Participez à la discussion',\n        'Post Comment'  => 'Envoyer',\n    ),\n);\n\nadd_filter( 'gettext', function ( $translation, $text ) use ( $wpide_overrides ) {\n    static $map = null;\n\n    if ( null === $map ) {\n        $locale = determine_locale();\n        $map    = isset( $wpide_overrides[ $locale ] ) ? $wpide_overrides[ $locale ] : array();\n    }\n\n    return isset( $map[ $text ] ) ? $map[ $text ] : $translation;\n}, 20, 2 );",
            "requires": [],
            "notes": "Keys are the original English strings exactly as written in the code. Add a text domain check if a string is used by several plugins."
        },
        {
            "id": "i18n-browser-language-redirect",
            "version": 1,
            "title": "Redirect First-Time Visitors to Their Language Section",
            "description": "Sends visitors who land on the homepage to the matching language section (such as /fr/ or /de/) based on their browser language, once; their choice is then remembered.",
            "category": "multilingual",
            "tags": [
                "redirect",
                "browser-language",
                "homepage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "$wpide_sections = array( // Browser language => homepage path, e.g. 'fr' => '/fr/', 'de' => '/de/'.\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_sections ) {\n    if ( ! $wpide_sections || ! is_front_page() || is_user_logged_in() || isset( $_COOKIE['wpide_lang_seen'] ) || empty( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) {\n        return;\n    }\n\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';\n    if ( '' === $agent || preg_match( '/bot|crawl|spider|slurp|preview|lighthouse/', $agent ) ) {\n        return;\n    }\n\n    setcookie( 'wpide_lang_seen', '1', time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );\n\n    $header = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT_LANGUAGE'] ) ) );\n    $first  = substr( $header, 0, 2 );\n\n    if ( isset( $wpide_sections[ $first ] ) ) {\n        nocache_headers();\n        wp_safe_redirect( home_url( $wpide_sections[ $first ] ), 302 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Map language codes to the paths of your translated homepages. Search engine bots are never redirected, so every version stays indexable."
        },
        {
            "id": "i18n-users-language-column",
            "version": 1,
            "title": "Language Column in the Users List",
            "description": "Shows each user's chosen dashboard language in Users > All Users, which helps when you send emails or support in their language.",
            "category": "multilingual",
            "tags": [
                "users",
                "locale",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_locale'] = __( 'Language' );\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_locale' !== $column ) {\n        return $output;\n    }\n\n    $locale = get_user_meta( $user_id, 'locale', true );\n\n    if ( ! $locale ) {\n        return '<span style=\"color:#646970\">' . esc_html__( 'Site default' ) . '</span>';\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/translation-install.php';\n    static $names = null;\n    if ( null === $names ) {\n        $names = array( 'en_US' => 'English (United States)' );\n        foreach ( (array) wp_get_available_translations() as $code => $data ) {\n            $names[ $code ] = $data['native_name'];\n        }\n    }\n\n    return esc_html( isset( $names[ $locale ] ) ? $names[ $locale ] : $locale );\n}, 10, 3 );",
            "requires": [],
            "notes": "Users without their own choice show \"Site default\"."
        },
        {
            "id": "i18n-admin-bar-language-switch",
            "version": 1,
            "title": "Quick Dashboard Language Switch in the Admin Bar",
            "description": "Adds a language menu to the toolbar so each user can switch their own dashboard language in one click, using the languages installed on the site.",
            "category": "multilingual",
            "tags": [
                "admin-bar",
                "admin-language",
                "locale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_admin() || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $languages = get_available_languages();\n\n    if ( ! $languages ) {\n        return;\n    }\n\n    array_unshift( $languages, 'en_US' );\n    $current = get_user_locale();\n\n    $bar->add_node( array(\n        'id'    => 'wpide-admin-lang',\n        'title' => esc_html( strtoupper( substr( $current, 0, 2 ) ) ),\n        'meta'  => array( 'title' => __( 'Dashboard language' ) ),\n    ) );\n\n    foreach ( array_unique( $languages ) as $locale ) {\n        $bar->add_node( array(\n            'parent' => 'wpide-admin-lang',\n            'id'     => 'wpide-admin-lang-' . sanitize_key( $locale ),\n            'title'  => esc_html( ( $locale === $current ? '✓ ' : '' ) . $locale ),\n            'href'   => wp_nonce_url( add_query_arg( array( 'action' => 'wpide_set_admin_lang', 'locale' => $locale ), admin_url( 'admin-post.php' ) ), 'wpide_set_admin_lang' ),\n        ) );\n    }\n}, 80 );\n\nadd_action( 'admin_post_wpide_set_admin_lang', function () {\n    check_admin_referer( 'wpide_set_admin_lang' );\n\n    $locale = isset( $_GET['locale'] ) ? sanitize_text_field( wp_unslash( $_GET['locale'] ) ) : '';\n\n    if ( 'en_US' === $locale || in_array( $locale, get_available_languages(), true ) ) {\n        update_user_meta( get_current_user_id(), 'locale', 'en_US' === $locale ? 'en_US' : $locale );\n    }\n\n    wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );\n    exit;\n} );",
            "requires": [],
            "notes": "Changes only the current user's profile language; the site language stays the same."
        },
        {
            "id": "i18n-silence-jit-textdomain-notice",
            "version": 1,
            "title": "Silence \"Translation Loading Triggered Too Early\" Notices",
            "description": "Hides the \"_load_textdomain_just_in_time was called incorrectly\" debug notice (added in WordPress 6.7) for plugins you cannot fix yourself, keeping debug logs readable.",
            "category": "multilingual",
            "tags": [
                "debug",
                "textdomain",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_domains = array(); // e.g. array( 'some-plugin' ). Empty = all domains.\n\nadd_filter( 'doing_it_wrong_trigger_error', function ( $trigger, $function_name, $message ) use ( $wpide_domains ) {\n    if ( '_load_textdomain_just_in_time' !== $function_name ) {\n        return $trigger;\n    }\n\n    if ( ! $wpide_domains ) {\n        return false;\n    }\n\n    foreach ( $wpide_domains as $domain ) {\n        if ( false !== strpos( $message, '<code>' . $domain . '</code>' ) ) {\n            return false;\n        }\n    }\n\n    return $trigger;\n}, 10, 3 );",
            "requires": [],
            "notes": "List the text domains to silence in $wpide_domains, or leave it empty to silence the notice for all of them. Report the issue to the plugin author too."
        },
        {
            "id": "i18n-lang-font-stacks",
            "version": 1,
            "title": "Font Stacks Per Language With :lang()",
            "description": "Switches to fonts that cover the script of the page or block language, for example Arabic, Hebrew, Japanese, Chinese, Korean, Thai and Hindi, while keeping your theme font for Latin text.",
            "category": "multilingual",
            "tags": [
                "fonts",
                "typography",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":lang(ar), :lang(fa), :lang(ur) {\n    font-family: \"Noto Naskh Arabic\", \"Geeza Pro\", Tahoma, \"Segoe UI\", sans-serif;\n    line-height: 1.8;\n}\n\n:lang(he) {\n    font-family: \"Noto Sans Hebrew\", Arial, \"Segoe UI\", sans-serif;\n}\n\n:lang(ja) {\n    font-family: \"Hiragino Sans\", \"Hiragino Kaku Gothic ProN\", \"Noto Sans JP\", Meiryo, sans-serif;\n    line-break: strict;\n}\n\n:lang(zh), :lang(zh-CN) {\n    font-family: \"PingFang SC\", \"Noto Sans SC\", \"Microsoft YaHei\", sans-serif;\n}\n\n:lang(zh-TW), :lang(zh-HK) {\n    font-family: \"PingFang TC\", \"Noto Sans TC\", \"Microsoft JhengHei\", sans-serif;\n}\n\n:lang(ko) {\n    font-family: \"Apple SD Gothic Neo\", \"Noto Sans KR\", \"Malgun Gothic\", sans-serif;\n    word-break: keep-all;\n}\n\n:lang(th) {\n    font-family: \"Noto Sans Thai\", Thonburi, Tahoma, sans-serif;\n    line-height: 1.8;\n}\n\n:lang(hi), :lang(mr), :lang(ne) {\n    font-family: \"Noto Sans Devanagari\", \"Kohinoor Devanagari\", Mangal, sans-serif;\n}",
            "requires": [],
            "notes": "These use fonts already installed on most devices; swap in web fonts you load if you prefer."
        },
        {
            "id": "i18n-hyphenation-by-language",
            "version": 1,
            "title": "Automatic Hyphenation for Long-Word Languages",
            "description": "Turns on the browser's built-in hyphenation for post content in languages with long compound words, such as German, Dutch and Finnish, so narrow columns on phones stop showing large gaps.",
            "category": "multilingual",
            "tags": [
                "hyphenation",
                "typography",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":lang(de) .entry-content p,\n:lang(nl) .entry-content p,\n:lang(fi) .entry-content p,\n:lang(sv) .entry-content p,\n:lang(da) .entry-content p,\n:lang(nb) .entry-content p,\n:lang(hu) .entry-content p,\n:lang(de) .wp-block-post-content p,\n:lang(nl) .wp-block-post-content p,\n:lang(fi) .wp-block-post-content p {\n    -webkit-hyphens: auto;\n    hyphens: auto;\n    hyphenate-limit-chars: 8 3 3;\n    overflow-wrap: break-word;\n}",
            "requires": [],
            "notes": "Hyphenation follows the page's lang attribute, so make sure it is set correctly. Headings are left alone."
        },
        {
            "id": "i18n-quotes-by-language",
            "version": 1,
            "title": "Correct Quotation Marks Per Language",
            "description": "Makes <q> quotations use the right quote marks for the language of the page or passage, such as « » in French, „ “ in German and 「 」 in Japanese.",
            "category": "multilingual",
            "tags": [
                "typography",
                "quotes",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":lang(en) q { quotes: \"\\201C\" \"\\201D\" \"\\2018\" \"\\2019\"; }\n:lang(fr) q { quotes: \"\\00AB\\00A0\" \"\\00A0\\00BB\" \"\\201C\" \"\\201D\"; }\n:lang(de) q { quotes: \"\\201E\" \"\\201C\" \"\\201A\" \"\\2018\"; }\n:lang(es) q, :lang(it) q, :lang(pt) q, :lang(ru) q { quotes: \"\\00AB\" \"\\00BB\" \"\\201C\" \"\\201D\"; }\n:lang(pl) q, :lang(nl) q { quotes: \"\\201E\" \"\\201D\" \"\\00AB\" \"\\00BB\"; }\n:lang(ja) q, :lang(zh-TW) q { quotes: \"\\300C\" \"\\300D\" \"\\300E\" \"\\300F\"; }\n:lang(zh) q { quotes: \"\\201C\" \"\\201D\" \"\\2018\" \"\\2019\"; }\n:lang(sv) q, :lang(fi) q { quotes: \"\\201D\" \"\\201D\" \"\\2019\" \"\\2019\"; }",
            "requires": [],
            "notes": "Applies to inline <q> elements; typed quote characters in your text are not changed."
        },
        {
            "id": "i18n-visitor-local-times",
            "version": 1,
            "title": "Show Post Times in the Visitor's Own Timezone",
            "description": "Converts post dates and times on the page into each visitor's local timezone and language format in the browser, so an event posted for 18:00 London reads 19:00 in Paris.",
            "category": "multilingual",
            "tags": [
                "timezone",
                "dates",
                "javascript"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    if (!window.Intl || !Intl.DateTimeFormat) { return; }\n    var lang = document.documentElement.lang || undefined;\n    document.querySelectorAll('time[datetime]').forEach(function (el) {\n        var value = el.getAttribute('datetime');\n        if (el.classList.contains('wpide-no-localize') || !/T\\d{2}:\\d{2}/.test(value)) { return; }\n        var date = new Date(value);\n        if (isNaN(date.getTime())) { return; }\n        el.title = el.textContent.trim();\n        el.textContent = new Intl.DateTimeFormat(lang, { dateStyle: 'medium', timeStyle: 'short' }).format(date);\n    });\n})();\n</script>\n    <?php\n}, 50 );",
            "requires": [],
            "notes": "Works on any <time datetime=\"...\"> element with a full date and time; elements without a time are left as they are. Add the class wpide-no-localize to skip one."
        },
        {
            "id": "i18n-current-language-js",
            "version": 1,
            "title": "Expose the Page Language to JavaScript",
            "description": "Adds a small window.wpideLang object with the current locale, language code and text direction, so your scripts can format dates, numbers or messages correctly without guessing.",
            "category": "multilingual",
            "tags": [
                "javascript",
                "locale",
                "developer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    $locale = determine_locale();\n    $code   = strtok( str_replace( '_', '-', $locale ), '-' );\n\n    if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $code = ICL_LANGUAGE_CODE;\n    } elseif ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $code = pll_current_language();\n    }\n\n    $data = array(\n        'locale' => $locale,\n        'code'   => $code,\n        'dir'    => is_rtl() ? 'rtl' : 'ltr',\n    );\n\n    echo '<script>window.wpideLang=' . wp_json_encode( $data ) . ';</script>' . \"\\n\";\n}, 1 );",
            "requires": [],
            "notes": "Picks up the language set by WPML or Polylang when either is active, otherwise the WordPress locale."
        },
        {
            "id": "i18n-language-body-class",
            "version": 1,
            "title": "Current Language Class on the Body Tag",
            "description": "Adds classes like lang-fr and dir-rtl to the body tag, so you can adjust styles per language, whether the language comes from WPML, Polylang or the WordPress locale.",
            "category": "multilingual",
            "tags": [
                "body-class",
                "css",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'body_class', function ( $classes ) {\n    $code = strtok( str_replace( '_', '-', determine_locale() ), '-' );\n\n    if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $code = ICL_LANGUAGE_CODE;\n    } elseif ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $code = pll_current_language();\n    }\n\n    $classes[] = 'lang-' . sanitize_html_class( strtolower( $code ) );\n    $classes[] = is_rtl() ? 'dir-rtl' : 'dir-ltr';\n\n    return $classes;\n} );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "i18n-language-content-shortcode",
            "version": 1,
            "title": "Show Content Only in Certain Languages",
            "description": "Adds a [wpide_lang only=\"fr,de\"]...[/wpide_lang] shortcode that shows the enclosed text only when the page is displayed in one of those languages, for small differences between translations.",
            "category": "multilingual",
            "tags": [
                "shortcode",
                "language",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_lang', function ( $atts, $content = '' ) {\n    $atts   = shortcode_atts( array( 'only' => '', 'except' => '' ), $atts, 'wpide_lang' );\n    $locale = strtolower( determine_locale() );\n    $code   = strtok( $locale, '_' );\n\n    if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $code = strtolower( ICL_LANGUAGE_CODE );\n    } elseif ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $code = strtolower( pll_current_language() );\n    }\n\n    $matches = function ( $list ) use ( $code, $locale ) {\n        $items = array_filter( array_map( 'trim', explode( ',', strtolower( $list ) ) ) );\n        return in_array( $code, $items, true ) || in_array( $locale, $items, true );\n    };\n\n    if ( '' !== $atts['only'] && ! $matches( $atts['only'] ) ) {\n        return '';\n    }\n\n    if ( '' !== $atts['except'] && $matches( $atts['except'] ) ) {\n        return '';\n    }\n\n    return do_shortcode( $content );\n} );",
            "requires": [],
            "notes": "Uses the WPML or Polylang language when available, otherwise the WordPress locale (e.g. fr matches fr_FR). Add except=\"en\" to hide content in a language instead."
        },
        {
            "id": "i18n-comment-notify-by-language",
            "version": 1,
            "title": "Comment Notifications to a Per-Language Moderator",
            "description": "Sends comment moderation emails to a different moderator depending on the language of the post, using WPML, Polylang or each post's language box, so every comment reaches someone who reads it.",
            "category": "multilingual",
            "tags": [
                "comments",
                "moderation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_moderators = array( // Language code => moderator email.\n    'fr' => 'moderation-fr@example.com',\n    'de' => 'moderation-de@example.com',\n);\n\n$wpide_post_lang = function ( $post_id ) {\n    if ( has_filter( 'wpml_post_language_details' ) ) {\n        $details = apply_filters( 'wpml_post_language_details', null, $post_id );\n        if ( is_array( $details ) && ! empty( $details['language_code'] ) ) {\n            return $details['language_code'];\n        }\n    }\n\n    if ( function_exists( 'pll_get_post_language' ) ) {\n        $lang = pll_get_post_language( $post_id );\n        if ( $lang ) {\n            return $lang;\n        }\n    }\n\n    $meta = get_post_meta( $post_id, '_wpide_lang', true );\n\n    return $meta ? strtolower( strtok( $meta, '-' ) ) : '';\n};\n\nadd_filter( 'comment_moderation_recipients', function ( $emails, $comment_id ) use ( $wpide_moderators, $wpide_post_lang ) {\n    $comment = get_comment( $comment_id );\n    $lang    = $comment ? $wpide_post_lang( (int) $comment->comment_post_ID ) : '';\n\n    return isset( $wpide_moderators[ $lang ] ) ? array( $wpide_moderators[ $lang ] ) : $emails;\n}, 10, 2 );",
            "requires": [],
            "notes": "Map language codes to email addresses; posts in other languages keep the default recipients."
        },
        {
            "id": "wpml-language-switcher-shortcode",
            "version": 1,
            "title": "Lightweight WPML Language Switcher Shortcode",
            "description": "Adds a [wpide_wpml_switcher] shortcode that prints an accessible list or dropdown of your WPML languages, with native names, codes or flags, anywhere you can place a shortcode.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "language-switcher",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_wpml_switcher', function ( $atts ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return '';\n    }\n\n    $atts = shortcode_atts( array(\n        'style'        => 'list',\n        'show'         => 'native',\n        'flags'        => '0',\n        'hide_current' => '0',\n        'skip_missing' => '0',\n    ), $atts, 'wpide_wpml_switcher' );\n\n    $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => $atts['skip_missing'] ? 1 : 0, 'orderby' => 'custom' ) );\n\n    if ( empty( $languages ) || ! is_array( $languages ) ) {\n        return '';\n    }\n\n    $label = function ( $lang ) use ( $atts ) {\n        if ( 'code' === $atts['show'] ) {\n            return strtoupper( $lang['language_code'] );\n        }\n        return 'translated' === $atts['show'] ? $lang['translated_name'] : $lang['native_name'];\n    };\n\n    if ( 'dropdown' === $atts['style'] ) {\n        $id   = wp_unique_id( 'wpide-wpml-ls-' );\n        $html = '<form class=\"wpide-wpml-switcher\" onsubmit=\"return false\"><label class=\"screen-reader-text\" for=\"' . esc_attr( $id ) . '\">' . esc_html__( 'Language' ) . '</label>';\n        $html .= '<select id=\"' . esc_attr( $id ) . '\" onchange=\"if(this.value){window.location.href=this.value;}\">';\n        foreach ( $languages as $lang ) {\n            $html .= sprintf(\n                '<option value=\"%1$s\" lang=\"%2$s\"%3$s>%4$s</option>',\n                esc_url( $lang['url'] ),\n                esc_attr( $lang['tag'] ?? $lang['language_code'] ),\n                selected( ! empty( $lang['active'] ), true, false ),\n                esc_html( $label( $lang ) )\n            );\n        }\n        return $html . '</select></form>';\n    }\n\n    $html = '<nav class=\"wpide-wpml-switcher\" aria-label=\"' . esc_attr__( 'Language' ) . '\"><ul style=\"list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75em\">';\n    foreach ( $languages as $lang ) {\n        $current = ! empty( $lang['active'] );\n        if ( $current && $atts['hide_current'] ) {\n            continue;\n        }\n        $flag  = $atts['flags'] && ! empty( $lang['country_flag_url'] ) ? '<img src=\"' . esc_url( $lang['country_flag_url'] ) . '\" alt=\"\" width=\"18\" height=\"12\" style=\"vertical-align:middle;margin-inline-end:.35em\"> ' : '';\n        $html .= sprintf(\n            '<li><a href=\"%1$s\" hreflang=\"%2$s\" lang=\"%2$s\"%3$s>%4$s%5$s</a></li>',\n            esc_url( $lang['url'] ),\n            esc_attr( $lang['tag'] ?? $lang['language_code'] ),\n            $current ? ' aria-current=\"true\" style=\"font-weight:700\"' : '',\n            $flag,\n            esc_html( $label( $lang ) )\n        );\n    }\n\n    return $html . '</ul></nav>';\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Attributes: style=\"list|dropdown\", show=\"native|translated|code\", flags=\"1\", hide_current=\"1\", skip_missing=\"1\" (hide languages without a translation of the current page)."
        },
        {
            "id": "wpml-menu-language-links",
            "version": 1,
            "title": "Add WPML Language Links to a Menu Location",
            "description": "Appends links to the other WPML languages of the current page at the end of a chosen theme menu, labelled with each language's native name.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "menus",
                "language-switcher"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_location = 'primary'; // Theme menu location.\n\nadd_filter( 'wp_nav_menu_items', function ( $items, $args ) use ( $wpide_location ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) || empty( $args->theme_location ) || $args->theme_location !== $wpide_location ) {\n        return $items;\n    }\n\n    $languages = apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 1 ) );\n\n    if ( empty( $languages ) || ! is_array( $languages ) ) {\n        return $items;\n    }\n\n    foreach ( $languages as $lang ) {\n        if ( ! empty( $lang['active'] ) ) {\n            continue;\n        }\n        $items .= sprintf(\n            '<li class=\"menu-item menu-item-language menu-item-language-%1$s\"><a href=\"%2$s\" hreflang=\"%3$s\" lang=\"%3$s\">%4$s</a></li>',\n            esc_attr( $lang['language_code'] ),\n            esc_url( $lang['url'] ),\n            esc_attr( $lang['tag'] ?? $lang['language_code'] ),\n            esc_html( $lang['native_name'] )\n        );\n    }\n\n    return $items;\n}, 10, 2 );",
            "requires": [
                "wpml"
            ],
            "notes": "Set $wpide_location to your theme's menu location (see Appearance > Menus > Manage Locations). Languages without a translation of the page are skipped."
        },
        {
            "id": "wpml-translatable-announcement",
            "version": 1,
            "title": "Translatable Announcement Bar With WPML",
            "description": "Shows a slim announcement bar at the top of every page whose text you translate in WPML > String Translation, so each language gets its own message.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "announcement",
                "string-translation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_text = 'Free shipping on orders over $50 this week only.'; // Default-language text.\n$wpide_link = '/shop/';                                          // Optional link, or ''.\n\nadd_action( 'admin_init', function () use ( $wpide_text, $wpide_link ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n    do_action( 'wpml_register_single_string', 'WPIDE Snippets', 'Announcement text', $wpide_text );\n    if ( $wpide_link ) {\n        do_action( 'wpml_register_single_string', 'WPIDE Snippets', 'Announcement link', $wpide_link );\n    }\n} );\n\nadd_action( 'wp_body_open', function () use ( $wpide_text, $wpide_link ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n\n    $text = apply_filters( 'wpml_translate_single_string', $wpide_text, 'WPIDE Snippets', 'Announcement text' );\n    $link = $wpide_link ? apply_filters( 'wpml_translate_single_string', $wpide_link, 'WPIDE Snippets', 'Announcement link' ) : '';\n\n    if ( '' === trim( (string) $text ) ) {\n        return;\n    }\n\n    if ( $link && 0 === strpos( $link, '/' ) ) {\n        $link = apply_filters( 'wpml_permalink', home_url( $link ) );\n    }\n\n    echo '<div class=\"wpide-announcement\" role=\"region\" aria-label=\"' . esc_attr__( 'Announcement' ) . '\" style=\"background:#1d2327;color:#fff;text-align:center;padding:8px 16px;font-size:14px\">';\n    echo $link ? '<a href=\"' . esc_url( $link ) . '\" style=\"color:inherit\">' . esc_html( $text ) . '</a>' : esc_html( $text );\n    echo '</div>';\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Edit $wpide_text and $wpide_link, visit the dashboard once, then translate the strings under the \"WPIDE Snippets\" domain in String Translation."
        },
        {
            "id": "wpml-translate-theme-mods",
            "version": 1,
            "title": "Translate Customizer Text Settings With WPML",
            "description": "Registers chosen Customizer (theme_mod) text settings, such as a footer copyright or header phone number, in WPML String Translation and shows the translated value in each language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "customizer",
                "string-translation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mods = array( 'footer_text', 'header_phone' ); // theme_mod names to translate.\n\nadd_action( 'customize_save_after', function () use ( $wpide_mods ) {\n    foreach ( $wpide_mods as $mod ) {\n        $value = get_theme_mod( $mod );\n        if ( is_string( $value ) && '' !== $value ) {\n            do_action( 'wpml_register_single_string', 'Theme mods', $mod, $value );\n        }\n    }\n} );\n\nadd_action( 'admin_init', function () use ( $wpide_mods ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) || get_transient( 'wpide_wpml_mods_registered' ) ) {\n        return;\n    }\n    foreach ( $wpide_mods as $mod ) {\n        $value = get_theme_mod( $mod );\n        if ( is_string( $value ) && '' !== $value ) {\n            do_action( 'wpml_register_single_string', 'Theme mods', $mod, $value );\n        }\n    }\n    set_transient( 'wpide_wpml_mods_registered', 1, DAY_IN_SECONDS );\n} );\n\nif ( ! is_admin() ) {\n    foreach ( $wpide_mods as $wpide_mod ) {\n        add_filter( 'theme_mod_' . $wpide_mod, function ( $value ) use ( $wpide_mod ) {\n            return is_string( $value ) && '' !== $value\n                ? apply_filters( 'wpml_translate_single_string', $value, 'Theme mods', $wpide_mod )\n                : $value;\n        } );\n    }\n}",
            "requires": [
                "wpml"
            ],
            "notes": "List the theme_mod names in $wpide_mods (find them in your theme's customizer code). Strings refresh in String Translation whenever the Customizer is saved."
        },
        {
            "id": "wpml-translate-plugin-option",
            "version": 1,
            "title": "Make Any Text Option Translatable With WPML",
            "description": "Registers the text of selected site options, for example a plugin's notice text or a cookie banner message, in WPML String Translation and returns the right translation on the front end.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "options",
                "string-translation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_options = array( 'my_plugin_notice_text' ); // Option names holding plain text.\n\nadd_action( 'admin_init', function () use ( $wpide_options ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n    foreach ( $wpide_options as $option ) {\n        $value = get_option( $option );\n        $hash  = md5( (string) maybe_serialize( $value ) );\n        if ( is_string( $value ) && '' !== $value && get_option( 'wpide_wpml_opt_' . $option ) !== $hash ) {\n            do_action( 'wpml_register_single_string', 'Site options', $option, $value );\n            update_option( 'wpide_wpml_opt_' . $option, $hash, false );\n        }\n    }\n} );\n\nif ( ! is_admin() ) {\n    foreach ( $wpide_options as $wpide_option ) {\n        add_filter( 'option_' . $wpide_option, function ( $value ) use ( $wpide_option ) {\n            return is_string( $value ) && '' !== $value\n                ? apply_filters( 'wpml_translate_single_string', $value, 'Site options', $wpide_option )\n                : $value;\n        } );\n    }\n}",
            "requires": [
                "wpml"
            ],
            "notes": "Only works for options stored as plain strings. For array options, use WPML's own Admin Texts screen instead."
        },
        {
            "id": "wpml-404-to-language-home",
            "version": 1,
            "title": "Send Secondary-Language 404s to That Language's Homepage",
            "description": "When a visitor hits a missing page in a non-default WPML language, sends them to the homepage of that language instead of a 404, keeping them in their language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "404",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() || ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n\n    $current = apply_filters( 'wpml_current_language', null );\n    $default = apply_filters( 'wpml_default_language', null );\n\n    if ( ! $current || $current === $default || 'all' === $current ) {\n        return;\n    }\n\n    wp_safe_redirect( apply_filters( 'wpml_home_url', home_url( '/' ) ), 302 );\n    exit;\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Uses a 302 redirect so search engines keep reporting real broken links; change to 301 if you prefer. The default language keeps its normal 404 page."
        },
        {
            "id": "wpml-menu-per-language",
            "version": 1,
            "title": "Use a Different Menu Per Language in WPML",
            "description": "Shows a completely different menu in a theme location for each WPML language, for sites whose language versions have different structures rather than translated copies.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "menus",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_menus = array( // Theme location => array( language code => menu ID ).\n    'primary' => array(\n        // 'fr' => 12,\n        // 'de' => 15,\n    ),\n);\n\nadd_filter( 'wp_nav_menu_args', function ( $args ) use ( $wpide_menus ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) || empty( $args['theme_location'] ) || ! isset( $wpide_menus[ $args['theme_location'] ] ) ) {\n        return $args;\n    }\n\n    $lang = apply_filters( 'wpml_current_language', null );\n    $map  = $wpide_menus[ $args['theme_location'] ];\n\n    if ( $lang && ! empty( $map[ $lang ] ) && wp_get_nav_menu_object( (int) $map[ $lang ] ) ) {\n        $args['menu'] = (int) $map[ $lang ]; // An explicit menu wins over the location's assigned menu.\n    }\n\n    return $args;\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Map location => language => menu ID (find IDs in Appearance > Menus, in the URL). Languages not listed keep WPML's usual translated menu."
        },
        {
            "id": "wpml-hreflang-x-default",
            "version": 1,
            "title": "Choose the x-default Language for WPML hreflang Tags",
            "description": "Points WPML's x-default hreflang tag at the language you choose, for example English, instead of the site's default language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "hreflang",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_x_default = 'en'; // hreflang code to use as x-default.\n\nadd_filter( 'wpml_hreflangs', function ( $hreflangs ) use ( $wpide_x_default ) {\n    if ( is_array( $hreflangs ) && ! empty( $hreflangs[ $wpide_x_default ] ) ) {\n        $hreflangs['x-default'] = $hreflangs[ $wpide_x_default ];\n    }\n\n    return $hreflangs;\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Use the hreflang code as WPML prints it (en, en-us, fr…). If that language has no translation of the page, the tag is left as WPML set it."
        },
        {
            "id": "wpml-hreflang-skip-paged",
            "version": 1,
            "title": "Remove WPML hreflang Tags From Paginated and Filtered Pages",
            "description": "Stops WPML from printing hreflang tags on page 2+ of archives, search results and URLs with query strings, where the alternates often point at mismatched pages.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "hreflang",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpml_hreflangs', function ( $hreflangs ) {\n    if ( is_paged() || is_search() || ( ! empty( $_GET ) && ! is_singular() ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return array();\n    }\n\n    return $hreflangs;\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "The first page of each archive and every singular page keep their hreflang tags."
        },
        {
            "id": "wpml-sync-custom-fields",
            "version": 1,
            "title": "Copy Custom Fields to All WPML Translations on Save",
            "description": "Copies the custom fields you list from an original post to all of its WPML translations whenever the original is saved, for values like prices, SKUs or coordinates that never change between languages.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "custom-fields",
                "sync"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_keys = array( 'price', 'sku', 'latitude', 'longitude' ); // Meta keys to copy.\n\nadd_action( 'save_post', function ( $post_id, $post ) use ( $wpide_keys ) {\n    static $running = false;\n\n    if ( $running || ! defined( 'ICL_SITEPRESS_VERSION' ) || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $type    = apply_filters( 'wpml_element_type', $post->post_type );\n    $details = apply_filters( 'wpml_element_language_details', null, array( 'element_id' => $post_id, 'element_type' => $type ) );\n\n    if ( ! $details || ! empty( $details->source_language_code ) || empty( $details->trid ) ) {\n        return; // Not an original post.\n    }\n\n    $translations = apply_filters( 'wpml_get_element_translations', null, $details->trid, $type );\n\n    if ( empty( $translations ) || ! is_array( $translations ) ) {\n        return;\n    }\n\n    $running = true;\n    foreach ( $translations as $translation ) {\n        $target = (int) $translation->element_id;\n        if ( $target === (int) $post_id ) {\n            continue;\n        }\n        foreach ( $wpide_keys as $key ) {\n            if ( metadata_exists( 'post', $post_id, $key ) ) {\n                update_post_meta( $target, $key, get_post_meta( $post_id, $key, true ) );\n            } else {\n                delete_post_meta( $target, $key );\n            }\n        }\n    }\n    $running = false;\n}, 20, 2 );",
            "requires": [
                "wpml"
            ],
            "notes": "Only runs when saving the original (source-language) post. For fields WPML already manages, set them to \"Copy\" in WPML > Settings > Custom Fields Translation instead."
        },
        {
            "id": "wpml-translated-field-shortcode",
            "version": 1,
            "title": "Show a Custom Field From the Right WPML Translation",
            "description": "Adds a [wpide_wpml_field] shortcode that displays a custom field from the current-language version of any post or page, so one shortcode works in every language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "custom-fields",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_wpml_field', function ( $atts ) {\n    $atts = shortcode_atts( array( 'key' => '', 'id' => 0 ), $atts, 'wpide_wpml_field' );\n    $key  = sanitize_key( $atts['key'] );\n    $id   = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n\n    if ( '' === $key || ! $id || is_protected_meta( $key, 'post' ) ) {\n        return '';\n    }\n\n    if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        $id = (int) apply_filters( 'wpml_object_id', $id, get_post_type( $id ), true );\n    }\n\n    $value = get_post_meta( $id, $key, true );\n\n    return is_scalar( $value ) ? wp_kses_post( (string) $value ) : '';\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Use [wpide_wpml_field key=\"opening_hours\" id=\"42\"]; leave out id to use the current post. Falls back to the original post when no translation exists."
        },
        {
            "id": "wpml-translation-link-shortcode",
            "version": 1,
            "title": "Link to the Current-Language Version of Any Page",
            "description": "Adds a [wpide_wpml_link id=\"123\"] shortcode that links to the translation of a page in the language being viewed, using that translation's title, so internal links never send visitors to the wrong language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "links",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_wpml_link', function ( $atts ) {\n    $atts = shortcode_atts( array( 'id' => 0, 'text' => '' ), $atts, 'wpide_wpml_link' );\n    $id   = absint( $atts['id'] );\n\n    if ( ! $id || ! get_post( $id ) ) {\n        return '';\n    }\n\n    if ( defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        $id = (int) apply_filters( 'wpml_object_id', $id, get_post_type( $id ), true );\n    }\n\n    if ( 'publish' !== get_post_status( $id ) ) {\n        return '';\n    }\n\n    return sprintf( '<a href=\"%1$s\">%2$s</a>', esc_url( get_permalink( $id ) ), esc_html( '' !== $atts['text'] ? $atts['text'] : get_the_title( $id ) ) );\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Add text=\"...\" to set your own link text (it can be translated with the page). Falls back to the original page if there is no translation."
        },
        {
            "id": "wpml-fallback-language-notice",
            "version": 1,
            "title": "Notice When WPML Shows Content in Another Language",
            "description": "Adds a short notice above posts that WPML displays in the default language because no translation exists yet (\"display as translated\" mode), so visitors know why the text is not in their language.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "notices",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_notice = 'This content is not available in your language yet, so we are showing the original version.';\n\nadd_action( 'admin_init', function () use ( $wpide_notice ) {\n    do_action( 'wpml_register_single_string', 'WPIDE Snippets', 'Fallback language notice', $wpide_notice );\n} );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_notice ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) || is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $details = apply_filters( 'wpml_post_language_details', null, get_the_ID() );\n    $current = apply_filters( 'wpml_current_language', null );\n\n    if ( ! is_array( $details ) || empty( $details['language_code'] ) || $details['language_code'] === $current ) {\n        return $content;\n    }\n\n    $text = apply_filters( 'wpml_translate_single_string', $wpide_notice, 'WPIDE Snippets', 'Fallback language notice' );\n\n    return '<p class=\"wpide-fallback-notice\" role=\"note\" style=\"padding:.75em 1em;border-inline-start:4px solid #dba617;background:rgba(219,166,23,.1)\">' . esc_html( $text ) . '</p>'\n        . '<div lang=\"' . esc_attr( $details['language_code'] ) . '\">' . $content . '</div>';\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Translate the notice text through String Translation under the \"WPIDE Snippets\" domain after visiting the dashboard once."
        },
        {
            "id": "wpml-rest-language-fields",
            "version": 1,
            "title": "Add Language and Translations to the REST API (WPML)",
            "description": "Adds wpml_language and wpml_translations fields to posts and pages in the REST API, so headless front ends and apps know each item's language and the IDs of its translations.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "rest-api",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_types = array( 'post', 'page' ); // Post types to extend.\n\nadd_action( 'rest_api_init', function () use ( $wpide_types ) {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n\n    register_rest_field( $wpide_types, 'wpml_language', array(\n        'get_callback' => function ( $post ) {\n            $details = apply_filters( 'wpml_post_language_details', null, $post['id'] );\n            return is_array( $details ) ? array( 'code' => $details['language_code'], 'locale' => $details['locale'] ) : null;\n        },\n        'schema'       => array( 'type' => array( 'object', 'null' ), 'context' => array( 'view', 'edit' ) ),\n    ) );\n\n    register_rest_field( $wpide_types, 'wpml_translations', array(\n        'get_callback' => function ( $post ) {\n            $type = apply_filters( 'wpml_element_type', $post['type'] );\n            $trid = apply_filters( 'wpml_element_trid', null, $post['id'], $type );\n            $out  = array();\n            if ( $trid ) {\n                foreach ( (array) apply_filters( 'wpml_get_element_translations', null, $trid, $type ) as $code => $item ) {\n                    if ( isset( $item->element_id ) ) {\n                        $out[ $code ] = (int) $item->element_id;\n                    }\n                }\n            }\n            return (object) $out;\n        },\n        'schema'       => array( 'type' => 'object', 'context' => array( 'view', 'edit' ) ),\n    ) );\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Add custom post types to $wpide_types. The translations field maps language codes to post IDs."
        },
        {
            "id": "wpml-missing-translations-widget",
            "version": 1,
            "title": "Dashboard Widget: Posts Missing WPML Translations",
            "description": "Adds a dashboard widget that lists your 20 most recent posts and pages in the default language with the languages they still need translating into.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "dashboard",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! defined( 'ICL_SITEPRESS_VERSION' ) || ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_wpml_missing', __( 'Missing translations' ), function () {\n        $rows = get_transient( 'wpide_wpml_missing' );\n\n        if ( false === $rows ) {\n            $rows      = array();\n            $languages = array_keys( (array) apply_filters( 'wpml_active_languages', null, array( 'skip_missing' => 0 ) ) );\n            $default   = apply_filters( 'wpml_default_language', null );\n\n            do_action( 'wpml_switch_language', $default );\n            $ids = get_posts( array(\n                'post_type'        => array( 'post', 'page' ),\n                'numberposts'      => 20,\n                'fields'           => 'ids',\n                'no_found_rows'    => true,\n                'suppress_filters' => false,\n            ) );\n            do_action( 'wpml_switch_language', null );\n\n            foreach ( $ids as $id ) {\n                $type    = apply_filters( 'wpml_element_type', get_post_type( $id ) );\n                $trid    = apply_filters( 'wpml_element_trid', null, $id, $type );\n                $have    = $trid ? array_keys( (array) apply_filters( 'wpml_get_element_translations', null, $trid, $type ) ) : array();\n                $missing = array_diff( $languages, $have );\n                if ( $missing ) {\n                    $rows[] = array( 'id' => $id, 'missing' => array_values( $missing ) );\n                }\n            }\n            set_transient( 'wpide_wpml_missing', $rows, HOUR_IN_SECONDS );\n        }\n\n        if ( ! $rows ) {\n            echo '<p>' . esc_html__( 'Everything recent is translated.' ) . '</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $rows as $row ) {\n            printf(\n                '<li><a href=\"%1$s\">%2$s</a> <span style=\"color:#646970\">%3$s</span></li>',\n                esc_url( (string) get_edit_post_link( $row['id'] ) ),\n                esc_html( get_the_title( $row['id'] ) ),\n                esc_html( strtoupper( implode( ', ', $row['missing'] ) ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );\n\nadd_action( 'save_post', function () {\n    delete_transient( 'wpide_wpml_missing' );\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Cached for an hour and refreshed whenever a post is saved. Only editors and administrators see it."
        },
        {
            "id": "wpml-string-shortcode",
            "version": 1,
            "title": "Translatable Text Shortcode for WPML",
            "description": "Adds a [wpide_t name=\"...\"]Text[/wpide_t] shortcode that registers its text in WPML String Translation and shows the translation, ideal for short phrases in widgets, footers and page builder text.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "string-translation",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_t', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'name' => '' ), $atts, 'wpide_t' );\n    $name = sanitize_text_field( $atts['name'] );\n    $text = trim( (string) $content );\n\n    if ( '' === $name || '' === $text || ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return do_shortcode( $text );\n    }\n\n    if ( current_user_can( 'manage_options' ) ) {\n        do_action( 'wpml_register_single_string', 'Shortcode text', $name, $text );\n    }\n\n    return do_shortcode( (string) apply_filters( 'wpml_translate_single_string', $text, 'Shortcode text', $name ) );\n} );",
            "requires": [
                "wpml"
            ],
            "notes": "Give every phrase a unique name. Strings register the first time an administrator views a page that uses them."
        },
        {
            "id": "wpml-admin-bar-translations",
            "version": 1,
            "title": "Edit Translations From the Admin Bar (WPML)",
            "description": "Adds a \"Translations\" menu to the toolbar on single posts and pages with a link to edit each language version, so editors can hop between translations without searching.",
            "category": "multilingual",
            "tags": [
                "wpml",
                "admin-bar",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! is_singular() || ! defined( 'ICL_SITEPRESS_VERSION' ) ) {\n        return;\n    }\n\n    $id = get_queried_object_id();\n\n    if ( ! current_user_can( 'edit_post', $id ) ) {\n        return;\n    }\n\n    $type         = apply_filters( 'wpml_element_type', get_post_type( $id ) );\n    $trid         = apply_filters( 'wpml_element_trid', null, $id, $type );\n    $translations = $trid ? (array) apply_filters( 'wpml_get_element_translations', null, $trid, $type ) : array();\n\n    if ( count( $translations ) < 2 ) {\n        return;\n    }\n\n    $bar->add_node( array( 'id' => 'wpide-wpml-tr', 'title' => esc_html__( 'Translations' ) ) );\n\n    foreach ( $translations as $code => $item ) {\n        $link = get_edit_post_link( (int) $item->element_id, 'raw' );\n        if ( $link ) {\n            $bar->add_node( array(\n                'parent' => 'wpide-wpml-tr',\n                'id'     => 'wpide-wpml-tr-' . sanitize_key( $code ),\n                'title'  => esc_html( strtoupper( $code ) . ( (int) $item->element_id === $id ? ' (this page)' : '' ) ),\n                'href'   => $link,\n            ) );\n        }\n    }\n}, 90 );",
            "requires": [
                "wpml"
            ],
            "notes": "Only languages that already have a translation are listed, and only for users who can edit the post."
        },
        {
            "id": "pll-language-switcher-shortcode",
            "version": 1,
            "title": "Polylang Language Switcher Shortcode",
            "description": "Adds a [wpide_pll_switcher] shortcode that prints an accessible Polylang language list or dropdown anywhere, with flags, names or both.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "language-switcher",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_pll_switcher', function ( $atts ) {\n    if ( ! function_exists( 'pll_the_languages' ) ) {\n        return '';\n    }\n\n    $atts = shortcode_atts( array(\n        'dropdown'          => '0',\n        'flags'             => '0',\n        'names'             => '1',\n        'hide_current'      => '0',\n        'hide_untranslated' => '0',\n    ), $atts, 'wpide_pll_switcher' );\n\n    $languages = pll_the_languages( array( 'raw' => 1, 'hide_if_empty' => 0 ) );\n\n    if ( empty( $languages ) || ! is_array( $languages ) ) {\n        return '';\n    }\n\n    $languages = array_filter( $languages, function ( $lang ) use ( $atts ) {\n        if ( $atts['hide_current'] && ! empty( $lang['current_lang'] ) ) {\n            return false;\n        }\n        return ! ( $atts['hide_untranslated'] && ! empty( $lang['no_translation'] ) && empty( $lang['current_lang'] ) );\n    } );\n\n    if ( $atts['dropdown'] ) {\n        $id   = wp_unique_id( 'wpide-pll-ls-' );\n        $html = '<label class=\"screen-reader-text\" for=\"' . esc_attr( $id ) . '\">' . esc_html__( 'Language' ) . '</label><select id=\"' . esc_attr( $id ) . '\" class=\"wpide-pll-switcher\" onchange=\"if(this.value){window.location.href=this.value;}\">';\n        foreach ( $languages as $lang ) {\n            $html .= sprintf( '<option value=\"%1$s\" lang=\"%2$s\"%3$s>%4$s</option>', esc_url( $lang['url'] ), esc_attr( $lang['locale'] ), selected( ! empty( $lang['current_lang'] ), true, false ), esc_html( $lang['name'] ) );\n        }\n        return $html . '</select>';\n    }\n\n    $html = '<nav class=\"wpide-pll-switcher\" aria-label=\"' . esc_attr__( 'Language' ) . '\"><ul style=\"list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:.75em\">';\n    foreach ( $languages as $lang ) {\n        $inner = '';\n        if ( $atts['flags'] && ! empty( $lang['flag'] ) ) {\n            $inner .= wp_kses( $lang['flag'], array( 'img' => array( 'src' => true, 'alt' => true, 'width' => true, 'height' => true, 'style' => true ) ) ) . ' ';\n        }\n        if ( $atts['names'] || ! $atts['flags'] ) {\n            $inner .= esc_html( $lang['name'] );\n        } else {\n            $inner .= '<span class=\"screen-reader-text\">' . esc_html( $lang['name'] ) . '</span>';\n        }\n        $html .= sprintf(\n            '<li><a href=\"%1$s\" hreflang=\"%2$s\" lang=\"%2$s\"%3$s>%4$s</a></li>',\n            esc_url( $lang['url'] ),\n            esc_attr( $lang['locale'] ),\n            ! empty( $lang['current_lang'] ) ? ' aria-current=\"true\" style=\"font-weight:700\"' : '',\n            $inner\n        );\n    }\n\n    return $html . '</ul></nav>';\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Attributes: dropdown=\"1\", flags=\"1\", names=\"1\", hide_current=\"1\", hide_untranslated=\"1\" (skip languages without a translation of this page)."
        },
        {
            "id": "pll-register-strings",
            "version": 1,
            "title": "Translatable Phrases With Polylang Strings",
            "description": "Registers a list of your own phrases in Languages > Translations and adds a [wpide_pll_string name=\"...\"] shortcode to show each one in the current language.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "strings",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_strings = array( // Name => default-language text.\n    'opening_hours' => 'Open Monday to Friday, 9am to 5pm',\n    'cta_button'    => 'Book a free consultation',\n);\n\nadd_action( 'init', function () use ( $wpide_strings ) {\n    if ( ! is_admin() || ! function_exists( 'pll_register_string' ) ) {\n        return;\n    }\n    foreach ( $wpide_strings as $name => $text ) {\n        pll_register_string( $name, $text, 'WPIDE' );\n    }\n} );\n\nadd_shortcode( 'wpide_pll_string', function ( $atts ) use ( $wpide_strings ) {\n    $atts = shortcode_atts( array( 'name' => '' ), $atts, 'wpide_pll_string' );\n\n    if ( ! isset( $wpide_strings[ $atts['name'] ] ) ) {\n        return '';\n    }\n\n    $text = $wpide_strings[ $atts['name'] ];\n\n    return esc_html( function_exists( 'pll__' ) ? pll__( $text ) : $text );\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Add phrases to $wpide_strings, then translate them in Languages > Translations under the \"WPIDE\" group. In PHP templates you can also call pll__( 'the phrase' )."
        },
        {
            "id": "pll-translate-theme-mods",
            "version": 1,
            "title": "Polylang Translations for Customizer Settings",
            "description": "Registers chosen Customizer text settings, such as footer copyright or a header phone number, in Polylang's string translations and shows the right translation in each language.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "customizer",
                "strings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mods = array( 'footer_text', 'header_phone' ); // theme_mod names to translate.\n\nadd_action( 'init', function () use ( $wpide_mods ) {\n    if ( ! is_admin() || ! function_exists( 'pll_register_string' ) ) {\n        return;\n    }\n    foreach ( $wpide_mods as $mod ) {\n        $value = get_theme_mod( $mod );\n        if ( is_string( $value ) && '' !== $value ) {\n            pll_register_string( $mod, $value, 'Theme mods', false !== strpos( $value, \"\\n\" ) );\n        }\n    }\n} );\n\nif ( ! is_admin() ) {\n    foreach ( $wpide_mods as $wpide_mod ) {\n        add_filter( 'theme_mod_' . $wpide_mod, function ( $value ) {\n            return is_string( $value ) && '' !== $value && function_exists( 'pll__' ) ? pll__( $value ) : $value;\n        } );\n    }\n}",
            "requires": [
                "polylang"
            ],
            "notes": "List the theme_mod names in $wpide_mods, save the Customizer, then translate them in Languages > Translations under \"Theme mods\"."
        },
        {
            "id": "pll-translate-option",
            "version": 1,
            "title": "Polylang: Translate Plugin and Site Text Options",
            "description": "Registers the text stored in selected options, such as a plugin's banner text or a footer notice, in Polylang's string translations and returns the translation on the front end.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "options",
                "strings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_options = array( 'my_plugin_notice_text' ); // Option names holding plain text.\n\nadd_action( 'init', function () use ( $wpide_options ) {\n    if ( ! is_admin() || ! function_exists( 'pll_register_string' ) ) {\n        return;\n    }\n    foreach ( $wpide_options as $option ) {\n        $value = get_option( $option );\n        if ( is_string( $value ) && '' !== $value ) {\n            pll_register_string( $option, $value, 'Site options', false !== strpos( $value, \"\\n\" ) );\n        }\n    }\n} );\n\nif ( ! is_admin() ) {\n    foreach ( $wpide_options as $wpide_option ) {\n        add_filter( 'option_' . $wpide_option, function ( $value ) {\n            return is_string( $value ) && '' !== $value && function_exists( 'pll__' ) ? pll__( $value ) : $value;\n        } );\n    }\n}",
            "requires": [
                "polylang"
            ],
            "notes": "Works for options that hold a plain string. Find option names in Tools > Site Health > Info or in the plugin's settings form field names."
        },
        {
            "id": "pll-404-to-language-home",
            "version": 1,
            "title": "Send Polylang 404s to the Visitor's Language Homepage",
            "description": "Redirects visitors who hit a missing page in a secondary Polylang language to that language's homepage, so they are not dropped onto a 404 page.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "404",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() || ! function_exists( 'pll_current_language' ) ) {\n        return;\n    }\n\n    $current = pll_current_language();\n\n    if ( ! $current || pll_default_language() === $current ) {\n        return;\n    }\n\n    wp_safe_redirect( pll_home_url( $current ), 302 );\n    exit;\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Uses a 302 so your 404 reports still show broken links. The default language keeps the normal 404 page."
        },
        {
            "id": "pll-hreflang-x-default",
            "version": 1,
            "title": "Polylang hreflang: Set Which Language Is x-default",
            "description": "Adds or replaces the x-default hreflang link Polylang prints, pointing it at the language you choose so search engines know which version to show everyone else.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "hreflang",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_x_default = 'en'; // hreflang code to use as x-default.\n\nadd_filter( 'pll_rel_hreflang_attributes', function ( $hreflangs ) use ( $wpide_x_default ) {\n    if ( is_array( $hreflangs ) && ! empty( $hreflangs[ $wpide_x_default ] ) ) {\n        $hreflangs['x-default'] = $hreflangs[ $wpide_x_default ];\n    }\n\n    return $hreflangs;\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Use the hreflang code Polylang prints for that language (often en or en-US; view your page source to check)."
        },
        {
            "id": "pll-copy-custom-fields",
            "version": 1,
            "title": "Choose Which Custom Fields Polylang Copies to Translations",
            "description": "Tells Polylang to copy (and keep in sync) extra custom fields when you create or update a translation, and to stop copying fields that should differ per language.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "custom-fields",
                "sync"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_copy = array( 'price', 'sku', 'event_date' );         // Copy to translations.\n$wpide_skip = array( '_yoast_wpseo_title', 'seo_description' ); // Never copy.\n\nadd_filter( 'pll_copy_post_metas', function ( $metas ) use ( $wpide_copy, $wpide_skip ) {\n    $metas = array_merge( (array) $metas, $wpide_copy );\n\n    return array_values( array_diff( array_unique( $metas ), $wpide_skip ) );\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "$wpide_copy are always copied; $wpide_skip are never copied. Syncing happens when \"Custom fields\" is ticked in Languages > Settings > Synchronization."
        },
        {
            "id": "pll-copy-term-meta",
            "version": 1,
            "title": "Copy Term Meta to Polylang Category Translations",
            "description": "Copies chosen term meta, such as a category color, icon or display order, to the translations of categories and tags when they are created or updated.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "taxonomies",
                "term-meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_keys = array( 'color', 'icon', 'order' ); // Term meta keys to copy.\n\nadd_filter( 'pll_copy_term_metas', function ( $metas ) use ( $wpide_keys ) {\n    return array_values( array_unique( array_merge( (array) $metas, $wpide_keys ) ) );\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Add your term meta keys to $wpide_keys."
        },
        {
            "id": "pll-translatable-post-types",
            "version": 1,
            "title": "Make Custom Post Types Translatable in Polylang",
            "description": "Turns on Polylang translation for custom post types registered by your theme or a plugin, even when they do not appear in Languages > Settings.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "post-types",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_types = array( 'book', 'portfolio' ); // Post type keys.\n\nadd_filter( 'pll_get_post_types', function ( $post_types, $is_settings ) use ( $wpide_types ) {\n    foreach ( $wpide_types as $type ) {\n        if ( $is_settings ) {\n            unset( $post_types[ $type ] ); // Managed here, so hide it from the settings list.\n        } else {\n            $post_types[ $type ] = $type;\n        }\n    }\n\n    return $post_types;\n}, 10, 2 );",
            "requires": [
                "polylang"
            ],
            "notes": "Use the post type keys (e.g. book, portfolio). They disappear from Polylang's settings list because this snippet now manages them."
        },
        {
            "id": "pll-translatable-taxonomies",
            "version": 1,
            "title": "Make Custom Taxonomies Translatable in Polylang",
            "description": "Turns on Polylang translation for custom taxonomies, such as product types or event categories, that are not offered in Languages > Settings.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "taxonomies",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_taxonomies = array( 'genre', 'location' ); // Taxonomy keys.\n\nadd_filter( 'pll_get_taxonomies', function ( $taxonomies, $is_settings ) use ( $wpide_taxonomies ) {\n    foreach ( $wpide_taxonomies as $taxonomy ) {\n        if ( $is_settings ) {\n            unset( $taxonomies[ $taxonomy ] );\n        } else {\n            $taxonomies[ $taxonomy ] = $taxonomy;\n        }\n    }\n\n    return $taxonomies;\n}, 10, 2 );",
            "requires": [
                "polylang"
            ],
            "notes": "Use the taxonomy keys; they are hidden from Polylang's settings list because this snippet manages them. Translate existing terms afterwards in each taxonomy's screen."
        },
        {
            "id": "pll-disable-home-redirect",
            "version": 1,
            "title": "Stop Polylang Redirecting the Homepage by Browser Language",
            "description": "Keeps visitors on the homepage they asked for instead of Polylang redirecting them to another language based on their browser, which also avoids caching and SEO surprises.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "redirect",
                "homepage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pll_redirect_home', '__return_false' );",
            "requires": [
                "polylang"
            ],
            "notes": "Visitors can still switch language with your language switcher."
        },
        {
            "id": "pll-preferred-language-from-profile",
            "version": 1,
            "title": "Use Each User's Profile Language as Their Polylang Language",
            "description": "Makes Polylang pick the language set in a logged-in user's profile when it decides which language to show them on the homepage, instead of relying on browser settings.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "users",
                "locale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pll_preferred_language', function ( $slug ) {\n    if ( ! is_user_logged_in() || ! function_exists( 'pll_languages_list' ) ) {\n        return $slug;\n    }\n\n    $user_locale = get_user_meta( get_current_user_id(), 'locale', true );\n\n    if ( ! $user_locale ) {\n        return $slug;\n    }\n\n    $locales = pll_languages_list( array( 'fields' => 'locale' ) );\n    $slugs   = pll_languages_list( array( 'fields' => 'slug' ) );\n    $index   = array_search( $user_locale, $locales, true );\n\n    return false !== $index && isset( $slugs[ $index ] ) ? $slugs[ $index ] : $slug;\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Only applies when the profile language is one of your Polylang languages; otherwise Polylang's normal detection runs."
        },
        {
            "id": "pll-available-in-links",
            "version": 1,
            "title": "\"Also Available In\" Links Above Polylang Posts",
            "description": "Shows a short line above each post or page listing the other languages it has been translated into, with links, so visitors who arrive in the wrong language can switch straight away.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "translations",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_label = 'Also available in:'; // Text before the links.\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_label ) {\n    if ( is_admin() || ! is_singular() || ! in_the_loop() || ! is_main_query() || ! function_exists( 'pll_get_post_translations' ) ) {\n        return $content;\n    }\n\n    $current      = pll_current_language();\n    $translations = pll_get_post_translations( get_the_ID() );\n    $names        = array_combine( pll_languages_list( array( 'fields' => 'slug' ) ), pll_languages_list( array( 'fields' => 'name' ) ) );\n    $links        = array();\n\n    foreach ( $translations as $slug => $post_id ) {\n        if ( $slug === $current || 'publish' !== get_post_status( $post_id ) ) {\n            continue;\n        }\n        $links[] = sprintf( '<a href=\"%1$s\" hreflang=\"%2$s\" lang=\"%2$s\">%3$s</a>', esc_url( get_permalink( $post_id ) ), esc_attr( $slug ), esc_html( isset( $names[ $slug ] ) ? $names[ $slug ] : strtoupper( $slug ) ) );\n    }\n\n    if ( ! $links ) {\n        return $content;\n    }\n\n    $label = function_exists( 'pll__' ) ? pll__( $wpide_label ) : $wpide_label;\n\n    return '<p class=\"wpide-pll-available\">' . esc_html( $label ) . ' ' . implode( ', ', $links ) . '</p>' . $content;\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Hidden when the post has no translations. Change $wpide_label, or register it as a Polylang string to translate it."
        },
        {
            "id": "pll-search-all-languages",
            "version": 1,
            "title": "Search Across All Languages With Polylang",
            "description": "Makes the site search return results from every Polylang language instead of only the current one, so visitors find content that has not been translated yet.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "search",
                "languages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_search() && function_exists( 'pll_current_language' ) ) {\n        $query->set( 'lang', '' );\n    }\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Each result still links to the post in its own language."
        },
        {
            "id": "pll-rest-language-fields",
            "version": 1,
            "title": "Polylang Language Fields in the REST API",
            "description": "Adds pll_language and pll_translations fields to posts and pages in the REST API, which Polylang's free version does not provide, so headless sites and apps can link translations.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "rest-api",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_types = array( 'post', 'page' ); // Post types to extend.\n\nadd_action( 'rest_api_init', function () use ( $wpide_types ) {\n    if ( ! function_exists( 'pll_get_post_language' ) ) {\n        return;\n    }\n\n    register_rest_field( $wpide_types, 'pll_language', array(\n        'get_callback' => function ( $post ) {\n            $slug = pll_get_post_language( $post['id'] );\n            return $slug ? array( 'slug' => $slug, 'locale' => pll_get_post_language( $post['id'], 'locale' ) ) : null;\n        },\n        'schema'       => array( 'type' => array( 'object', 'null' ), 'context' => array( 'view', 'edit' ) ),\n    ) );\n\n    register_rest_field( $wpide_types, 'pll_translations', array(\n        'get_callback' => function ( $post ) {\n            return (object) array_map( 'intval', (array) pll_get_post_translations( $post['id'] ) );\n        },\n        'schema'       => array( 'type' => 'object', 'context' => array( 'view', 'edit' ) ),\n    ) );\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Add custom post types to $wpide_types. The translations field maps language slugs to post IDs."
        },
        {
            "id": "pll-translation-progress-widget",
            "version": 1,
            "title": "Dashboard Widget: Polylang Translation Progress",
            "description": "Adds a dashboard widget showing how many published posts and pages exist in each Polylang language, with a progress bar compared with the default language.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "dashboard",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'pll_count_posts' ) || ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_pll_progress', __( 'Translation progress' ), function () {\n        $data = get_transient( 'wpide_pll_progress' );\n\n        if ( false === $data ) {\n            $data = array();\n            foreach ( array( 'post', 'page' ) as $type ) {\n                foreach ( pll_languages_list( array( 'fields' => 'slug' ) ) as $slug ) {\n                    $data[ $type ][ $slug ] = (int) pll_count_posts( $slug, array( 'post_type' => $type, 'post_status' => 'publish' ) );\n                }\n            }\n            set_transient( 'wpide_pll_progress', $data, HOUR_IN_SECONDS );\n        }\n\n        $default = pll_default_language();\n\n        foreach ( $data as $type => $counts ) {\n            $base = max( 1, isset( $counts[ $default ] ) ? $counts[ $default ] : 1 );\n            echo '<h3>' . esc_html( get_post_type_object( $type )->labels->name ) . '</h3>';\n            foreach ( $counts as $slug => $count ) {\n                $pct = min( 100, (int) round( $count / $base * 100 ) );\n                printf(\n                    '<div style=\"display:flex;align-items:center;gap:8px;margin:4px 0\"><strong style=\"width:3em\">%1$s</strong><span style=\"flex:1;background:#f0f0f1;height:8px;border-radius:4px;overflow:hidden\" role=\"progressbar\" aria-valuenow=\"%2$d\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"%1$s\"><span style=\"display:block;height:100%%;width:%2$d%%;background:#2271b1\"></span></span><span style=\"width:4em;text-align:end\">%3$s</span></div>',\n                    esc_html( strtoupper( $slug ) ),\n                    (int) $pct,\n                    esc_html( number_format_i18n( $count ) )\n                );\n            }\n        }\n    } );\n} );\n\nadd_action( 'transition_post_status', function ( $new, $old ) {\n    if ( 'publish' === $new || 'publish' === $old ) {\n        delete_transient( 'wpide_pll_progress' );\n    }\n}, 10, 2 );",
            "requires": [
                "polylang"
            ],
            "notes": "Counts are cached for an hour and refreshed whenever content is published."
        },
        {
            "id": "pll-copy-content-to-new-translation",
            "version": 1,
            "title": "Start New Polylang Translations With the Original Content",
            "description": "Pre-fills the title, content and excerpt of a new translation with the source post's text when you click the \"+\" icon in Polylang, so translators can work over the original instead of an empty editor.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "translations",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_source = function () {\n    if ( empty( $_GET['from_post'] ) || empty( $_GET['new_lang'] ) || ! function_exists( 'pll_get_post_language' ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return null;\n    }\n\n    $post = get_post( absint( $_GET['from_post'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n\n    return $post && current_user_can( 'edit_post', $post->ID ) ? $post : null;\n};\n\nadd_filter( 'default_title', function ( $title ) use ( $wpide_source ) {\n    $post = $wpide_source();\n    return $post ? $post->post_title : $title;\n} );\n\nadd_filter( 'default_content', function ( $content ) use ( $wpide_source ) {\n    $post = $wpide_source();\n    return $post ? $post->post_content : $content;\n} );\n\nadd_filter( 'default_excerpt', function ( $excerpt ) use ( $wpide_source ) {\n    $post = $wpide_source();\n    return $post ? $post->post_excerpt : $excerpt;\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "Polylang's free version already copies taxonomies and custom fields; this adds the text. Only works for posts the user can edit."
        },
        {
            "id": "pll-admin-bar-translations",
            "version": 1,
            "title": "Polylang Translations Menu in the Toolbar",
            "description": "Adds a \"Translations\" toolbar menu on posts and pages with links to edit each existing language version, plus links to create the missing ones.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "admin-bar",
                "translations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! is_singular() || ! function_exists( 'pll_get_post_translations' ) ) {\n        return;\n    }\n\n    $id = get_queried_object_id();\n\n    if ( ! current_user_can( 'edit_post', $id ) || ! pll_is_translated_post_type( get_post_type( $id ) ) ) {\n        return;\n    }\n\n    $translations = pll_get_post_translations( $id );\n\n    $bar->add_node( array( 'id' => 'wpide-pll-tr', 'title' => esc_html__( 'Translations' ) ) );\n\n    foreach ( pll_languages_list( array( 'fields' => 'slug' ) ) as $slug ) {\n        if ( ! empty( $translations[ $slug ] ) ) {\n            $href  = get_edit_post_link( $translations[ $slug ], 'raw' );\n            $label = strtoupper( $slug ) . ( (int) $translations[ $slug ] === $id ? ' (this page)' : '' );\n        } else {\n            $href  = add_query_arg( array( 'post_type' => get_post_type( $id ), 'from_post' => $id, 'new_lang' => $slug, '_wpnonce' => wp_create_nonce( 'new-post-translation' ) ), admin_url( 'post-new.php' ) );\n            $label = '+ ' . strtoupper( $slug );\n        }\n\n        if ( $href ) {\n            $bar->add_node( array( 'parent' => 'wpide-pll-tr', 'id' => 'wpide-pll-tr-' . sanitize_key( $slug ), 'title' => esc_html( $label ), 'href' => $href ) );\n        }\n    }\n}, 90 );",
            "requires": [
                "polylang"
            ],
            "notes": "Visible only to users who can edit the post."
        },
        {
            "id": "pll-default-language-for-code-posts",
            "version": 1,
            "title": "Assign a Language to Posts Created by Importers and Code",
            "description": "Gives posts created outside the editor, for example by importers, cron jobs, forms or the REST API, the default Polylang language when they have none, so they do not vanish from language-filtered lists.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "importers",
                "languages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_lang = ''; // Language slug to assign; empty = Polylang's default language.\n\nadd_action( 'wp_after_insert_post', function ( $post_id, $post ) use ( $wpide_lang ) {\n    if ( ! function_exists( 'pll_set_post_language' ) || wp_is_post_revision( $post_id ) || 'auto-draft' === $post->post_status ) {\n        return;\n    }\n\n    if ( ! pll_is_translated_post_type( $post->post_type ) || pll_get_post_language( $post_id ) ) {\n        return;\n    }\n\n    pll_set_post_language( $post_id, $wpide_lang ? $wpide_lang : pll_default_language() );\n}, 20, 2 );",
            "requires": [
                "polylang"
            ],
            "notes": "Posts that already have a language are not touched. Change $wpide_lang to assign another language instead of the default."
        },
        {
            "id": "i18n-logo-per-language",
            "version": 1,
            "title": "Different Logo for Each Language",
            "description": "Swaps the site logo set in the Customizer for a language-specific image, using the language from WPML, Polylang or the WordPress locale.",
            "category": "multilingual",
            "tags": [
                "logo",
                "branding",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_logos = array( // Language code or locale => attachment ID.\n    // 'fr'    => 123,\n    // 'de_DE' => 456,\n);\n\nadd_filter( 'theme_mod_custom_logo', function ( $logo_id ) use ( $wpide_logos ) {\n    if ( is_admin() ) {\n        return $logo_id;\n    }\n\n    $locale = determine_locale();\n    $code   = strtok( $locale, '_' );\n\n    if ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $code = ICL_LANGUAGE_CODE;\n    } elseif ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $code = pll_current_language();\n    }\n\n    foreach ( array( $locale, $code ) as $key ) {\n        if ( ! empty( $wpide_logos[ $key ] ) && wp_attachment_is_image( $wpide_logos[ $key ] ) ) {\n            return (int) $wpide_logos[ $key ];\n        }\n    }\n\n    return $logo_id;\n} );",
            "requires": [],
            "notes": "Upload each logo to the Media Library and map language codes (or locales such as fr_FR) to attachment IDs in $wpide_logos."
        },
        {
            "id": "pll-cookie-lifetime",
            "version": 1,
            "title": "Change How Long Polylang Remembers the Language",
            "description": "Sets how long Polylang's language cookie lasts (one year by default), for example a single session on kiosks and shared computers, or 30 days for privacy-conscious sites.",
            "category": "multilingual",
            "tags": [
                "polylang",
                "cookies",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_seconds = 30 * DAY_IN_SECONDS; // Cookie lifetime in seconds; 0 = session only.\n\nadd_filter( 'pll_cookie_expiration', function () use ( $wpide_seconds ) {\n    return max( 0, (int) $wpide_seconds );\n} );",
            "requires": [
                "polylang"
            ],
            "notes": "0 makes it a session cookie that disappears when the browser closes."
        },
        {
            "id": "i18n-site-title-per-locale",
            "version": 1,
            "title": "Site Title and Tagline Per Language",
            "description": "Shows a different site title and tagline for each language the site is displayed in, without a translation plugin, by mapping locales to text.",
            "category": "multilingual",
            "tags": [
                "site-title",
                "tagline",
                "locale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_titles = array( // Locale => array( title, tagline ).\n    'fr_FR' => array( 'Mon Site', 'Le meilleur du web' ),\n    'de_DE' => array( 'Meine Website', 'Das Beste aus dem Netz' ),\n);\n\nif ( ! is_admin() ) {\n    add_filter( 'option_blogname', function ( $value ) use ( $wpide_titles ) {\n        $locale = determine_locale();\n        return ! empty( $wpide_titles[ $locale ][0] ) ? $wpide_titles[ $locale ][0] : $value;\n    } );\n\n    add_filter( 'option_blogdescription', function ( $value ) use ( $wpide_titles ) {\n        $locale = determine_locale();\n        return ! empty( $wpide_titles[ $locale ][1] ) ? $wpide_titles[ $locale ][1] : $value;\n    } );\n}",
            "requires": [],
            "notes": "Works with any locale switcher, including the ?lang= snippet. The dashboard keeps the original values so they can still be edited."
        },
        {
            "id": "i18n-transliterate-slugs",
            "version": 1,
            "title": "Latin Slugs for Cyrillic, Greek and Other Scripts",
            "description": "Turns post and term titles written in Cyrillic, Greek, Georgian or other non-Latin scripts into readable Latin URL slugs (for example \"Привет мир\" becomes privet-mir) instead of long percent-encoded addresses.",
            "category": "multilingual",
            "tags": [
                "slugs",
                "transliteration",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'sanitize_title', function ( $title, $raw_title, $context ) {\n    if ( 'save' !== $context || '' === $title || ! preg_match( '/[^\\x00-\\x7F]/', $title ) ) {\n        return $title;\n    }\n\n    static $table = null;\n\n    if ( class_exists( 'Transliterator' ) ) {\n        $tr = Transliterator::create( 'Any-Latin; Latin-ASCII; Lower()' );\n        if ( $tr ) {\n            $out = $tr->transliterate( $title );\n            if ( is_string( $out ) && '' !== $out ) {\n                return $out;\n            }\n        }\n    }\n\n    if ( null === $table ) {\n        $table = array(\n            'а' => 'a', 'б' => 'b', 'в' => 'v', 'г' => 'g', 'ґ' => 'g', 'д' => 'd', 'е' => 'e', 'є' => 'ye', 'ё' => 'yo', 'ж' => 'zh', 'з' => 'z', 'и' => 'i', 'і' => 'i', 'ї' => 'yi', 'й' => 'y',\n            'к' => 'k', 'л' => 'l', 'м' => 'm', 'н' => 'n', 'о' => 'o', 'п' => 'p', 'р' => 'r', 'с' => 's', 'т' => 't', 'у' => 'u', 'ф' => 'f', 'х' => 'h', 'ц' => 'ts', 'ч' => 'ch',\n            'ш' => 'sh', 'щ' => 'shch', 'ъ' => '', 'ы' => 'y', 'ь' => '', 'э' => 'e', 'ю' => 'yu', 'я' => 'ya',\n            'α' => 'a', 'β' => 'v', 'γ' => 'g', 'δ' => 'd', 'ε' => 'e', 'ζ' => 'z', 'η' => 'i', 'θ' => 'th', 'ι' => 'i', 'κ' => 'k', 'λ' => 'l', 'μ' => 'm', 'ν' => 'n', 'ξ' => 'x',\n            'ο' => 'o', 'π' => 'p', 'ρ' => 'r', 'σ' => 's', 'ς' => 's', 'τ' => 't', 'υ' => 'y', 'φ' => 'f', 'χ' => 'ch', 'ψ' => 'ps', 'ω' => 'o',\n            'ά' => 'a', 'έ' => 'e', 'ή' => 'i', 'ί' => 'i', 'ό' => 'o', 'ύ' => 'y', 'ώ' => 'o',\n        );\n    }\n\n    return strtr( mb_strtolower( $title, 'UTF-8' ), $table );\n}, 9, 3 );",
            "requires": [],
            "notes": "Uses PHP's intl Transliterator when available and a built-in Cyrillic/Greek table otherwise. Existing slugs are not changed."
        },
        {
            "id": "i18n-locale-translation-fallback",
            "version": 1,
            "title": "Fall Back to a Related Language's Translations",
            "description": "Uses translations from a closely related language when the visitor's exact locale has none, for example German (Germany) files on a Swiss German site or Spanish (Spain) on a Mexican Spanish site, instead of falling back to English.",
            "category": "multilingual",
            "tags": [
                "translations",
                "locale",
                "fallback"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_fallbacks = array( // Locale => fallback locale.\n    'de_CH'          => 'de_DE',\n    'de_CH_informal' => 'de_DE',\n    'es_MX'          => 'es_ES',\n    'fr_CA'          => 'fr_FR',\n    'pt_PT'          => 'pt_BR',\n);\n\nadd_filter( 'load_textdomain_mofile', function ( $mofile ) use ( $wpide_fallbacks ) {\n    if ( is_readable( $mofile ) || is_readable( preg_replace( '/\\.mo$/', '.l10n.php', $mofile ) ) ) {\n        return $mofile;\n    }\n\n    foreach ( $wpide_fallbacks as $locale => $fallback ) {\n        $suffix = $locale . '.mo';\n        if ( substr( $mofile, -strlen( $suffix ) ) === $suffix ) {\n            $candidate = substr( $mofile, 0, -strlen( $suffix ) ) . $fallback . '.mo';\n            if ( is_readable( $candidate ) || is_readable( preg_replace( '/\\.mo$/', '.l10n.php', $candidate ) ) ) {\n                return $candidate;\n            }\n        }\n    }\n\n    return $mofile;\n} );",
            "requires": [],
            "notes": "Map each locale to its fallback in $wpide_fallbacks. The fallback language pack must be installed (Settings > General, then switch back)."
        },
        {
            "id": "i18n-language-suggestion-banner",
            "version": 1,
            "title": "Suggest the Visitor's Language Version",
            "description": "Shows a small, dismissible banner when the visitor's browser language differs from the page language and a translated version exists (read from the page's hreflang links), with a link to switch.",
            "category": "multilingual",
            "tags": [
                "language",
                "banner",
                "hreflang"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    try { if (sessionStorage.getItem('wpideLangBanner')) { return; } } catch (e) {}\n    var pageLang = (document.documentElement.lang || '').toLowerCase().split('-')[0];\n    var wanted = (navigator.languages || [navigator.language || '']).map(function (l) { return l.toLowerCase(); });\n    var links = Array.prototype.slice.call(document.querySelectorAll('link[rel=\"alternate\"][hreflang]'));\n    if (!pageLang || !links.length || wanted[0].split('-')[0] === pageLang) { return; }\n    var match = null;\n    wanted.some(function (w) {\n        match = links.filter(function (l) { var h = l.hreflang.toLowerCase(); return h === w || h.split('-')[0] === w.split('-')[0]; })[0];\n        return !!match;\n    });\n    if (!match || match.hreflang.toLowerCase().split('-')[0] === pageLang) { return; }\n    var name = match.hreflang;\n    try { name = new Intl.DisplayNames([match.hreflang], { type: 'language' }).of(match.hreflang) || name; } catch (e) {}\n    var bar = document.createElement('div');\n    bar.setAttribute('role', 'region');\n    bar.setAttribute('aria-label', 'Language');\n    bar.style.cssText = 'position:fixed;left:16px;right:16px;bottom:16px;max-width:480px;margin:0 auto;padding:12px 44px 12px 16px;background:#1d2327;color:#fff;border-radius:8px;z-index:99999;font:14px/1.4 system-ui,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.25)';\n    var a = document.createElement('a');\n    a.href = match.href;\n    a.lang = match.hreflang;\n    a.textContent = name + ' →';\n    a.style.color = 'inherit';\n    a.style.fontWeight = '600';\n    var close = document.createElement('button');\n    close.type = 'button';\n    close.setAttribute('aria-label', 'Close');\n    close.textContent = '×';\n    close.style.cssText = 'position:absolute;top:6px;right:8px;background:none;border:0;color:inherit;font-size:22px;cursor:pointer';\n    close.addEventListener('click', function () {\n        bar.remove();\n        try { sessionStorage.setItem('wpideLangBanner', '1'); } catch (e) {}\n    });\n    bar.appendChild(a);\n    bar.appendChild(close);\n    document.body.appendChild(bar);\n})();\n</script>",
            "requires": [],
            "notes": "Works with WPML, Polylang or manual hreflang tags. Dismissal is remembered for the browser session; nothing redirects automatically."
        },
        {
            "id": "tec-upcoming-events-shortcode",
            "version": 1,
            "title": "Upcoming Events List Shortcode (The Events Calendar)",
            "description": "Adds a [wpide_upcoming_events] shortcode that lists the next events from The Events Calendar with date, time and venue, for sidebars, landing pages and footers.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "events",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_upcoming_events', function ( $atts ) {\n    if ( ! function_exists( 'tribe_get_events' ) ) {\n        return '';\n    }\n\n    $atts = shortcode_atts( array( 'number' => 5, 'category' => '', 'featured' => '' ), $atts, 'wpide_upcoming_events' );\n    $key  = 'wpide_tec_upcoming_' . md5( wp_json_encode( $atts ) ) . '_' . (int) get_option( 'wpide_tec_ver', 1 );\n    $html = get_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $args = array(\n        'posts_per_page' => min( 20, max( 1, absint( $atts['number'] ) ) ),\n        'start_date'     => 'now',\n        'eventDisplay'   => 'list',\n    );\n\n    if ( '' !== $atts['category'] ) {\n        $args['tax_query'] = array( array( 'taxonomy' => 'tribe_events_cat', 'field' => 'slug', 'terms' => sanitize_title( $atts['category'] ) ) ); // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n    if ( $atts['featured'] ) {\n        $args['featured'] = true;\n    }\n\n    $events = tribe_get_events( $args );\n    $html   = '';\n\n    if ( $events ) {\n        $html = '<ul class=\"wpide-upcoming-events\">';\n        foreach ( $events as $event ) {\n            $venue = function_exists( 'tribe_get_venue' ) ? tribe_get_venue( $event->ID ) : '';\n            $html .= sprintf(\n                '<li><a href=\"%1$s\">%2$s</a><br><small>%3$s%4$s</small></li>',\n                esc_url( get_permalink( $event ) ),\n                esc_html( get_the_title( $event ) ),\n                esc_html( tribe_get_start_date( $event, ! tribe_event_is_all_day( $event->ID ) ) ),\n                $venue ? ' &middot; ' . esc_html( $venue ) : ''\n            );\n        }\n        $html .= '</ul>';\n    } else {\n        $html = '<p class=\"wpide-upcoming-events-empty\">' . esc_html__( 'No upcoming events right now.' ) . '</p>';\n    }\n\n    set_transient( $key, $html, 15 * MINUTE_IN_SECONDS );\n\n    return $html;\n} );\n\nadd_action( 'save_post_tribe_events', function () {\n    update_option( 'wpide_tec_ver', (int) get_option( 'wpide_tec_ver', 1 ) + 1, false );\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Attributes: number (default 5), category (an event category slug) and featured=\"1\". Results are cached for 15 minutes and cleared when an event is saved."
        },
        {
            "id": "tec-next-event-countdown",
            "version": 1,
            "title": "Countdown to the Next Event (The Events Calendar)",
            "description": "Adds a [wpide_next_event_countdown] shortcode that shows the next upcoming event with a live days-hours-minutes countdown to its start.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "countdown",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_next_event_countdown', function ( $atts ) {\n    if ( ! function_exists( 'tribe_get_events' ) ) {\n        return '';\n    }\n\n    $atts = shortcode_atts( array( 'category' => '' ), $atts, 'wpide_next_event_countdown' );\n    $args = array( 'posts_per_page' => 1, 'start_date' => 'now' );\n\n    if ( '' !== $atts['category'] ) {\n        $args['tax_query'] = array( array( 'taxonomy' => 'tribe_events_cat', 'field' => 'slug', 'terms' => sanitize_title( $atts['category'] ) ) ); // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n\n    $events = tribe_get_events( $args );\n\n    if ( ! $events ) {\n        return '';\n    }\n\n    $event = $events[0];\n    $utc   = get_post_meta( $event->ID, '_EventStartDateUTC', true );\n    $start = $utc ? strtotime( $utc . ' UTC' ) : strtotime( (string) get_post_meta( $event->ID, '_EventStartDate', true ) );\n\n    if ( ! $start || $start <= time() ) {\n        return '';\n    }\n\n    $id = wp_unique_id( 'wpide-countdown-' );\n\n    ob_start();\n    ?>\n<div class=\"wpide-event-countdown\" id=\"<?php echo esc_attr( $id ); ?>\" data-start=\"<?php echo (int) $start; ?>\">\n    <a href=\"<?php echo esc_url( get_permalink( $event ) ); ?>\"><?php echo esc_html( get_the_title( $event ) ); ?></a>\n    <p aria-live=\"off\"><span data-part=\"d\">0</span> days, <span data-part=\"h\">0</span> hours, <span data-part=\"m\">0</span> minutes</p>\n</div>\n<script>\n(function () {\n    var el = document.getElementById(<?php echo wp_json_encode( $id ); ?>);\n    if (!el) { return; }\n    var start = parseInt(el.getAttribute('data-start'), 10) * 1000;\n    function tick() {\n        var left = Math.max(0, start - Date.now());\n        el.querySelector('[data-part=\"d\"]').textContent = Math.floor(left / 864e5);\n        el.querySelector('[data-part=\"h\"]').textContent = Math.floor(left % 864e5 / 36e5);\n        el.querySelector('[data-part=\"m\"]').textContent = Math.floor(left % 36e5 / 6e4);\n    }\n    tick();\n    setInterval(tick, 60000);\n})();\n</script>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Add category=\"slug\" to count down to the next event in one category. The countdown updates once a minute and needs no external library."
        },
        {
            "id": "tec-exclude-past-events-search",
            "version": 1,
            "title": "Hide Past Events From Site Search",
            "description": "Removes events that have already ended from your site's search results, while keeping posts, pages and upcoming events, when The Events Calendar is active.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "search",
                "past-events"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || ! class_exists( 'Tribe__Events__Main' ) ) {\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array(\n        'relation' => 'OR',\n        array( 'key' => '_EventEndDate', 'compare' => 'NOT EXISTS' ),\n        array( 'key' => '_EventEndDate', 'value' => current_time( 'mysql' ), 'compare' => '>=', 'type' => 'DATETIME' ),\n    );\n\n    $query->set( 'meta_query', $meta_query );\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Past events stay reachable through the calendar's own past-events view."
        },
        {
            "id": "tec-rename-events-label",
            "version": 1,
            "title": "Rename \"Events\" in The Events Calendar",
            "description": "Changes the word \"Event/Events\" used across The Events Calendar's views and admin into your own term, such as Show/Shows, Class/Classes or Session/Sessions.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "labels",
                "rename"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_singular = 'Show';  // Replaces \"Event\".\n$wpide_plural   = 'Shows'; // Replaces \"Events\".\n\nadd_filter( 'tribe_event_label_singular', function () use ( $wpide_singular ) {\n    return $wpide_singular;\n} );\nadd_filter( 'tribe_event_label_singular_lowercase', function () use ( $wpide_singular ) {\n    return strtolower( $wpide_singular );\n} );\nadd_filter( 'tribe_event_label_plural', function () use ( $wpide_plural ) {\n    return $wpide_plural;\n} );\nadd_filter( 'tribe_event_label_plural_lowercase', function () use ( $wpide_plural ) {\n    return strtolower( $wpide_plural );\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Only the labels change; the URL slug is set in Events > Settings > General."
        },
        {
            "id": "tec-allow-index-empty-views",
            "version": 1,
            "title": "Let Search Engines Index Empty Event Calendar Views",
            "description": "Stops The Events Calendar from adding a noindex tag to calendar pages that currently have no events, useful for seasonal calendars that should stay in search results.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'tribe_events_add_no_index_meta', '__return_false' );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Single events and populated views are not affected."
        },
        {
            "id": "tec-redirect-old-past-events",
            "version": 1,
            "title": "Redirect Long-Past Events to the Calendar",
            "description": "Sends visitors (and search engines) from single events that ended more than a set number of days ago to the main events page with a 301 redirect, keeping your calendar fresh.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "redirect",
                "past-events"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "$wpide_days = 90; // Redirect events that ended more than this many days ago.\n\nadd_action( 'template_redirect', function () use ( $wpide_days ) {\n    if ( ! is_singular( 'tribe_events' ) || current_user_can( 'edit_posts' ) || ! function_exists( 'tribe_get_events_link' ) ) {\n        return;\n    }\n\n    $end = strtotime( (string) get_post_meta( get_queried_object_id(), '_EventEndDate', true ) );\n\n    if ( $end && $end < current_time( 'timestamp' ) - $wpide_days * DAY_IN_SECONDS ) { // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- event dates are stored in site time.\n        wp_safe_redirect( tribe_get_events_link(), 301 );\n        exit;\n    }\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Change $wpide_days. Logged-in editors can still view old events."
        },
        {
            "id": "tec-event-schema-extras",
            "version": 1,
            "title": "Add Online and Status Details to Event Schema",
            "description": "Enriches The Events Calendar's JSON-LD with eventStatus and eventAttendanceMode, marking events that have an event website but no venue as online events, as Google recommends.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "schema",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'tribe_json_ld_event_object', function ( $data, $args, $post ) {\n    if ( ! is_object( $data ) || ! $post ) {\n        return $data;\n    }\n\n    $statuses = array(\n        'cancelled'   => 'https://schema.org/EventCancelled',\n        'postponed'   => 'https://schema.org/EventPostponed',\n        'rescheduled' => 'https://schema.org/EventRescheduled',\n    );\n    $status = strtolower( (string) get_post_meta( $post->ID, 'event_status', true ) );\n\n    $data->eventStatus = isset( $statuses[ $status ] ) ? $statuses[ $status ] : 'https://schema.org/EventScheduled';\n\n    $has_venue = (int) get_post_meta( $post->ID, '_EventVenueID', true ) > 0;\n    $url       = get_post_meta( $post->ID, '_EventURL', true );\n\n    if ( ! $has_venue && $url ) {\n        $data->eventAttendanceMode = 'https://schema.org/OnlineEventAttendanceMode';\n        $data->location            = (object) array( '@type' => 'VirtualLocation', 'url' => esc_url_raw( $url ) );\n    } else {\n        $data->eventAttendanceMode = 'https://schema.org/OfflineEventAttendanceMode';\n    }\n\n    return $data;\n}, 10, 3 );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Add a custom field named event_status with the value cancelled, postponed or rescheduled to mark changed events."
        },
        {
            "id": "tec-venue-admin-column",
            "version": 1,
            "title": "Venue Column in the Events Admin List",
            "description": "Adds a Venue column to Events in the dashboard, so you can see where each event takes place without opening it.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "admin-columns",
                "venues"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_tribe_events_posts_columns', function ( $columns ) {\n    $columns['wpide_venue'] = __( 'Venue' );\n\n    return $columns;\n}, 20 );\n\nadd_action( 'manage_tribe_events_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_venue' !== $column ) {\n        return;\n    }\n\n    $venue_id = (int) get_post_meta( $post_id, '_EventVenueID', true );\n\n    if ( ! $venue_id ) {\n        echo '&mdash;';\n        return;\n    }\n\n    static $primed = false;\n    if ( ! $primed ) {\n        global $wp_query;\n        $ids = array();\n        foreach ( (array) $wp_query->posts as $p ) {\n            $ids[] = (int) get_post_meta( is_object( $p ) ? $p->ID : (int) $p, '_EventVenueID', true );\n        }\n        _prime_post_caches( array_filter( array_unique( $ids ) ), false, false );\n        $primed = true;\n    }\n\n    $venue = get_post( $venue_id );\n    $city  = get_post_meta( $venue_id, '_VenueCity', true );\n\n    if ( $venue ) {\n        printf( '<a href=\"%1$s\">%2$s</a>%3$s', esc_url( (string) get_edit_post_link( $venue_id ) ), esc_html( $venue->post_title ), $city ? '<br><small>' . esc_html( $city ) . '</small>' : '' );\n    }\n}, 10, 2 );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Venue names are loaded in one query for the whole page."
        },
        {
            "id": "tec-upcoming-events-dashboard-widget",
            "version": 1,
            "title": "Dashboard Widget: Events in the Next Two Weeks",
            "description": "Adds a dashboard widget listing the events coming up in the next 14 days with dates and quick edit links, so the team always knows what is on.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "dashboard",
                "events"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_days = 14; // Days ahead to show.\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_days ) {\n    if ( ! function_exists( 'tribe_get_events' ) || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_tec_next', __( 'Coming up' ), function () use ( $wpide_days ) {\n        $events = tribe_get_events( array(\n            'start_date'     => 'now',\n            'end_date'       => wp_date( 'Y-m-d 23:59:59', time() + $wpide_days * DAY_IN_SECONDS ),\n            'posts_per_page' => 15,\n        ) );\n\n        if ( ! $events ) {\n            echo '<p>' . esc_html__( 'Nothing scheduled.' ) . '</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $events as $event ) {\n            printf(\n                '<li><strong>%1$s</strong> &ndash; <a href=\"%2$s\">%3$s</a></li>',\n                esc_html( tribe_get_start_date( $event, true, 'D j M, H:i' ) ),\n                esc_url( (string) get_edit_post_link( $event->ID ) ),\n                esc_html( get_the_title( $event ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Change $wpide_days to look further ahead."
        },
        {
            "id": "tec-pending-event-alert",
            "version": 1,
            "title": "Email Alert for Events Awaiting Review",
            "description": "Emails your events editor whenever an event is submitted for review (for example through Community Events or by a contributor), with a direct link to approve it.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "moderation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_editor_email = get_option( 'admin_email' ); // Recipient.\n\nadd_action( 'transition_post_status', function ( $new, $old, $post ) use ( $wpide_editor_email ) {\n    if ( 'tribe_events' !== $post->post_type || 'pending' !== $new || 'pending' === $old ) {\n        return;\n    }\n\n    $start = get_post_meta( $post->ID, '_EventStartDate', true );\n\n    wp_mail(\n        $wpide_editor_email,\n        sprintf( '[%s] Event awaiting review: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $post->post_title ),\n        sprintf(\n            \"A new event is waiting for review.\\n\\nTitle: %1\\$s\\nStarts: %2\\$s\\nReview: %3\\$s\\n\",\n            $post->post_title,\n            $start ? $start : 'not set',\n            admin_url( 'post.php?post=' . $post->ID . '&action=edit' )\n        )\n    );\n}, 10, 3 );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Set $wpide_editor_email; it defaults to the site admin email."
        },
        {
            "id": "tec-search-results-event-date",
            "version": 1,
            "title": "Show Event Dates in Search Results",
            "description": "Adds the event's date (and venue when there is one) in front of its excerpt in search results, so visitors can tell upcoming events from old ones before clicking.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "search",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'get_the_excerpt', function ( $excerpt, $post ) {\n    if ( ! is_search() || ! $post || 'tribe_events' !== $post->post_type || ! function_exists( 'tribe_get_start_date' ) ) {\n        return $excerpt;\n    }\n\n    $venue = function_exists( 'tribe_get_venue' ) ? tribe_get_venue( $post->ID ) : '';\n    $meta  = tribe_get_start_date( $post, false ) . ( $venue ? ' · ' . $venue : '' );\n\n    return '[' . wp_strip_all_tags( $meta ) . '] ' . $excerpt;\n}, 20, 2 );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Only affects events; other results keep their usual excerpt."
        },
        {
            "id": "tec-past-events-out-of-sitemap",
            "version": 1,
            "title": "Keep Past Events Out of the XML Sitemap",
            "description": "Leaves events that have already ended out of WordPress's built-in sitemap (wp-sitemap.xml), so search engines spend their time on upcoming events.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "sitemap",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_sitemaps_posts_query_args', function ( $args, $post_type ) {\n    if ( 'tribe_events' !== $post_type ) {\n        return $args;\n    }\n\n    $args['meta_query'] = array( // phpcs:ignore WordPress.DB.SlowDBQuery\n        array( 'key' => '_EventEndDate', 'value' => current_time( 'mysql' ), 'compare' => '>=', 'type' => 'DATETIME' ),\n    );\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Only affects the core WordPress sitemap; SEO plugins with their own sitemaps have separate settings."
        },
        {
            "id": "give-fundraising-totals-shortcode",
            "version": 1,
            "title": "Fundraising Totals Shortcode (GiveWP)",
            "description": "Adds a [wpide_give_totals] shortcode that shows the total raised and number of donations across all your GiveWP forms (or selected ones), for headers, impact pages and annual reports.",
            "category": "integrations",
            "tags": [
                "givewp",
                "donations",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_give_totals', function ( $atts ) {\n    if ( ! function_exists( 'give_get_meta' ) ) {\n        return '';\n    }\n\n    $atts  = shortcode_atts( array( 'show' => 'amount', 'forms' => '' ), $atts, 'wpide_give_totals' );\n    $ids   = array_filter( array_map( 'absint', explode( ',', $atts['forms'] ) ) );\n    $key   = 'wpide_give_totals_' . md5( implode( ',', $ids ) );\n    $total = get_transient( $key );\n\n    if ( false === $total ) {\n        if ( ! $ids ) {\n            $ids = get_posts( array( 'post_type' => 'give_forms', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true ) );\n        }\n        $total = array( 'amount' => 0.0, 'count' => 0 );\n        foreach ( $ids as $form_id ) {\n            $total['amount'] += (float) give_get_meta( $form_id, '_give_form_earnings', true );\n            $total['count']  += (int) give_get_meta( $form_id, '_give_form_sales', true );\n        }\n        set_transient( $key, $total, HOUR_IN_SECONDS );\n        $keys = (array) get_option( 'wpide_give_total_keys', array() );\n        update_option( 'wpide_give_total_keys', array_unique( array_merge( $keys, array( $key ) ) ), false );\n    }\n\n    if ( 'count' === $atts['show'] ) {\n        return esc_html( number_format_i18n( $total['count'] ) );\n    }\n\n    $amount = function_exists( 'give_currency_filter' ) && function_exists( 'give_format_amount' )\n        ? give_currency_filter( give_format_amount( $total['amount'], array( 'sanitize' => false ) ) )\n        : number_format_i18n( $total['amount'], 2 );\n\n    return '<span class=\"wpide-give-total\">' . wp_kses_post( $amount ) . '</span>';\n} );\n\nadd_action( 'give_complete_donation', function () {\n    foreach ( (array) get_option( 'wpide_give_total_keys', array() ) as $key ) {\n        delete_transient( $key );\n    }\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Attributes: show=\"amount|count\", forms=\"12,34\" to limit to certain forms. Totals are cached for an hour and refresh after every completed donation."
        },
        {
            "id": "give-large-donation-alert",
            "version": 1,
            "title": "Email Alert for Large Donations (GiveWP)",
            "description": "Emails your fundraising team as soon as a donation at or above a set amount is completed, so major donors can be thanked personally the same day.",
            "category": "integrations",
            "tags": [
                "givewp",
                "donations",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_threshold = 500;                     // Amount that triggers the alert.\n$wpide_notify_to = get_option( 'admin_email' ); // Recipient(s).\n\nadd_action( 'give_complete_donation', function ( $donation_id ) use ( $wpide_threshold, $wpide_notify_to ) {\n    if ( ! function_exists( 'give_donation_amount' ) ) {\n        return;\n    }\n\n    $amount = (float) give_donation_amount( $donation_id );\n\n    if ( $amount < $wpide_threshold ) {\n        return;\n    }\n\n    $form_id = function_exists( 'give_get_payment_form_id' ) ? give_get_payment_form_id( $donation_id ) : 0;\n    $email   = function_exists( 'give_get_payment_user_email' ) ? give_get_payment_user_email( $donation_id ) : '';\n\n    wp_mail(\n        $wpide_notify_to,\n        sprintf( 'Large donation received: %s', html_entity_decode( wp_strip_all_tags( give_currency_filter( give_format_amount( $amount, array( 'sanitize' => false ) ) ) ) ) ),\n        sprintf(\n            \"Donation #%1\\$d\\nAmount: %2\\$s\\nForm: %3\\$s\\nDonor email: %4\\$s\\nView: %5\\$s\\n\",\n            $donation_id,\n            $amount,\n            $form_id ? get_the_title( $form_id ) : '-',\n            $email,\n            admin_url( 'edit.php?post_type=give_forms&page=give-payment-history&view=view-payment-details&id=' . (int) $donation_id )\n        )\n    );\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Set $wpide_threshold in your site currency and $wpide_notify_to."
        },
        {
            "id": "give-trust-message",
            "version": 1,
            "title": "Reassurance Message Above GiveWP Forms",
            "description": "Shows a short trust message with a padlock icon above every GiveWP donation form, for example about secure payments and where the money goes.",
            "category": "integrations",
            "tags": [
                "givewp",
                "donations",
                "trust"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_message = 'Your donation is processed securely. 100% of public donations fund our programmes.'; // Message text.\n\nadd_action( 'give_pre_form', function ( $form_id ) use ( $wpide_message ) {\n    echo '<p class=\"wpide-give-trust\" style=\"display:flex;gap:.5em;align-items:center;font-size:.9em;margin:0 0 1em\">'\n        . '<span aria-hidden=\"true\">&#128274;</span> ' . esc_html( $wpide_message ) . '</p>';\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Change $wpide_message. Works with the legacy and multi-step form templates that run GiveWP's form hooks."
        },
        {
            "id": "give-block-disposable-emails",
            "version": 1,
            "title": "Block Disposable Emails on GiveWP Donations",
            "description": "Rejects donations made with throwaway email addresses (such as mailinator or 10minutemail), cutting down on card-testing fraud through your donation forms.",
            "category": "integrations",
            "tags": [
                "givewp",
                "fraud",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_blocked = array( 'mailinator.com', '10minutemail.com', 'guerrillamail.com', 'tempmail.com', 'yopmail.com', 'trashmail.com', 'sharklasers.com', 'getnada.com' );\n\nadd_action( 'give_checkout_error_checks', function ( $valid_data, $data ) use ( $wpide_blocked ) {\n    $email  = isset( $data['give_email'] ) ? strtolower( sanitize_email( $data['give_email'] ) ) : '';\n    $domain = substr( strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_blocked, true ) && function_exists( 'give_set_error' ) ) {\n        give_set_error( 'wpide_disposable_email', __( 'Please use a permanent email address so we can send your receipt.' ) );\n    }\n}, 10, 2 );",
            "requires": [
                "givewp"
            ],
            "notes": "Add more domains to $wpide_blocked as you spot them."
        },
        {
            "id": "give-tribute-field",
            "version": 1,
            "title": "\"In Honor Of\" Field for GiveWP Donations",
            "description": "Adds an optional \"Donate in honor or memory of\" field to GiveWP forms, saves it with the donation, shows it on the donation details screen and offers an {honoree} email tag.",
            "category": "integrations",
            "tags": [
                "givewp",
                "donations",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_label = 'Donate in honor or memory of (optional)'; // Field label.\n\nadd_action( 'give_after_donation_levels', function ( $form_id ) use ( $wpide_label ) {\n    $id = 'wpide-honoree-' . (int) $form_id;\n    ?>\n    <p class=\"form-row form-row-wide wpide-give-honoree\">\n        <label class=\"give-label\" for=\"<?php echo esc_attr( $id ); ?>\"><?php echo esc_html( $wpide_label ); ?></label>\n        <input type=\"text\" class=\"give-input\" name=\"wpide_honoree\" id=\"<?php echo esc_attr( $id ); ?>\" maxlength=\"120\" autocomplete=\"off\">\n    </p>\n    <?php\n} );\n\nadd_action( 'give_insert_payment', function ( $payment_id ) {\n    if ( empty( $_POST['wpide_honoree'] ) || ! function_exists( 'give_update_meta' ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- GiveWP verifies its own form nonce.\n        return;\n    }\n\n    give_update_meta( $payment_id, 'wpide_honoree', sanitize_text_field( wp_unslash( $_POST['wpide_honoree'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n} );\n\nadd_action( 'give_payment_view_details', function ( $payment_id ) {\n    $honoree = function_exists( 'give_get_meta' ) ? give_get_meta( $payment_id, 'wpide_honoree', true ) : '';\n\n    if ( $honoree ) {\n        echo '<div class=\"postbox\"><h3 class=\"hndle\">' . esc_html__( 'Tribute' ) . '</h3><div class=\"inside\"><p>' . esc_html( $honoree ) . '</p></div></div>';\n    }\n} );\n\nadd_action( 'give_add_email_tags', function () {\n    if ( ! function_exists( 'give_add_email_tag' ) ) {\n        return;\n    }\n    give_add_email_tag( 'honoree', __( 'The person the donation honors' ), function ( $tag_args ) {\n        $payment_id = isset( $tag_args['payment_id'] ) ? (int) $tag_args['payment_id'] : 0;\n        return $payment_id ? esc_html( (string) give_get_meta( $payment_id, 'wpide_honoree', true ) ) : '';\n    } );\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Use {honoree} in GiveWP email templates. Works with the legacy and multi-step form templates; forms made with GiveWP 3's visual builder have their own custom field blocks."
        },
        {
            "id": "give-receipt-next-steps",
            "version": 1,
            "title": "\"What Happens Next\" Box on GiveWP Receipts",
            "description": "Adds a friendly box after the GiveWP donation receipt with next steps, such as following you on social media or sharing the campaign, to keep donors engaged.",
            "category": "integrations",
            "tags": [
                "givewp",
                "receipts",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_html = '<h3>Thank you!</h3><p>Your receipt is on its way by email. Want to do more? <a href=\"/volunteer/\">Volunteer with us</a> or share this campaign with a friend.</p>';\n\nadd_action( 'give_payment_receipt_after_table', function () use ( $wpide_html ) {\n    echo '<div class=\"wpide-give-next\" style=\"margin-top:1.5em;padding:1em 1.25em;border-radius:6px;background:rgba(102,187,106,.12)\">' . wp_kses_post( $wpide_html ) . '</div>';\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Edit the HTML in $wpide_html; basic tags and links are allowed."
        },
        {
            "id": "affwp-affiliate-stats-shortcode",
            "version": 1,
            "title": "Affiliate Earnings Summary Shortcode (AffiliateWP)",
            "description": "Adds a [wpide_affiliate_stats] shortcode that shows the logged-in affiliate their unpaid and paid earnings, referral count, visits and personal referral link on any page.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "affiliates",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_affiliate_stats', function () {\n    if ( ! function_exists( 'affwp_get_affiliate_id' ) || ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $affiliate_id = affwp_get_affiliate_id( get_current_user_id() );\n\n    if ( ! $affiliate_id || ! affwp_is_active_affiliate( $affiliate_id ) ) {\n        return '';\n    }\n\n    $rows = array(\n        __( 'Unpaid earnings' ) => affwp_get_affiliate_unpaid_earnings( $affiliate_id, true ),\n        __( 'Paid earnings' )   => affwp_get_affiliate_earnings( $affiliate_id, true ),\n        __( 'Referrals' )       => number_format_i18n( (int) affwp_get_affiliate_referral_count( $affiliate_id ) ),\n        __( 'Visits' )          => number_format_i18n( (int) affwp_get_affiliate_visit_count( $affiliate_id ) ),\n    );\n\n    $html = '<dl class=\"wpide-affwp-stats\" style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px\">';\n    foreach ( $rows as $label => $value ) {\n        $html .= '<div style=\"padding:12px;border:1px solid rgba(0,0,0,.1);border-radius:6px\"><dt style=\"font-size:.85em;opacity:.75\">' . esc_html( $label ) . '</dt><dd style=\"margin:0;font-size:1.4em;font-weight:600\">' . wp_kses_post( $value ) . '</dd></div>';\n    }\n    $html .= '</dl>';\n\n    $url   = affwp_get_affiliate_referral_url( array( 'affiliate_id' => $affiliate_id ) );\n    $html .= '<p><label>' . esc_html__( 'Your referral link' ) . '<br><input type=\"text\" readonly value=\"' . esc_attr( $url ) . '\" onclick=\"this.select()\" style=\"width:100%\"></label></p>';\n\n    return $html;\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Visitors who are not active affiliates see nothing; place it on a members page or next to the Affiliate Area."
        },
        {
            "id": "affwp-top-affiliates-leaderboard",
            "version": 1,
            "title": "Top Affiliates Leaderboard (AffiliateWP)",
            "description": "Adds a [wpide_affiliate_leaderboard] shortcode that ranks your active affiliates by earnings, showing first names only for privacy, to spark some friendly competition.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "leaderboard",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_affiliate_leaderboard', function ( $atts ) {\n    if ( ! function_exists( 'affiliate_wp' ) ) {\n        return '';\n    }\n\n    $atts   = shortcode_atts( array( 'number' => 10, 'amounts' => '0' ), $atts, 'wpide_affiliate_leaderboard' );\n    $number = min( 50, max( 1, absint( $atts['number'] ) ) );\n    $key    = 'wpide_affwp_board_' . $number;\n    $rows   = get_transient( $key );\n\n    if ( false === $rows ) {\n        $rows       = array();\n        $affiliates = affiliate_wp()->affiliates->get_affiliates( array( 'number' => $number, 'status' => 'active', 'orderby' => 'earnings', 'order' => 'DESC' ) );\n        foreach ( (array) $affiliates as $affiliate ) {\n            $user   = get_userdata( $affiliate->user_id );\n            $rows[] = array(\n                'name'     => $user ? ( $user->first_name ? $user->first_name : $user->display_name ) : '—',\n                'earnings' => (float) $affiliate->earnings,\n            );\n        }\n        set_transient( $key, $rows, 6 * HOUR_IN_SECONDS );\n    }\n\n    if ( ! $rows ) {\n        return '';\n    }\n\n    $html = '<ol class=\"wpide-affwp-leaderboard\">';\n    foreach ( $rows as $row ) {\n        $html .= '<li>' . esc_html( $row['name'] ) . ( $atts['amounts'] && function_exists( 'affwp_currency_filter' ) ? ' &ndash; ' . esc_html( affwp_currency_filter( affwp_format_amount( $row['earnings'] ) ) ) : '' ) . '</li>';\n    }\n\n    return $html . '</ol>';\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Use number=\"10\" to set how many affiliates to show. Cached for six hours. Earnings amounts are hidden unless you add amounts=\"1\"."
        },
        {
            "id": "affwp-rate-by-role",
            "version": 1,
            "title": "Higher Commission Rate for a User Role (AffiliateWP)",
            "description": "Gives affiliates with a specific user role, such as \"partner\" or \"wholesale\", a different commission rate from the global AffiliateWP rate, unless they have a custom rate set on their profile.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "commissions",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_role = 'partner'; // User role that gets the special rate.\n$wpide_rate = 30;        // Rate for that role (30 = 30% for percentage rates).\n\nadd_filter( 'affwp_get_affiliate_rate', function ( $rate, $affiliate_id ) use ( $wpide_role, $wpide_rate ) {\n    if ( ! function_exists( 'affwp_get_affiliate' ) ) {\n        return $rate;\n    }\n\n    $affiliate = affwp_get_affiliate( $affiliate_id );\n\n    if ( ! $affiliate || '' !== (string) $affiliate->rate ) {\n        return $rate; // A custom rate on the affiliate wins.\n    }\n\n    $user = get_userdata( $affiliate->user_id );\n\n    if ( $user && in_array( $wpide_role, (array) $user->roles, true ) ) {\n        $type = function_exists( 'affwp_get_affiliate_rate_type' ) ? affwp_get_affiliate_rate_type( $affiliate_id ) : 'percentage';\n        return 'percentage' === $type ? $wpide_rate / 100 : $wpide_rate;\n    }\n\n    return $rate;\n}, 10, 2 );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "The rate uses the affiliate's rate type (percentage or flat). Affiliates with their own rate in AffiliateWP keep it."
        },
        {
            "id": "affwp-cap-referral-amount",
            "version": 1,
            "title": "Cap the Commission Per Referral (AffiliateWP)",
            "description": "Limits how much commission any single referral can earn, so a very large order does not produce an outsized payout.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "commissions",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_max_commission = 200; // Maximum commission per referral.\n\nadd_filter( 'affwp_calc_referral_amount', function ( $amount ) use ( $wpide_max_commission ) {\n    return min( (float) $amount, (float) $wpide_max_commission );\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Set $wpide_max_commission in your store currency. Referrals already recorded are not changed."
        },
        {
            "id": "affwp-referred-by-banner",
            "version": 1,
            "title": "\"Referred By\" Welcome Banner (AffiliateWP)",
            "description": "Greets visitors who arrive through an affiliate link with a small banner naming the person who referred them, which builds trust and lifts conversions.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "referrals",
                "banner"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_text = 'Welcome! You were referred by %s.'; // %s = affiliate first name.\n\nadd_action( 'wp_footer', function () use ( $wpide_text ) {\n    if ( ! function_exists( 'affiliate_wp' ) || is_admin() ) {\n        return;\n    }\n\n    $affiliate_id = absint( affiliate_wp()->tracking->get_affiliate_id() );\n\n    if ( ! $affiliate_id || ! affwp_is_active_affiliate( $affiliate_id ) ) {\n        return;\n    }\n\n    $affiliate = affwp_get_affiliate( $affiliate_id );\n    $user      = $affiliate ? get_userdata( $affiliate->user_id ) : false;\n    $name      = $user ? ( $user->first_name ? $user->first_name : $user->display_name ) : '';\n\n    if ( ! $name ) {\n        return;\n    }\n    ?>\n<div class=\"wpide-referred-by\" role=\"status\" style=\"position:fixed;inset-inline:16px;bottom:16px;max-width:420px;margin-inline:auto;padding:12px 40px 12px 16px;background:#1d2327;color:#fff;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.2);z-index:9999;font-size:14px\">\n    <?php echo esc_html( sprintf( $wpide_text, $name ) ); ?>\n    <button type=\"button\" aria-label=\"<?php esc_attr_e( 'Close' ); ?>\" onclick=\"this.parentNode.remove();try{sessionStorage.setItem('wpideRefBanner','1')}catch(e){}\" style=\"position:absolute;top:6px;inset-inline-end:8px;background:none;border:0;color:inherit;font-size:20px;cursor:pointer\">&times;</button>\n</div>\n<script>try{if(sessionStorage.getItem('wpideRefBanner')){document.querySelector('.wpide-referred-by').remove();}}catch(e){}</script>\n    <?php\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Shows the affiliate's first name only. Change $wpide_text; %s is replaced with the name."
        },
        {
            "id": "affwp-pending-referrals-widget",
            "version": 1,
            "title": "Dashboard Widget: Pending Referrals (AffiliateWP)",
            "description": "Adds a dashboard widget for administrators with the number and value of referrals waiting for review, plus pending affiliate applications, linking to each AffiliateWP screen.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "dashboard",
                "referrals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'affiliate_wp' ) || ! current_user_can( 'manage_referrals' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_affwp_pending', __( 'Affiliate program' ), function () {\n        $data = get_transient( 'wpide_affwp_pending' );\n\n        if ( false === $data ) {\n            $data = array(\n                'referrals'  => (int) affiliate_wp()->referrals->count( array( 'status' => 'pending' ) ),\n                'affiliates' => (int) affiliate_wp()->affiliates->count( array( 'status' => 'pending' ) ),\n            );\n            set_transient( 'wpide_affwp_pending', $data, 10 * MINUTE_IN_SECONDS );\n        }\n\n        printf(\n            '<p><a href=\"%1$s\"><strong>%2$s</strong> %3$s</a></p><p><a href=\"%4$s\"><strong>%5$s</strong> %6$s</a></p>',\n            esc_url( admin_url( 'admin.php?page=affiliate-wp-referrals&status=pending' ) ),\n            esc_html( number_format_i18n( $data['referrals'] ) ),\n            esc_html__( 'referrals pending review' ),\n            esc_url( admin_url( 'admin.php?page=affiliate-wp-affiliates&status=pending' ) ),\n            esc_html( number_format_i18n( $data['affiliates'] ) ),\n            esc_html__( 'affiliate applications waiting' )\n        );\n    } );\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Counts are cached for ten minutes."
        },
        {
            "id": "affwp-admin-bar-affiliate-area",
            "version": 1,
            "title": "Affiliate Area Link in the Admin Bar (AffiliateWP)",
            "description": "Adds an \"Affiliate Area\" link to the toolbar for logged-in affiliates, showing their unpaid earnings, so they can reach their dashboard from anywhere on the site.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "admin-bar",
                "affiliates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! function_exists( 'affwp_get_affiliate_id' ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $affiliate_id = affwp_get_affiliate_id( get_current_user_id() );\n    $page_id      = (int) affiliate_wp()->settings->get( 'affiliates_page' );\n\n    if ( ! $affiliate_id || ! $page_id ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-affiliate-area',\n        'title' => esc_html( sprintf( 'Affiliate Area (%s)', wp_strip_all_tags( affwp_get_affiliate_unpaid_earnings( $affiliate_id, true ) ) ) ),\n        'href'  => get_permalink( $page_id ),\n    ) );\n}, 85 );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Uses the Affiliate Area page set in AffiliateWP > Settings."
        },
        {
            "id": "jetpack-related-posts-options",
            "version": 1,
            "title": "Customize Jetpack Related Posts",
            "description": "Changes the heading, number of posts and thumbnail visibility of Jetpack's Related Posts, and limits suggestions to posts from the last two years.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "related-posts",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_headline = 'You might also enjoy'; // Heading text.\n$wpide_count    = 3;                       // Posts to show (max 6).\n$wpide_years    = 2;                       // Only suggest posts from the last N years; 0 = no limit.\n\nadd_filter( 'jetpack_relatedposts_filter_headline', function () use ( $wpide_headline ) {\n    return '<h3 class=\"jp-relatedposts-headline\"><em>' . esc_html( $wpide_headline ) . '</em></h3>';\n} );\n\nadd_filter( 'jetpack_relatedposts_filter_options', function ( $options ) use ( $wpide_count ) {\n    $options['size'] = max( 1, min( 6, (int) $wpide_count ) );\n\n    return $options;\n} );\n\nadd_filter( 'jetpack_relatedposts_filter_date_range', function ( $range ) use ( $wpide_years ) {\n    if ( ! $wpide_years ) {\n        return $range;\n    }\n\n    return array(\n        'from' => strtotime( '-' . (int) $wpide_years . ' years' ),\n        'to'   => time(),\n    );\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Edit the values at the top. Jetpack can show at most 6 related posts."
        },
        {
            "id": "jetpack-related-posts-exclude",
            "version": 1,
            "title": "Exclude Posts From Jetpack Related Posts",
            "description": "Keeps chosen posts, and posts in chosen categories, out of Jetpack's Related Posts suggestions, such as announcements, legal pages or outdated content.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "related-posts",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_exclude_ids  = array();                 // Post IDs never suggested, e.g. array( 123, 456 ).\n$wpide_exclude_cats = array( 'announcements' ); // Category slugs never suggested.\n\nadd_filter( 'jetpack_relatedposts_filter_exclude_post_ids', function ( $exclude ) use ( $wpide_exclude_ids ) {\n    return array_unique( array_merge( (array) $exclude, array_map( 'absint', $wpide_exclude_ids ) ) );\n} );\n\nadd_filter( 'jetpack_relatedposts_filter_filters', function ( $filters ) use ( $wpide_exclude_cats ) {\n    if ( ! $wpide_exclude_cats ) {\n        return $filters;\n    }\n\n    $filters[] = array(\n        'not' => array(\n            'terms' => array( 'category.slug' => array_values( $wpide_exclude_cats ) ),\n        ),\n    );\n\n    return $filters;\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "List post IDs in $wpide_exclude_ids and category slugs in $wpide_exclude_cats. Category exclusion runs in Jetpack's search filter."
        },
        {
            "id": "jetpack-sharing-above-content",
            "version": 1,
            "title": "Move Jetpack Sharing Buttons Above the Content",
            "description": "Shows Jetpack's sharing buttons at the top of posts instead of the bottom, and keeps the Like button at the bottom where readers expect it.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "sharing",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'loop_start', function () {\n    if ( ! function_exists( 'sharing_display' ) ) {\n        return;\n    }\n\n    remove_filter( 'the_content', 'sharing_display', 19 );\n    remove_filter( 'the_excerpt', 'sharing_display', 19 );\n} );\n\nadd_filter( 'the_content', function ( $content ) {\n    if ( ! function_exists( 'sharing_display' ) || ! is_singular() || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    return sharing_display( '', false ) . $content;\n}, 8 );",
            "requires": [
                "jetpack"
            ],
            "notes": "Also removes the buttons from excerpts so they do not repeat on archive pages."
        },
        {
            "id": "jetpack-hide-sharing-by-type",
            "version": 1,
            "title": "Hide Jetpack Sharing Buttons on Selected Content",
            "description": "Turns off Jetpack's sharing buttons on chosen post types, categories or individual posts, such as landing pages, legal pages or members-only content.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "sharing",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_hide_types = array( 'page' );       // Post types.\n$wpide_hide_cats  = array( 'members' );    // Category slugs.\n$wpide_hide_ids   = array();               // Post IDs.\n\nadd_filter( 'sharing_show', function ( $show, $post ) use ( $wpide_hide_types, $wpide_hide_cats, $wpide_hide_ids ) {\n    if ( ! $show || ! $post instanceof WP_Post ) {\n        return $show;\n    }\n\n    if ( in_array( $post->post_type, $wpide_hide_types, true ) || in_array( (int) $post->ID, array_map( 'absint', $wpide_hide_ids ), true ) ) {\n        return false;\n    }\n\n    if ( $wpide_hide_cats && has_category( $wpide_hide_cats, $post ) ) {\n        return false;\n    }\n\n    return $show;\n}, 10, 2 );",
            "requires": [
                "jetpack"
            ],
            "notes": "Fill in any of the three lists; leave a list empty to skip that rule."
        },
        {
            "id": "jetpack-hide-promotions",
            "version": 1,
            "title": "Hide Jetpack Upsells and Promotions",
            "description": "Removes Jetpack's in-dashboard upgrade prompts, \"just in time\" messages and Blaze promotion links, giving clients a cleaner admin.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "admin",
                "upsells"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'jetpack_just_in_time_msgs', '__return_false', 20 );\nadd_filter( 'jetpack_show_promotions', '__return_false', 20 );\nadd_filter( 'jetpack_blaze_enabled', '__return_false' );",
            "requires": [
                "jetpack"
            ],
            "notes": "Jetpack features you use keep working; only the promotional prompts go away."
        },
        {
            "id": "jetpack-default-og-image",
            "version": 1,
            "title": "Default Social Image for Jetpack Open Graph Tags",
            "description": "Sets the image Jetpack uses in Open Graph tags when a post has no featured image or images in its content, so shared links always show your brand instead of a blank square.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "open-graph",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_image = 'https://example.com/wp-content/uploads/social-default.jpg'; // Fallback image URL.\n\nadd_filter( 'jetpack_open_graph_image_default', function () use ( $wpide_image ) {\n    return esc_url_raw( $wpide_image );\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Use a full image URL; 1200×630 pixels works best on most networks."
        },
        {
            "id": "jetpack-stats-honor-dnt",
            "version": 1,
            "title": "Respect Do Not Track in Jetpack Stats",
            "description": "Tells Jetpack Stats not to count visitors whose browser sends the Do Not Track signal, a simple privacy step for sites that want lighter tracking.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "stats",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'jetpack_honor_dnt_header_for_stats', '__return_true' );",
            "requires": [
                "jetpack"
            ],
            "notes": "Visitor numbers in Jetpack Stats may drop slightly afterwards."
        },
        {
            "id": "jetpack-hide-menu-non-admins",
            "version": 1,
            "title": "Hide the Jetpack Menu From Non-Administrators",
            "description": "Removes the Jetpack menu (and its dashboard widget) for authors, editors and other roles that cannot manage options, so they only see what they need.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "admin-menu",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        remove_menu_page( 'jetpack' );\n    }\n}, 999 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        remove_meta_box( 'jetpack_summary_widget', 'dashboard', 'normal' );\n    }\n}, 999 );",
            "requires": [
                "jetpack"
            ],
            "notes": "Administrators still see everything."
        },
        {
            "id": "jetpack-photon-skip-images",
            "version": 1,
            "title": "Keep Some Images Off Jetpack's Image CDN",
            "description": "Serves images from your own server instead of Jetpack's Site Accelerator CDN when their URL contains a pattern you choose, for example SVGs, signed URLs or a specific folder.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "cdn",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_patterns = array( '.svg', '/private-uploads/', 'X-Amz-Signature' ); // URL fragments to skip.\n\nadd_filter( 'jetpack_photon_skip_image', function ( $skip, $src ) use ( $wpide_patterns ) {\n    foreach ( $wpide_patterns as $pattern ) {\n        if ( false !== stripos( (string) $src, $pattern ) ) {\n            return true;\n        }\n    }\n\n    return $skip;\n}, 10, 2 );",
            "requires": [
                "jetpack"
            ],
            "notes": "Patterns are matched against the image URL as plain text."
        },
        {
            "id": "jetpack-offline-mode-staging",
            "version": 1,
            "title": "Jetpack Offline Mode on Staging Sites",
            "description": "Switches Jetpack to offline (development) mode automatically on staging, dev and local hosts, so test copies never connect to WordPress.com or clash with the live site.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "staging",
                "development"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_markers = array( 'staging', 'stage.', 'dev.', 'localhost' ); // Host fragments that mean \"not live\".\n\nadd_filter( 'jetpack_offline_mode', function ( $offline ) use ( $wpide_markers ) {\n    $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );\n\n    if ( preg_match( '/\\.(local|test)$/', $host ) ) {\n        return true;\n    }\n\n    foreach ( $wpide_markers as $marker ) {\n        if ( false !== strpos( $host, $marker ) ) {\n            return true;\n        }\n    }\n\n    return $offline;\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Edit $wpide_markers to match your staging hostnames; .local and .test hosts are always included."
        },
        {
            "id": "rocket-purge-related-pages",
            "version": 1,
            "title": "Clear Related Pages From WP Rocket's Cache on Update",
            "description": "When a post is updated, also clears the WP Rocket cache for the homepage, the blog page, its category and tag archives and the author page, so lists never show stale titles or excerpts.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "cache",
                "purge"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_extra_urls = array(); // e.g. array( home_url( '/news/' ) ).\n\nadd_action( 'transition_post_status', function ( $new, $old, $post ) use ( $wpide_extra_urls ) {\n    if ( ! function_exists( 'rocket_clean_files' ) || ( 'publish' !== $new && 'publish' !== $old ) || wp_is_post_revision( $post ) || ! is_post_type_viewable( $post->post_type ) ) {\n        return;\n    }\n\n    $urls = $wpide_extra_urls;\n    $urls[] = home_url( '/' );\n\n    if ( 'post' === $post->post_type && get_option( 'page_for_posts' ) ) {\n        $urls[] = get_permalink( (int) get_option( 'page_for_posts' ) );\n    }\n\n    foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {\n        if ( ! is_taxonomy_viewable( $taxonomy ) ) {\n            continue;\n        }\n        foreach ( (array) wp_get_post_terms( $post->ID, $taxonomy ) as $term ) {\n            $link = get_term_link( $term );\n            if ( ! is_wp_error( $link ) ) {\n                $urls[] = $link;\n            }\n        }\n    }\n\n    $urls[] = get_author_posts_url( (int) $post->post_author );\n\n    rocket_clean_files( array_values( array_unique( array_filter( $urls ) ) ) );\n}, 20, 3 );",
            "requires": [],
            "notes": "Does nothing unless WP Rocket is active. Add extra URLs to $wpide_extra_urls, such as a landing page that lists posts."
        },
        {
            "id": "rocket-never-cache-urls",
            "version": 1,
            "title": "Never Cache Certain URLs in WP Rocket",
            "description": "Adds URL patterns that WP Rocket must never cache, such as account, quote or booking pages with personal content, without touching the plugin's settings screen.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "cache",
                "exclusions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_patterns = array( '/my-account/(.*)', '/quote/(.*)', '/booking/(.*)' ); // Paths never cached.\n\nadd_filter( 'rocket_cache_reject_uri', function ( $uris ) use ( $wpide_patterns ) {\n    return array_values( array_unique( array_merge( (array) $uris, $wpide_patterns ) ) );\n} );",
            "requires": [],
            "notes": "Patterns are regular expressions matched against the path, e.g. /my-account/(.*). Save WP Rocket's settings once after activating so its config file is regenerated."
        },
        {
            "id": "rocket-cache-query-strings",
            "version": 1,
            "title": "Cache Pages With Certain Query Strings in WP Rocket",
            "description": "Tells WP Rocket to keep separate cached copies for URLs with the query parameters you list, such as a currency or filter parameter, instead of skipping the cache for them.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "cache",
                "query-strings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_params = array( 'currency', 'filter_color' ); // Query parameters that deserve their own cache.\n\nadd_filter( 'rocket_cache_query_strings', function ( $params ) use ( $wpide_params ) {\n    return array_values( array_unique( array_merge( (array) $params, $wpide_params ) ) );\n} );",
            "requires": [],
            "notes": "Only add parameters that change page content. Re-save WP Rocket's settings after activating."
        },
        {
            "id": "rocket-delay-js-exclusions",
            "version": 1,
            "title": "Keep Critical Scripts Out of WP Rocket's Delay JavaScript",
            "description": "Excludes scripts you list, such as a consent banner, chat widget or analytics loader, from WP Rocket's \"Delay JavaScript execution\" so they run right away.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "javascript",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_keep = array( 'cookieyes', 'complianz', 'googletagmanager.com', 'jquery.min.js' ); // Patterns.\n\nadd_filter( 'rocket_delay_js_exclusions', function ( $exclusions ) use ( $wpide_keep ) {\n    return array_values( array_unique( array_merge( (array) $exclusions, $wpide_keep ) ) );\n} );",
            "requires": [],
            "notes": "Use part of the script's file name or a keyword from an inline script. Clear WP Rocket's cache after activating."
        },
        {
            "id": "rocket-disable-lazyload-on-pages",
            "version": 1,
            "title": "Turn Off WP Rocket Lazy Load on Chosen Pages",
            "description": "Disables WP Rocket's image and iframe lazy loading on the pages you list, such as a gallery, slider page or landing page where above-the-fold images must load instantly.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "lazy-load",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_pages = array( 'home', 'gallery' ); // Page slugs or IDs.\n\nadd_action( 'wp', function () use ( $wpide_pages ) {\n    if ( is_admin() || ! defined( 'WP_ROCKET_VERSION' ) || ! is_page( $wpide_pages ) ) {\n        return;\n    }\n\n    add_filter( 'do_rocket_lazyload', '__return_false' );\n    add_filter( 'do_rocket_lazyload_iframes', '__return_false' );\n} );",
            "requires": [],
            "notes": "List page IDs or slugs in $wpide_pages. Clear WP Rocket's cache for those pages afterwards."
        },
        {
            "id": "rocket-clear-cache-admin-bar-editors",
            "version": 1,
            "title": "Let Editors Clear the WP Rocket Cache",
            "description": "Adds a \"Clear page cache\" item to the toolbar for editors, who normally cannot purge WP Rocket, protected with a nonce and limited to clearing the page they are viewing.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "cache",
                "editors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! function_exists( 'rocket_clean_files' ) || ! current_user_can( 'edit_others_posts' ) || current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $url = home_url( add_query_arg( array() ) );\n\n    $bar->add_node( array(\n        'id'    => 'wpide-rocket-clear',\n        'title' => esc_html__( 'Clear page cache' ),\n        'href'  => wp_nonce_url( add_query_arg( array( 'action' => 'wpide_rocket_clear', 'url' => rawurlencode( $url ) ), admin_url( 'admin-post.php' ) ), 'wpide_rocket_clear' ),\n    ) );\n}, 90 );\n\nadd_action( 'admin_post_wpide_rocket_clear', function () {\n    check_admin_referer( 'wpide_rocket_clear' );\n\n    if ( ! current_user_can( 'edit_others_posts' ) || ! function_exists( 'rocket_clean_files' ) ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to do that.' ), 403 );\n    }\n\n    $url = isset( $_GET['url'] ) ? esc_url_raw( rawurldecode( wp_unslash( $_GET['url'] ) ) ) : '';\n\n    if ( $url && wp_parse_url( $url, PHP_URL_HOST ) === wp_parse_url( home_url(), PHP_URL_HOST ) ) {\n        rocket_clean_files( array( $url ) );\n    }\n\n    wp_safe_redirect( $url ? $url : home_url( '/' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Administrators already have WP Rocket's own menu. Change edit_others_posts to another capability if needed."
        },
        {
            "id": "rocket-purge-after-options-save",
            "version": 1,
            "title": "Clear WP Rocket When Site Settings Change",
            "description": "Clears WP Rocket's whole-site cache when you save the Customizer, change menus or edit widgets, so header, footer and sidebar changes show up immediately.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "cache",
                "customizer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_clear = function () {\n    if ( function_exists( 'rocket_clean_domain' ) ) {\n        rocket_clean_domain();\n    }\n};\n\nadd_action( 'customize_save_after', $wpide_clear );\nadd_action( 'wp_update_nav_menu', $wpide_clear );\nadd_action( 'update_option_sidebars_widgets', $wpide_clear );",
            "requires": [],
            "notes": "WP Rocket already clears for theme switches; this covers the everyday design edits."
        },
        {
            "id": "litespeed-purge-related-pages",
            "version": 1,
            "title": "Purge Related Pages From LiteSpeed Cache on Update",
            "description": "When a post changes, also purges the homepage and the post's category and tag archives from LiteSpeed Cache, so lists and teasers are never out of date.",
            "category": "integrations",
            "tags": [
                "litespeed",
                "cache",
                "purge"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'transition_post_status', function ( $new, $old, $post ) {\n    if ( ! defined( 'LSCWP_V' ) || ( 'publish' !== $new && 'publish' !== $old ) || wp_is_post_revision( $post ) || ! is_post_type_viewable( $post->post_type ) ) {\n        return;\n    }\n\n    do_action( 'litespeed_purge_url', home_url( '/' ) );\n\n    foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {\n        if ( ! is_taxonomy_viewable( $taxonomy ) ) {\n            continue;\n        }\n        foreach ( (array) wp_get_post_terms( $post->ID, $taxonomy ) as $term ) {\n            $link = get_term_link( $term );\n            if ( ! is_wp_error( $link ) ) {\n                do_action( 'litespeed_purge_url', $link );\n            }\n        }\n    }\n}, 20, 3 );",
            "requires": [],
            "notes": "Uses LiteSpeed Cache's documented purge actions and does nothing if the plugin is inactive."
        },
        {
            "id": "litespeed-nocache-pages",
            "version": 1,
            "title": "Exclude Pages From LiteSpeed Cache by Rule",
            "description": "Marks chosen pages as not cacheable in LiteSpeed Cache from code, for example pages with a personalised widget, a query parameter or for visitors with a specific cookie.",
            "category": "integrations",
            "tags": [
                "litespeed",
                "cache",
                "exclusions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "$wpide_pages   = array( 'quote', 'booking' );  // Page slugs or IDs.\n$wpide_params  = array( 'preview_token' );     // Query parameters.\n$wpide_cookies = array( 'wpide_personalised' ); // Cookie names.\n\nadd_action( 'template_redirect', function () use ( $wpide_pages, $wpide_params, $wpide_cookies ) {\n    if ( ! defined( 'LSCWP_V' ) ) {\n        return;\n    }\n\n    $reason = '';\n\n    if ( $wpide_pages && is_page( $wpide_pages ) ) {\n        $reason = 'wpide page rule';\n    }\n    foreach ( $wpide_params as $param ) {\n        if ( isset( $_GET[ $param ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n            $reason = 'wpide query rule';\n        }\n    }\n    foreach ( $wpide_cookies as $cookie ) {\n        if ( isset( $_COOKIE[ $cookie ] ) ) {\n            $reason = 'wpide cookie rule';\n        }\n    }\n\n    if ( $reason ) {\n        do_action( 'litespeed_control_set_nocache', $reason );\n    }\n} );",
            "requires": [],
            "notes": "Edit the three lists; any match marks the page as non-cacheable for that request."
        },
        {
            "id": "litespeed-purge-on-settings-change",
            "version": 1,
            "title": "Purge LiteSpeed Cache After Design Changes",
            "description": "Purges the whole LiteSpeed Cache when the Customizer, menus or widgets are saved, so header, footer and sidebar changes appear on every page right away.",
            "category": "integrations",
            "tags": [
                "litespeed",
                "cache",
                "customizer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_purge = function () {\n    if ( defined( 'LSCWP_V' ) ) {\n        do_action( 'litespeed_purge_all' );\n    }\n};\n\nadd_action( 'customize_save_after', $wpide_purge );\nadd_action( 'wp_update_nav_menu', $wpide_purge );\nadd_action( 'update_option_sidebars_widgets', $wpide_purge );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "litespeed-private-cache-logged-in-pages",
            "version": 1,
            "title": "Use LiteSpeed Private Cache on Personalised Pages",
            "description": "Serves pages you choose (such as a dashboard or account page) from LiteSpeed's private per-visitor cache instead of the public one, so personal content is cached safely.",
            "category": "integrations",
            "tags": [
                "litespeed",
                "cache",
                "private"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "$wpide_pages = array( 'dashboard', 'my-account' ); // Page slugs or IDs.\n\nadd_action( 'template_redirect', function () use ( $wpide_pages ) {\n    if ( defined( 'LSCWP_V' ) && is_page( $wpide_pages ) ) {\n        do_action( 'litespeed_control_set_private', 'wpide personalised page' );\n    }\n} );",
            "requires": [],
            "notes": "List the page slugs or IDs in $wpide_pages."
        },
        {
            "id": "w3tc-flush-related-pages",
            "version": 1,
            "title": "Flush Related Pages From W3 Total Cache on Update",
            "description": "When a post changes, also flushes the homepage and the post's category and tag archives from W3 Total Cache's page cache, so lists update with the post.",
            "category": "integrations",
            "tags": [
                "w3-total-cache",
                "cache",
                "purge"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'transition_post_status', function ( $new, $old, $post ) {\n    if ( ! function_exists( 'w3tc_flush_url' ) || ( 'publish' !== $new && 'publish' !== $old ) || wp_is_post_revision( $post ) || ! is_post_type_viewable( $post->post_type ) ) {\n        return;\n    }\n\n    w3tc_flush_url( home_url( '/' ) );\n\n    foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {\n        if ( ! is_taxonomy_viewable( $taxonomy ) ) {\n            continue;\n        }\n        foreach ( (array) wp_get_post_terms( $post->ID, $taxonomy ) as $term ) {\n            $link = get_term_link( $term );\n            if ( ! is_wp_error( $link ) ) {\n                w3tc_flush_url( $link );\n            }\n        }\n    }\n}, 20, 3 );",
            "requires": [],
            "notes": "Uses W3 Total Cache's public flush functions and does nothing if it is not active."
        },
        {
            "id": "w3tc-skip-cache-rules",
            "version": 1,
            "title": "Skip W3 Total Cache on Chosen Pages",
            "description": "Tells W3 Total Cache not to page-cache, minify or serve from CDN the pages you choose, using its documented DONOTCACHEPAGE-style constants.",
            "category": "integrations",
            "tags": [
                "w3-total-cache",
                "cache",
                "exclusions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "$wpide_pages       = array( 'checkout-custom', 'live-scores' ); // Page slugs or IDs.\n$wpide_skip_minify = true;                                       // Also skip minify and CDN.\n\nadd_action( 'template_redirect', function () use ( $wpide_pages, $wpide_skip_minify ) {\n    if ( ! defined( 'W3TC' ) || ! is_page( $wpide_pages ) ) {\n        return;\n    }\n\n    if ( ! defined( 'DONOTCACHEPAGE' ) ) {\n        define( 'DONOTCACHEPAGE', true );\n    }\n    if ( $wpide_skip_minify && ! defined( 'DONOTMINIFY' ) ) {\n        define( 'DONOTMINIFY', true );\n    }\n    if ( $wpide_skip_minify && ! defined( 'DONOTCDN' ) ) {\n        define( 'DONOTCDN', true );\n    }\n} );",
            "requires": [],
            "notes": "List page slugs or IDs; set $wpide_skip_minify to false to keep minification on those pages."
        },
        {
            "id": "w3tc-flush-on-settings-change",
            "version": 1,
            "title": "Flush W3 Total Cache After Design Changes",
            "description": "Flushes all W3 Total Cache caches when the Customizer, menus or widgets are saved, so site-wide changes are visible straight away.",
            "category": "integrations",
            "tags": [
                "w3-total-cache",
                "cache",
                "customizer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_flush = function () {\n    if ( function_exists( 'w3tc_flush_all' ) ) {\n        w3tc_flush_all();\n    }\n};\n\nadd_action( 'customize_save_after', $wpide_flush );\nadd_action( 'wp_update_nav_menu', $wpide_flush );\nadd_action( 'update_option_sidebars_widgets', $wpide_flush );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "cache-plugins-flush-button-any",
            "version": 1,
            "title": "One \"Clear Cache\" Button for Any Caching Plugin",
            "description": "Adds a single \"Clear all caches\" toolbar button for administrators that clears whichever cache plugin is active (WP Rocket, LiteSpeed Cache, W3 Total Cache, WP Super Cache, WP Fastest Cache or SG Optimizer) plus the object cache.",
            "category": "integrations",
            "tags": [
                "cache",
                "admin-bar",
                "purge"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-clear-all-caches',\n        'title' => esc_html__( 'Clear all caches' ),\n        'href'  => wp_nonce_url( add_query_arg( 'action', 'wpide_clear_all_caches', admin_url( 'admin-post.php' ) ), 'wpide_clear_all_caches' ),\n    ) );\n}, 95 );\n\nadd_action( 'admin_post_wpide_clear_all_caches', function () {\n    check_admin_referer( 'wpide_clear_all_caches' );\n\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to do that.' ), 403 );\n    }\n\n    $cleared = array();\n\n    if ( function_exists( 'rocket_clean_domain' ) ) {\n        rocket_clean_domain();\n        $cleared[] = 'WP Rocket';\n    }\n    if ( defined( 'LSCWP_V' ) ) {\n        do_action( 'litespeed_purge_all' );\n        $cleared[] = 'LiteSpeed';\n    }\n    if ( function_exists( 'w3tc_flush_all' ) ) {\n        w3tc_flush_all();\n        $cleared[] = 'W3 Total Cache';\n    }\n    if ( function_exists( 'wp_cache_clear_cache' ) ) {\n        wp_cache_clear_cache();\n        $cleared[] = 'WP Super Cache';\n    }\n    if ( isset( $GLOBALS['wp_fastest_cache'] ) && method_exists( $GLOBALS['wp_fastest_cache'], 'deleteCache' ) ) {\n        $GLOBALS['wp_fastest_cache']->deleteCache( true );\n        $cleared[] = 'WP Fastest Cache';\n    }\n    if ( function_exists( 'sg_cachepress_purge_cache' ) ) {\n        sg_cachepress_purge_cache();\n        $cleared[] = 'SG Optimizer';\n    }\n\n    wp_cache_flush();\n    $cleared[] = 'object cache';\n\n    set_transient( 'wpide_cleared_' . get_current_user_id(), implode( ', ', $cleared ), 60 );\n    wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    $key  = 'wpide_cleared_' . get_current_user_id();\n    $done = get_transient( $key );\n\n    if ( $done ) {\n        delete_transient( $key );\n        echo '<div class=\"notice notice-success is-dismissible\"><p>' . esc_html( sprintf( 'Cleared: %s.', $done ) ) . '</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Handy on agency sites where different clients use different cache plugins. Only administrators see it."
        },
        {
            "id": "redirection-editors-manage",
            "version": 1,
            "title": "Let Editors Manage Redirects in Redirection",
            "description": "Gives editors access to the Redirection plugin's screens so they can add and fix redirects themselves, without making them administrators.",
            "category": "integrations",
            "tags": [
                "redirection",
                "permissions",
                "editors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'redirection_role', function ( $role ) {\n    return defined( 'REDIRECTION_VERSION' ) ? 'edit_others_posts' : $role;\n} );",
            "requires": [],
            "notes": "Change edit_others_posts to the capability of the role you want to allow. Does nothing if Redirection is not active."
        },
        {
            "id": "redirection-anonymize-log-ip",
            "version": 1,
            "title": "Anonymize IP Addresses in Redirection Logs",
            "description": "Masks the last part of visitor IP addresses before the Redirection plugin stores them in its redirect and 404 logs, keeping the logs useful while respecting privacy laws.",
            "category": "integrations",
            "tags": [
                "redirection",
                "privacy",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'redirection_request_ip', function ( $ip ) {\n    if ( ! is_string( $ip ) || '' === $ip ) {\n        return $ip;\n    }\n\n    if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n        return preg_replace( '/\\.\\d+$/', '.0', $ip );\n    }\n\n    if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {\n        $parts = explode( ':', $ip );\n        return implode( ':', array_slice( $parts, 0, 3 ) ) . '::';\n    }\n\n    return $ip;\n} );",
            "requires": [],
            "notes": "IPv4 keeps the first three parts (1.2.3.0); IPv6 keeps the first three groups."
        },
        {
            "id": "redirection-ignore-tracking-params",
            "version": 1,
            "title": "Ignore Tracking Parameters When Matching Redirects",
            "description": "Strips utm_*, fbclid, gclid and similar tracking parameters from the URL the Redirection plugin matches against, so a redirect still fires when a link arrives from a campaign.",
            "category": "integrations",
            "tags": [
                "redirection",
                "utm",
                "matching"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_params = array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'msclkid', 'mc_cid', 'mc_eid' );\n\nadd_filter( 'redirection_request_url', function ( $url ) use ( $wpide_params ) {\n    return is_string( $url ) && false !== strpos( $url, '?' ) ? remove_query_arg( $wpide_params, $url ) : $url;\n} );",
            "requires": [],
            "notes": "Add parameters to $wpide_params. The visitor's analytics are unaffected because the original URL is only changed for matching."
        },
        {
            "id": "redirection-skip-bot-404-logs",
            "version": 1,
            "title": "Keep Bots Out of Redirection's 404 Log",
            "description": "Stops the Redirection plugin from logging 404s caused by crawlers and vulnerability scanners (wp-login probes, .env and .php file hunts), so the log shows only real broken links.",
            "category": "integrations",
            "tags": [
                "redirection",
                "404",
                "logs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_noise = array( '.env', 'wp-config', '.php', 'xmlrpc', '/.git', 'phpmyadmin', 'wlwmanifest' ); // URL fragments to ignore.\n\nadd_filter( 'redirection_404_data', function ( $data ) use ( $wpide_noise ) {\n    $agent = isset( $data['agent'] ) ? strtolower( (string) $data['agent'] ) : '';\n    $url   = isset( $data['url'] ) ? strtolower( (string) $data['url'] ) : '';\n\n    if ( preg_match( '/bot|crawl|spider|scan|curl|python|wget/', $agent ) ) {\n        return false;\n    }\n\n    foreach ( $wpide_noise as $fragment ) {\n        if ( false !== strpos( $url, $fragment ) ) {\n            return false;\n        }\n    }\n\n    return $data;\n} );",
            "requires": [],
            "notes": "Add your own patterns to $wpide_noise."
        },
        {
            "id": "wordfence-route-alerts",
            "version": 1,
            "title": "Send Wordfence Alerts to a Separate Address",
            "description": "Redirects Wordfence's security alert emails to a dedicated address (such as your agency's monitoring inbox) and adds a site tag to the subject, without changing the site admin email.",
            "category": "integrations",
            "tags": [
                "wordfence",
                "security",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_alerts_to = 'security@example.com'; // Recipient for Wordfence emails.\n$wpide_tag       = '[' . wp_parse_url( home_url(), PHP_URL_HOST ) . ']'; // Added to the subject.\n\nadd_filter( 'wp_mail', function ( $args ) use ( $wpide_alerts_to, $wpide_tag ) {\n    if ( ! defined( 'WORDFENCE_VERSION' ) || empty( $args['subject'] ) || false === stripos( $args['subject'], 'wordfence' ) ) {\n        return $args;\n    }\n\n    $args['to'] = $wpide_alerts_to;\n\n    if ( false === strpos( $args['subject'], $wpide_tag ) ) {\n        $args['subject'] = $wpide_tag . ' ' . $args['subject'];\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Matches emails whose subject contains \"Wordfence\". Keep the address in Wordfence's own settings too if you want both to receive them."
        },
        {
            "id": "wordfence-hide-dashboard-widget",
            "version": 1,
            "title": "Hide the Wordfence Dashboard Widget From Non-Admins",
            "description": "Removes Wordfence's activity report widget from the dashboard for editors and other roles, keeping their dashboard focused on content.",
            "category": "integrations",
            "tags": [
                "wordfence",
                "dashboard",
                "widgets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_everyone = false; // true = hide for all users.\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_everyone ) {\n    if ( ! defined( 'WORDFENCE_VERSION' ) ) {\n        return;\n    }\n\n    if ( $wpide_everyone || ! current_user_can( 'manage_options' ) ) {\n        remove_meta_box( 'wordfence_activity_report_widget', 'dashboard', 'normal' );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Set $wpide_everyone to true to hide it for administrators as well."
        },
        {
            "id": "smush-no-lazy-hero-images",
            "version": 1,
            "title": "Exclude Logos and Featured Images From Lazy Loading",
            "description": "Adds the \"no-lazyload\" class (and matching skip attributes) to the site logo and the featured image of single posts, so Smush and other lazy-load plugins load them immediately and improve Largest Contentful Paint.",
            "category": "integrations",
            "tags": [
                "smush",
                "lazy-load",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_custom_logo_image_attributes', function ( $attr ) {\n    $attr['class']         = trim( ( isset( $attr['class'] ) ? $attr['class'] : '' ) . ' no-lazyload skip-lazy' );\n    $attr['loading']       = 'eager';\n    $attr['data-no-lazy']  = '1';\n\n    return $attr;\n} );\n\nadd_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment ) {\n    if ( is_admin() || ! is_singular() || (int) get_post_thumbnail_id( get_queried_object_id() ) !== (int) $attachment->ID ) {\n        return $attr;\n    }\n\n    $attr['class']        = trim( ( isset( $attr['class'] ) ? $attr['class'] : '' ) . ' no-lazyload skip-lazy' );\n    $attr['loading']      = 'eager';\n    $attr['data-no-lazy'] = '1';\n\n    return $attr;\n}, 10, 2 );",
            "requires": [],
            "notes": "Smush, WP Rocket and LiteSpeed all honour these markers; it also turns off native lazy loading for those images."
        },
        {
            "id": "monsterinsights-hide-dashboard-widget",
            "version": 1,
            "title": "Hide the MonsterInsights Dashboard Widget",
            "description": "Removes the MonsterInsights reports widget from the WordPress dashboard for everyone except administrators, or for all users.",
            "category": "integrations",
            "tags": [
                "monsterinsights",
                "dashboard",
                "widgets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_everyone = false; // true = remove for all users.\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_everyone ) {\n    if ( ! function_exists( 'MonsterInsights' ) && ! function_exists( 'monsterinsights_get_option' ) ) {\n        return;\n    }\n\n    if ( $wpide_everyone || ! current_user_can( 'manage_options' ) ) {\n        remove_meta_box( 'monsterinsights_reports_widget', 'dashboard', 'normal' );\n        remove_meta_box( 'monsterinsights_reports_widget', 'dashboard', 'side' );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Set $wpide_everyone to true to remove it for administrators too."
        },
        {
            "id": "mc4wp-tag-subscribers",
            "version": 1,
            "title": "Tag Every Mailchimp Subscriber From Your Site",
            "description": "Adds tags such as \"website\" and the visitor's language to every subscriber Mailchimp for WordPress sends to Mailchimp, from forms and integrations alike, so you can segment by source.",
            "category": "integrations",
            "tags": [
                "mailchimp",
                "newsletter",
                "tags"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_tags = array( 'website' ); // Tags added to every subscriber.\n\nadd_filter( 'mc4wp_subscriber_data', function ( $subscriber ) use ( $wpide_tags ) {\n    if ( ! is_object( $subscriber ) ) {\n        return $subscriber;\n    }\n\n    $lang = strtok( determine_locale(), '_' );\n    if ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $lang = pll_current_language();\n    } elseif ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $lang = ICL_LANGUAGE_CODE;\n    }\n\n    $tags   = $wpide_tags;\n    $tags[] = 'lang-' . sanitize_key( $lang );\n\n    $subscriber->tags = array_values( array_unique( array_merge( isset( $subscriber->tags ) ? (array) $subscriber->tags : array(), $tags ) ) );\n\n    return $subscriber;\n} );",
            "requires": [],
            "notes": "Edit $wpide_tags. The language tag uses the site or WPML/Polylang language."
        },
        {
            "id": "mc4wp-signup-page-merge-field",
            "version": 1,
            "title": "Save the Signup Page in a Mailchimp Merge Field",
            "description": "Stores the URL of the page where someone subscribed in a Mailchimp merge field, so you can see which articles and landing pages grow your list.",
            "category": "integrations",
            "tags": [
                "mailchimp",
                "newsletter",
                "attribution"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_field = 'SIGNUPURL'; // Mailchimp merge field tag.\n\nadd_filter( 'mc4wp_subscriber_data', function ( $subscriber ) use ( $wpide_field ) {\n    if ( ! is_object( $subscriber ) ) {\n        return $subscriber;\n    }\n\n    $url = wp_get_referer();\n    if ( ! $url && isset( $_SERVER['REQUEST_URI'] ) ) {\n        $url = home_url( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) );\n    }\n\n    if ( $url ) {\n        $subscriber->merge_fields = isset( $subscriber->merge_fields ) ? (array) $subscriber->merge_fields : array();\n        $subscriber->merge_fields[ $wpide_field ] = esc_url_raw( strtok( $url, '#' ) );\n    }\n\n    return $subscriber;\n} );",
            "requires": [],
            "notes": "Create a text merge field in your Mailchimp audience first and put its tag (e.g. SIGNUPURL) in $wpide_field."
        },
        {
            "id": "mc4wp-block-disposable-emails",
            "version": 1,
            "title": "Block Disposable Emails in Mailchimp for WP Forms",
            "description": "Refuses newsletter signups from throwaway email domains in Mailchimp for WordPress forms, keeping your audience clean and your Mailchimp bill lower.",
            "category": "integrations",
            "tags": [
                "mailchimp",
                "validation",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_blocked = array( 'mailinator.com', 'yopmail.com', '10minutemail.com', 'guerrillamail.com', 'tempmail.com', 'trashmail.com', 'getnada.com' );\n$wpide_message = 'Please use a permanent email address.';\n\nadd_filter( 'mc4wp_form_errors', function ( $errors, $form ) use ( $wpide_blocked ) {\n    $data   = is_object( $form ) && isset( $form->data ) ? (array) $form->data : array();\n    $email  = isset( $data['EMAIL'] ) ? strtolower( (string) $data['EMAIL'] ) : '';\n    $domain = substr( (string) strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_blocked, true ) ) {\n        $errors[] = 'wpide_disposable_email';\n    }\n\n    return $errors;\n}, 10, 2 );\n\nadd_filter( 'mc4wp_form_messages', function ( $messages ) use ( $wpide_message ) {\n    $messages['wpide_disposable_email'] = $wpide_message;\n\n    return $messages;\n} );",
            "requires": [],
            "notes": "Add domains to $wpide_blocked. The error text can be changed in $wpide_message."
        },
        {
            "id": "mc4wp-notify-team-on-signup",
            "version": 1,
            "title": "Email the Team About New Newsletter Signups",
            "description": "Sends a short email to your team each time someone subscribes through a Mailchimp for WordPress form, including the form name and page, for low-volume lists where every lead counts.",
            "category": "integrations",
            "tags": [
                "mailchimp",
                "newsletter",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_notify_to = get_option( 'admin_email' ); // Recipient(s).\n\nadd_action( 'mc4wp_form_subscribed', function ( $form, $email ) use ( $wpide_notify_to ) {\n    $page = wp_get_referer();\n\n    wp_mail(\n        $wpide_notify_to,\n        sprintf( '[%s] New newsletter subscriber', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf(\n            \"Email: %1\\$s\\nForm: %2\\$s\\nPage: %3\\$s\\nTime: %4\\$s\\n\",\n            sanitize_email( $email ),\n            is_object( $form ) && isset( $form->name ) ? $form->name : '-',\n            $page ? $page : '-',\n            wp_date( 'Y-m-d H:i' )\n        )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "Set $wpide_notify_to. Not recommended for lists with dozens of signups per day."
        },
        {
            "id": "akismet-keep-spam-longer",
            "version": 1,
            "title": "Keep Akismet Spam Longer Before Deleting",
            "description": "Changes how long Akismet keeps caught spam before deleting it (15 days by default), giving you more time to rescue legitimate comments marked as spam.",
            "category": "integrations",
            "tags": [
                "akismet",
                "spam",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_days = 30; // Days to keep spam.\n\nadd_filter( 'akismet_delete_comment_interval', function () use ( $wpide_days ) {\n    return max( 1, (int) $wpide_days );\n} );",
            "requires": [],
            "notes": "Set $wpide_days; use a lower number to clean up sooner on very busy sites."
        },
        {
            "id": "akismet-privacy-notice",
            "version": 1,
            "title": "Show a Custom Akismet Privacy Notice Under Comments",
            "description": "Turns on the Akismet privacy notice below the comment form and replaces it with your own wording and link to your privacy policy, which many privacy laws expect.",
            "category": "integrations",
            "tags": [
                "akismet",
                "privacy",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'akismet_comment_form_privacy_notice', function () {\n    return 'display';\n} );\n\nadd_filter( 'akismet_comment_form_privacy_notice_markup', function () {\n    $policy = get_privacy_policy_url();\n\n    return '<p class=\"akismet_comment_form_privacy_notice\" style=\"font-size:.85em\">'\n        . esc_html__( 'Comments are checked for spam by Akismet.' ) . ' '\n        . ( $policy ? '<a href=\"' . esc_url( $policy ) . '\">' . esc_html__( 'How we handle your data' ) . '</a>' : '<a href=\"https://akismet.com/privacy/\" rel=\"nofollow noopener\">' . esc_html__( 'How Akismet handles your data' ) . '</a>' )\n        . '</p>';\n} );",
            "requires": [],
            "notes": "Uses your Privacy Policy page from Settings > Privacy when one is set."
        },
        {
            "id": "akismet-skip-logged-in-users",
            "version": 1,
            "title": "Skip Akismet Checks for Trusted Logged-In Users",
            "description": "Stops sending comments from logged-in users who can edit posts (authors, editors, administrators) to Akismet, saving API calls and keeping staff replies out of the spam queue.",
            "category": "integrations",
            "tags": [
                "akismet",
                "comments",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'preprocess_comment', function ( $commentdata ) {\n    if ( class_exists( 'Akismet' ) && is_user_logged_in() && current_user_can( 'edit_posts' ) ) {\n        remove_filter( 'preprocess_comment', array( 'Akismet', 'auto_check_comment' ), 1 );\n    }\n\n    return $commentdata;\n}, 0 );",
            "requires": [],
            "notes": "Change edit_posts to moderate_comments to trust only moderators and administrators."
        },
        {
            "id": "akismet-spam-blocked-shortcode",
            "version": 1,
            "title": "\"Spam Blocked by Akismet\" Counter Shortcode",
            "description": "Adds a [wpide_akismet_count] shortcode that shows how many spam comments Akismet has blocked on your site, a small trust signal for footers and contact pages.",
            "category": "integrations",
            "tags": [
                "akismet",
                "spam",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_akismet_count', function ( $atts ) {\n    if ( ! class_exists( 'Akismet' ) ) {\n        return '';\n    }\n\n    $atts  = shortcode_atts( array( 'text' => '%s spam comments blocked by Akismet' ), $atts, 'wpide_akismet_count' );\n    $count = (int) get_option( 'akismet_spam_count', 0 );\n\n    if ( ! $count ) {\n        return '';\n    }\n\n    return '<span class=\"wpide-akismet-count\">' . esc_html( sprintf( $atts['text'], number_format_i18n( $count ) ) ) . '</span>';\n} );",
            "requires": [],
            "notes": "Use [wpide_akismet_count text=\"%s spam comments blocked\"]; %s becomes the number."
        },
        {
            "id": "akismet-check-custom-form",
            "version": 1,
            "title": "Check Your Own Form Submissions With Akismet",
            "description": "Adds a wpide_akismet_is_spam() helper that sends a name, email and message from any custom form to Akismet and tells you whether it looks like spam, so your hand-built forms get the same protection as comments.",
            "category": "integrations",
            "tags": [
                "akismet",
                "forms",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_akismet_is_spam' ) ) {\n    function wpide_akismet_is_spam( array $fields ) {\n        if ( ! class_exists( 'Akismet' ) || ! Akismet::get_api_key() ) {\n            return false;\n        }\n\n        $request = array(\n            'blog'                 => home_url(),\n            'blog_lang'            => get_locale(),\n            'blog_charset'         => get_option( 'blog_charset' ),\n            'user_ip'              => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n            'user_agent'           => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '',\n            'referrer'             => wp_get_raw_referer() ? wp_get_raw_referer() : '',\n            'permalink'            => wp_get_referer() ? wp_get_referer() : home_url( '/' ),\n            'comment_type'         => 'contact-form',\n            'comment_author'       => isset( $fields['name'] ) ? (string) $fields['name'] : '',\n            'comment_author_email' => isset( $fields['email'] ) ? (string) $fields['email'] : '',\n            'comment_content'      => isset( $fields['message'] ) ? (string) $fields['message'] : '',\n        );\n\n        $response = Akismet::http_post( Akismet::build_query( $request ), 'comment-check' );\n\n        return is_array( $response ) && isset( $response[1] ) && 'true' === trim( $response[1] );\n    }\n}",
            "requires": [],
            "notes": "Call wpide_akismet_is_spam( array( 'name' => ..., 'email' => ..., 'message' => ... ) ) in your form handler; it returns false when Akismet is missing or unreachable, so submissions are never lost."
        },
        {
            "id": "akismet-hide-approved-count",
            "version": 1,
            "title": "Hide Akismet's Approved-Comments Count in the Comments List",
            "description": "Removes the \"X approved\" badge Akismet shows next to each commenter in Comments, which some moderators find distracting or which reveals too much about returning visitors.",
            "category": "integrations",
            "tags": [
                "akismet",
                "comments",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'akismet_show_user_comments_approved', '__return_false' );",
            "requires": [],
            "notes": "\"\""
        },
        {
            "id": "tec-event-category-colors",
            "version": 1,
            "title": "Colour-Code Event Categories in Calendar Views",
            "description": "Adds a coloured left border to events in The Events Calendar's list, day and month views based on their event category, so visitors can scan the calendar by type.",
            "category": "integrations",
            "tags": [
                "events-calendar",
                "categories",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_colors = array( // Event category slug => colour.\n    'workshops' => '#2271b1',\n    'concerts'  => '#d63638',\n    'kids'      => '#00a32a',\n);\n\nadd_action( 'wp_head', function () use ( $wpide_colors ) {\n    if ( ! class_exists( 'Tribe__Events__Main' ) || ! function_exists( 'tribe_is_event_query' ) || ! tribe_is_event_query() ) {\n        return;\n    }\n\n    $css = '';\n    foreach ( $wpide_colors as $slug => $color ) {\n        $slug  = sanitize_html_class( $slug );\n        $color = sanitize_hex_color( $color );\n        if ( $slug && $color ) {\n            $css .= sprintf(\n                '.tribe-events .tribe_events_cat-%1$s,.tribe-events .tribe-events-calendar-month__calendar-event.tribe_events_cat-%1$s{border-inline-start:4px solid %2$s;padding-inline-start:8px}',\n                $slug,\n                $color\n            );\n        }\n    }\n\n    if ( $css ) {\n        echo '<style id=\"wpide-tec-cat-colors\">' . $css . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput -- built from sanitized parts.\n    }\n} );",
            "requires": [
                "events-calendar"
            ],
            "notes": "Map category slugs to colours in $wpide_colors. Uses the category classes TEC adds to each event."
        },
        {
            "id": "give-donor-count-admin-bar",
            "version": 1,
            "title": "Today's Donations in the Admin Bar (GiveWP)",
            "description": "Shows administrators the number and total of donations received today in the toolbar, linking to GiveWP's donations list, for a quick pulse during campaigns.",
            "category": "integrations",
            "tags": [
                "givewp",
                "admin-bar",
                "donations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! function_exists( 'give_donation_amount' ) || ! current_user_can( 'view_give_reports' ) ) {\n        return;\n    }\n\n    $data = get_transient( 'wpide_give_today' );\n\n    if ( false === $data ) {\n        $ids  = get_posts( array(\n            'post_type'     => 'give_payment',\n            'post_status'   => 'publish',\n            'numberposts'   => 500,\n            'fields'        => 'ids',\n            'no_found_rows' => true,\n            'date_query'    => array( array( 'after' => wp_date( 'Y-m-d 00:00:00' ), 'inclusive' => true ) ),\n        ) );\n        $data = array( 'count' => count( $ids ), 'total' => 0.0 );\n        foreach ( $ids as $id ) {\n            $data['total'] += (float) give_donation_amount( $id );\n        }\n        set_transient( 'wpide_give_today', $data, 5 * MINUTE_IN_SECONDS );\n    }\n\n    $total = function_exists( 'give_currency_filter' ) ? give_currency_filter( give_format_amount( $data['total'], array( 'sanitize' => false ) ) ) : number_format_i18n( $data['total'], 2 );\n\n    $bar->add_node( array(\n        'id'    => 'wpide-give-today',\n        'title' => esc_html( sprintf( 'Today: %1$d donations, %2$s', $data['count'], html_entity_decode( wp_strip_all_tags( $total ) ) ) ),\n        'href'  => admin_url( 'edit.php?post_type=give_forms&page=give-payment-history' ),\n    ) );\n}, 90 );\n\nadd_action( 'give_complete_donation', function () {\n    delete_transient( 'wpide_give_today' );\n} );",
            "requires": [
                "givewp"
            ],
            "notes": "Cached for five minutes; counts completed donations only."
        },
        {
            "id": "give-donor-comment-limit",
            "version": 1,
            "title": "Donor Comment Length Limit for GiveWP",
            "description": "Limits the optional donor comment on GiveWP forms to a sensible length and strips links from it, so donor walls do not fill up with spam.",
            "category": "integrations",
            "tags": [
                "givewp",
                "comments",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_max_chars = 280; // Maximum comment length.\n\nadd_action( 'give_checkout_error_checks', function ( $valid_data, $data ) use ( $wpide_max_chars ) {\n    $comment = isset( $data['give_comment'] ) ? (string) $data['give_comment'] : '';\n\n    if ( '' === $comment || ! function_exists( 'give_set_error' ) ) {\n        return;\n    }\n\n    if ( mb_strlen( $comment ) > $wpide_max_chars ) {\n        give_set_error( 'wpide_comment_length', sprintf( __( 'Please keep your comment under %d characters.' ), $wpide_max_chars ) );\n    }\n\n    if ( preg_match( '#https?://|www\\.#i', $comment ) ) {\n        give_set_error( 'wpide_comment_links', __( 'Links are not allowed in donation comments.' ) );\n    }\n}, 10, 2 );",
            "requires": [
                "givewp"
            ],
            "notes": "Change $wpide_max_chars. Only affects forms with the donor comment field enabled."
        },
        {
            "id": "give-hide-menu-non-staff",
            "version": 1,
            "title": "Hide GiveWP Menus From Non-Fundraising Staff",
            "description": "Removes the Donations menu from the dashboard for users who cannot view GiveWP reports, keeping editors' menus short on charity sites.",
            "category": "integrations",
            "tags": [
                "givewp",
                "admin-menu",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( defined( 'GIVE_VERSION' ) && ! current_user_can( 'view_give_reports' ) ) {\n        remove_menu_page( 'edit.php?post_type=give_forms' );\n    }\n}, 999 );",
            "requires": [
                "givewp"
            ],
            "notes": "Give your fundraising team the view_give_reports capability (GiveWP's Worker and Manager roles have it)."
        },
        {
            "id": "affwp-custom-referral-variable-link",
            "version": 1,
            "title": "Copyable Referral Link for Any Page (AffiliateWP)",
            "description": "Adds a [wpide_affiliate_link] shortcode that shows logged-in affiliates their referral link for the current page (or a page you choose) with a copy button.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "referral-links",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_affiliate_link', function ( $atts ) {\n    if ( ! function_exists( 'affwp_get_affiliate_id' ) || ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $affiliate_id = affwp_get_affiliate_id( get_current_user_id() );\n\n    if ( ! $affiliate_id || ! affwp_is_active_affiliate( $affiliate_id ) ) {\n        return '';\n    }\n\n    $atts = shortcode_atts( array( 'url' => '' ), $atts, 'wpide_affiliate_link' );\n    $base = $atts['url'] ? esc_url_raw( $atts['url'] ) : get_permalink();\n    $link = affwp_get_affiliate_referral_url( array( 'affiliate_id' => $affiliate_id, 'base_url' => $base ) );\n    $id   = wp_unique_id( 'wpide-afflink-' );\n\n    return sprintf(\n        '<div class=\"wpide-affiliate-link\"><label for=\"%1$s\">%2$s</label><div style=\"display:flex;gap:6px\"><input id=\"%1$s\" type=\"text\" readonly value=\"%3$s\" style=\"flex:1;min-width:0\"><button type=\"button\" onclick=\"var i=document.getElementById(\\'%1$s\\');i.select();if(navigator.clipboard){navigator.clipboard.writeText(i.value);}this.textContent=\\'%5$s\\';\">%4$s</button></div></div>',\n        esc_attr( $id ),\n        esc_html__( 'Your referral link for this page' ),\n        esc_attr( $link ),\n        esc_html__( 'Copy' ),\n        esc_js( __( 'Copied' ) )\n    );\n} );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Use [wpide_affiliate_link url=\"https://example.com/product/\"] to build a link to another page. Non-affiliates see nothing."
        },
        {
            "id": "affwp-notify-on-paid-referral",
            "version": 1,
            "title": "Notify Affiliates When a Referral Is Paid (AffiliateWP)",
            "description": "Emails an affiliate a short thank-you when one of their referrals is marked as paid, with the amount, which keeps partners motivated between payouts.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "email",
                "referrals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'affwp_set_referral_status', function ( $referral_id, $new_status, $old_status ) {\n    if ( 'paid' !== $new_status || 'paid' === $old_status || ! function_exists( 'affwp_get_referral' ) ) {\n        return;\n    }\n\n    $referral  = affwp_get_referral( $referral_id );\n    $affiliate = $referral ? affwp_get_affiliate( $referral->affiliate_id ) : false;\n    $user      = $affiliate ? get_userdata( $affiliate->user_id ) : false;\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        $user->user_email,\n        sprintf( '[%s] Your commission has been paid', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf(\n            \"Hi %1\\$s,\\n\\nGood news: your commission of %2\\$s for referral #%3\\$d has been paid.\\n\\nThank you for sending customers our way!\\n\",\n            $user->first_name ? $user->first_name : $user->display_name,\n            html_entity_decode( affwp_currency_filter( affwp_format_amount( $referral->amount ) ) ),\n            $referral_id\n        )\n    );\n}, 10, 3 );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Sent once per referral when its status changes to paid."
        },
        {
            "id": "affwp-menu-affiliate-link",
            "version": 1,
            "title": "Show \"Become an Affiliate\" or \"Affiliate Area\" Menu Link (AffiliateWP)",
            "description": "Adds a menu item to a theme menu location that reads \"Affiliate Area\" for affiliates and \"Become an affiliate\" for everyone else, both pointing to your AffiliateWP page.",
            "category": "integrations",
            "tags": [
                "affiliatewp",
                "menus",
                "affiliates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_location = 'primary'; // Theme menu location.\n\nadd_filter( 'wp_nav_menu_items', function ( $items, $args ) use ( $wpide_location ) {\n    if ( ! function_exists( 'affiliate_wp' ) || empty( $args->theme_location ) || $args->theme_location !== $wpide_location ) {\n        return $items;\n    }\n\n    $page_id = (int) affiliate_wp()->settings->get( 'affiliates_page' );\n\n    if ( ! $page_id ) {\n        return $items;\n    }\n\n    $is_affiliate = is_user_logged_in() && affwp_get_affiliate_id( get_current_user_id() );\n    $label        = $is_affiliate ? __( 'Affiliate Area' ) : __( 'Become an affiliate' );\n\n    return $items . sprintf( '<li class=\"menu-item menu-item-affiliate\"><a href=\"%1$s\">%2$s</a></li>', esc_url( get_permalink( $page_id ) ), esc_html( $label ) );\n}, 10, 2 );",
            "requires": [
                "affiliatewp"
            ],
            "notes": "Set $wpide_location to your theme's menu location."
        },
        {
            "id": "jetpack-related-posts-position",
            "version": 1,
            "title": "Move Jetpack Related Posts Into a Shortcode",
            "description": "Removes Jetpack's Related Posts from the bottom of posts and lets you place them anywhere with a [wpide_related] shortcode or in your theme.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "related-posts",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp', function () {\n    if ( ! class_exists( 'Jetpack_RelatedPosts' ) || ! method_exists( 'Jetpack_RelatedPosts', 'init' ) ) {\n        return;\n    }\n\n    $jprp = Jetpack_RelatedPosts::init();\n    remove_filter( 'the_content', array( $jprp, 'filter_add_target_to_dom' ), 40 );\n}, 20 );\n\nadd_shortcode( 'wpide_related', function () {\n    if ( ! class_exists( 'Jetpack_RelatedPosts' ) || ! is_singular() ) {\n        return '';\n    }\n\n    return do_shortcode( '[jetpack-related-posts]' );\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Jetpack also offers its own Related Posts block; this is for classic themes and page builders."
        },
        {
            "id": "jetpack-infinite-scroll-settings",
            "version": 1,
            "title": "Tune Jetpack Infinite Scroll",
            "description": "Sets how many posts Jetpack's Infinite Scroll loads at a time and switches it to a \"Load more\" button, which is friendlier to footers and keyboard users than endless scrolling.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "infinite-scroll",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_posts = 9;       // Posts per load.\n$wpide_type  = 'click'; // click = \"Load more\" button, scroll = automatic.\n\nadd_filter( 'infinite_scroll_settings', function ( $settings ) use ( $wpide_posts, $wpide_type ) {\n    if ( is_array( $settings ) ) {\n        $settings['posts_per_page'] = max( 1, (int) $wpide_posts );\n        $settings['type']           = 'scroll' === $wpide_type ? 'scroll' : 'click';\n    }\n\n    return $settings;\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Your theme must support Infinite Scroll. Set $wpide_type to scroll to keep automatic loading."
        },
        {
            "id": "mc4wp-sync-language-merge-field",
            "version": 1,
            "title": "Send the Visitor's Language to Mailchimp (MC4WP)",
            "description": "Sets Mailchimp's built-in subscriber language for every signup from your site, using the current WordPress, WPML or Polylang language, so Mailchimp can send the right translation of your campaigns.",
            "category": "integrations",
            "tags": [
                "mailchimp",
                "multilingual",
                "newsletter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'mc4wp_subscriber_data', function ( $subscriber ) {\n    if ( ! is_object( $subscriber ) ) {\n        return $subscriber;\n    }\n\n    $lang = strtok( determine_locale(), '_' );\n    if ( function_exists( 'pll_current_language' ) && pll_current_language() ) {\n        $lang = pll_current_language();\n    } elseif ( defined( 'ICL_LANGUAGE_CODE' ) && ICL_LANGUAGE_CODE ) {\n        $lang = ICL_LANGUAGE_CODE;\n    }\n\n    $subscriber->language = strtolower( substr( sanitize_key( $lang ), 0, 2 ) );\n\n    return $subscriber;\n} );",
            "requires": [],
            "notes": "Mailchimp expects codes like en, fr, de or pt_PT; regional variants are mapped to the two-letter code."
        },
        {
            "id": "jetpack-disable-modules",
            "version": 1,
            "title": "Permanently Switch Off Chosen Jetpack Modules",
            "description": "Removes Jetpack modules you never want used, such as Likes, Comments or Stats, from the list of available modules, so nobody can turn them on by accident.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "modules",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_disabled = array( 'likes', 'comments' ); // Module slugs to remove.\n\nadd_filter( 'jetpack_get_available_modules', function ( $modules ) use ( $wpide_disabled ) {\n    foreach ( $wpide_disabled as $slug ) {\n        unset( $modules[ $slug ] );\n    }\n\n    return $modules;\n}, 20 );",
            "requires": [
                "jetpack"
            ],
            "notes": "Use Jetpack module slugs (likes, comments, stats, sharedaddy, related-posts, infinite-scroll, photon...). Already active modules stop loading."
        },
        {
            "id": "jetpack-remove-infinite-scroll-credit",
            "version": 1,
            "title": "Replace the Jetpack Infinite Scroll Footer Credit",
            "description": "Replaces the \"Proudly powered by WordPress\" credit that Jetpack's Infinite Scroll shows in its sticky footer with your own text, or removes it.",
            "category": "integrations",
            "tags": [
                "jetpack",
                "infinite-scroll",
                "footer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_credit = '&copy; ' . wp_date( 'Y' ) . ' ' . get_bloginfo( 'name' ); // Credit HTML, or ''.\n\nadd_filter( 'infinite_scroll_credit', function () use ( $wpide_credit ) {\n    return wp_kses( $wpide_credit, array( 'a' => array( 'href' => true, 'rel' => true ) ) );\n} );",
            "requires": [
                "jetpack"
            ],
            "notes": "Leave $wpide_credit empty to show nothing. Basic links are allowed."
        },
        {
            "id": "rocket-rucss-safelist",
            "version": 1,
            "title": "Keep Selectors in WP Rocket's \"Remove Unused CSS\"",
            "description": "Adds CSS selectors and stylesheet URLs that WP Rocket's Remove Unused CSS must always keep, such as classes added by JavaScript for menus, sliders and pop-ups, so those elements do not lose their styling.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "css",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_safelist = array( '.menu-open', '.is-active', '.modal-open', '/wp-content/plugins/slider/(.*).css' ); // Selectors or CSS file patterns.\n\nadd_filter( 'rocket_rucss_safelist', function ( $safelist ) use ( $wpide_safelist ) {\n    return array_values( array_unique( array_merge( (array) $safelist, $wpide_safelist ) ) );\n} );",
            "requires": [],
            "notes": "Use selectors (.menu-open, #popup) or partial stylesheet paths (/plugins/slider/). Clear the Used CSS in WP Rocket afterwards."
        },
        {
            "id": "rocket-preload-exclusions",
            "version": 1,
            "title": "Stop WP Rocket Preloading Certain URLs",
            "description": "Keeps WP Rocket's cache preload from crawling URLs you list, such as filtered archive pages, calendars or tag pages, saving server resources on large sites.",
            "category": "integrations",
            "tags": [
                "wp-rocket",
                "preload",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_skip = array( '/tag/(.*)', '/events/list/page/(.*)', '(.*)filter_(.*)' ); // URL patterns not to preload.\n\nadd_filter( 'rocket_preload_exclude_urls', function ( $regexes ) use ( $wpide_skip ) {\n    return array_values( array_unique( array_merge( (array) $regexes, $wpide_skip ) ) );\n} );",
            "requires": [],
            "notes": "Patterns are regular expressions matched against the URL, e.g. /tag/(.*) or (.*)?filter_(.*)."
        },
        {
            "id": "cache-bypass-for-cookie",
            "version": 1,
            "title": "Skip Page Caching for Visitors With a Cookie",
            "description": "Makes page-cache plugins (WP Rocket, W3 Total Cache, WP Super Cache, LiteSpeed Cache and others that respect DONOTCACHEPAGE) skip caching for visitors who carry a cookie you choose, such as a personalisation, A/B test or affiliate cookie.",
            "category": "integrations",
            "tags": [
                "cache",
                "cookies",
                "personalisation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cookie = 'wpide_variant'; // Cookie name (prefix match).\n\nadd_filter( 'rocket_cache_reject_cookies', function ( $cookies ) use ( $wpide_cookie ) {\n    $cookies   = (array) $cookies;\n    $cookies[] = $wpide_cookie;\n\n    return array_values( array_unique( $cookies ) );\n} );\n\nadd_action( 'init', function () use ( $wpide_cookie ) {\n    if ( is_admin() || empty( $_COOKIE ) ) {\n        return;\n    }\n\n    foreach ( array_keys( $_COOKIE ) as $name ) {\n        if ( 0 === strpos( (string) $name, $wpide_cookie ) ) {\n            if ( ! defined( 'DONOTCACHEPAGE' ) ) {\n                define( 'DONOTCACHEPAGE', true );\n            }\n            if ( defined( 'LSCWP_V' ) ) {\n                do_action( 'litespeed_control_set_nocache', 'wpide cookie' );\n            }\n            break;\n        }\n    }\n}, 1 );",
            "requires": [],
            "notes": "Set $wpide_cookie. WP Rocket also gets the cookie added to its \"never cache\" list, so cached copies are not served to those visitors; re-save its settings once."
        },
        {
            "id": "elementor-stop-icon-fonts-for-visitors",
            "version": 1,
            "title": "Stop Elementor Icon Fonts Loading for Visitors",
            "description": "Removes the Font Awesome and Elementor icon font stylesheets from the front end for visitors, which saves several requests on sites that use SVG icons or no icons at all.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "performance",
                "icons",
                "fonts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Stylesheet handles to remove. Delete any you still need.\n$wpide_el_icon_handles = array(\n    'font-awesome',\n    'elementor-icons',\n    'elementor-icons-shared-0',\n    'elementor-icons-fa-solid',\n    'elementor-icons-fa-regular',\n    'elementor-icons-fa-brands',\n);\n\n$wpide_el_dequeue_icons = function () use ( $wpide_el_icon_handles ) {\n    if ( ! class_exists( '\\Elementor\\Plugin' ) || is_user_logged_in() ) {\n        return;\n    }\n\n    if ( isset( $_GET['elementor-preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    foreach ( $wpide_el_icon_handles as $handle ) {\n        wp_dequeue_style( $handle );\n    }\n};\n\n// Head styles and the styles Elementor adds late, while widgets render.\nadd_action( 'wp_print_styles', $wpide_el_dequeue_icons, 100 );\nadd_action( 'wp_print_footer_scripts', $wpide_el_dequeue_icons, 1 );",
            "requires": [
                "elementor"
            ],
            "notes": "Only use this if your pages show no icon-font icons; turn on Elementor's \"Inline Font Icons\" feature first so icons render as SVG."
        },
        {
            "id": "elementor-remove-dashboard-widget",
            "version": 1,
            "title": "Remove the Elementor Overview Dashboard Widget",
            "description": "Removes the \"Elementor Overview\" box with news and recent edits from the WordPress dashboard, so the dashboard stays focused on your own widgets.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "dashboard",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'e-dashboard-overview', 'dashboard', 'normal' );\n}, 40 );",
            "requires": [
                "elementor"
            ],
            "notes": ""
        },
        {
            "id": "elementor-admin-bar-link-for-admins",
            "version": 1,
            "title": "\"Edit with Elementor\" in the Admin Bar for Administrators Only",
            "description": "Hides the \"Edit with Elementor\" menu in the admin bar from everyone except administrators, so editors and shop managers are not tempted to change designed layouts.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "admin-bar",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Who keeps the admin bar link.\n$wpide_el_bar_capability = 'manage_options';\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_el_bar_capability ) {\n    if ( current_user_can( $wpide_el_bar_capability ) ) {\n        return;\n    }\n\n    $wp_admin_bar->remove_node( 'elementor_edit_page' );\n}, 999 );",
            "requires": [
                "elementor"
            ],
            "notes": "Change manage_options to another capability to keep the link for more roles."
        },
        {
            "id": "elementor-row-action-for-admins",
            "version": 1,
            "title": "Hide \"Edit with Elementor\" Links From Non-Administrators",
            "description": "Removes the \"Edit with Elementor\" links under post titles in admin lists and the \"Edit with Elementor\" button in the editor for users who are not administrators. They can still edit text in the regular editor.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "admin",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Users with this capability keep the link.\n$wpide_el_row_capability = 'manage_options';\n\n$wpide_el_row_links = function ( $actions ) use ( $wpide_el_row_capability ) {\n    if ( ! current_user_can( $wpide_el_row_capability ) ) {\n        unset( $actions['edit_with_elementor'] );\n    }\n\n    return $actions;\n};\n\nadd_filter( 'post_row_actions', $wpide_el_row_links, 20 );\nadd_filter( 'page_row_actions', $wpide_el_row_links, 20 );\n\n// Also hide the \"Edit with Elementor\" button in the block and classic editors.\nadd_action( 'admin_head', function () use ( $wpide_el_row_capability ) {\n    if ( ! current_user_can( $wpide_el_row_capability ) ) {\n        echo '<style>#elementor-switch-mode,#elementor-editor,#elementor-go-to-edit-page-link{display:none!important;}</style>';\n    }\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Pair it with \"Block Roles From the Elementor Editor\" to stop access completely, not just hide the link."
        },
        {
            "id": "elementor-block-roles-from-editor",
            "version": 1,
            "title": "Block Roles From the Elementor Editor",
            "description": "Stops the roles you list from opening the Elementor editor, using Elementor's own role exclusion list, while they keep normal access to the WordPress editor.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "roles",
                "editor",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that may not use the Elementor editor.\n$wpide_el_blocked_roles = array( 'author', 'contributor' );\n\n$wpide_el_exclude_roles = function ( $value ) use ( $wpide_el_blocked_roles ) {\n    $roles = is_array( $value ) ? $value : array();\n    $roles = array_merge( $roles, $wpide_el_blocked_roles );\n\n    return array_values( array_unique( array_diff( $roles, array( 'administrator' ) ) ) );\n};\n\nadd_filter( 'option_elementor_exclude_user_roles', $wpide_el_exclude_roles );\nadd_filter( 'default_option_elementor_exclude_user_roles', $wpide_el_exclude_roles );",
            "requires": [
                "elementor"
            ],
            "notes": "Role slugs, not names: author, contributor, editor, shop_manager. Administrators can never be excluded."
        },
        {
            "id": "elementor-limit-post-types",
            "version": 1,
            "title": "Use Elementor Only on Pages",
            "description": "Limits Elementor to the post types you choose (pages by default), so the \"Edit with Elementor\" button disappears from blog posts and other content types.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "post-types",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that may be edited with Elementor.\n$wpide_el_post_types = array( 'page' );\n\n$wpide_el_cpt_support = function () use ( $wpide_el_post_types ) {\n    return $wpide_el_post_types;\n};\n\nadd_filter( 'option_elementor_cpt_support', $wpide_el_cpt_support );\nadd_filter( 'default_option_elementor_cpt_support', $wpide_el_cpt_support );",
            "requires": [
                "elementor"
            ],
            "notes": "Posts already built with Elementor keep their design; add post to the list if you still need to edit them."
        },
        {
            "id": "elementor-opt-out-usage-tracking",
            "version": 1,
            "title": "Opt Out of Elementor Usage Data Sharing",
            "description": "Keeps Elementor's usage data sharing turned off and marks its \"Become a super contributor\" notice as answered, so it no longer asks on new installs.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "privacy",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_elementor_allow_tracking', function () {\n    return 'no';\n} );\n\nadd_filter( 'pre_option_elementor_tracker_notice', function () {\n    return '1';\n} );",
            "requires": [
                "elementor"
            ],
            "notes": ""
        },
        {
            "id": "elementor-hide-pro-upsells",
            "version": 1,
            "title": "Hide Elementor Pro Upsells in wp-admin and the Editor",
            "description": "Hides the \"Upgrade\" and \"Get Pro\" menu links in wp-admin and the locked Pro widgets in the Elementor panel, for a cleaner interface on sites that use the free version.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "upsell",
                "admin",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_head', function () {\n    echo '<style id=\"wpide-el-upsells\">#adminmenu a[href*=\"go_elementor_pro\"],#adminmenu a[href*=\"elementor-one-upgrade\"],#adminmenu a[href*=\"go_knowledge_base_site\"]{display:none!important;}</style>' . \"\\n\";\n} );\n\nadd_action( 'elementor/editor/after_enqueue_styles', function () {\n    wp_add_inline_style( 'elementor-editor', '.elementor-element-wrapper.elementor-element--promotion,#elementor-panel-get-pro-elements,#elementor-panel-get-pro-elements-sticky,.elementor-panel-heading-promotion{display:none!important;}' );\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "This only hides the links with CSS; nothing in Elementor is changed."
        },
        {
            "id": "elementor-notice-box-widget",
            "version": 1,
            "title": "Custom Elementor Widget: Notice Box",
            "description": "Adds a simple \"Notice Box\" widget to Elementor with a title, text and four styles (info, success, warning, error), in its own \"Site Widgets\" panel category.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "widget",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/elements/categories_registered', function ( $elements_manager ) {\n    $elements_manager->add_category( 'wpide-site', array(\n        'title' => 'Site Widgets',\n        'icon'  => 'eicon-plug',\n    ) );\n} );\n\nadd_action( 'elementor/widgets/register', function ( $widgets_manager ) {\n    if ( ! class_exists( '\\Elementor\\Widget_Base' ) ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Elementor_Notice_Widget' ) ) {\n        class WPIDE_Elementor_Notice_Widget extends \\Elementor\\Widget_Base {\n            public function get_name() {\n                return 'wpide-notice';\n            }\n\n            public function get_title() {\n                return 'Notice Box';\n            }\n\n            public function get_icon() {\n                return 'eicon-alert';\n            }\n\n            public function get_categories() {\n                return array( 'wpide-site' );\n            }\n\n            protected function register_controls() {\n                $this->start_controls_section( 'content', array( 'label' => 'Notice' ) );\n                $this->add_control( 'type', array(\n                    'label'   => 'Style',\n                    'type'    => \\Elementor\\Controls_Manager::SELECT,\n                    'default' => 'info',\n                    'options' => array( 'info' => 'Info', 'success' => 'Success', 'warning' => 'Warning', 'error' => 'Error' ),\n                ) );\n                $this->add_control( 'title', array( 'label' => 'Title', 'type' => \\Elementor\\Controls_Manager::TEXT, 'default' => 'Good to know' ) );\n                $this->add_control( 'text', array( 'label' => 'Text', 'type' => \\Elementor\\Controls_Manager::TEXTAREA, 'default' => 'Write your message here.' ) );\n                $this->end_controls_section();\n            }\n\n            protected function render() {\n                $s      = $this->get_settings_for_display();\n                $colors = array( 'info' => '#2271b1', 'success' => '#00a32a', 'warning' => '#dba617', 'error' => '#d63638' );\n                $color  = isset( $colors[ $s['type'] ] ) ? $colors[ $s['type'] ] : $colors['info'];\n\n                printf(\n                    '<div class=\"wpide-notice wpide-notice--%1$s\" role=\"note\" style=\"border-left:4px solid %2$s;background:color-mix(in srgb,%2$s 8%%,transparent);padding:1em 1.25em;border-radius:4px;\"><strong style=\"display:block;margin-bottom:.25em;\">%3$s</strong>%4$s</div>',\n                    esc_attr( $s['type'] ),\n                    esc_attr( $color ),\n                    esc_html( $s['title'] ),\n                    wp_kses_post( wpautop( $s['text'] ) )\n                );\n            }\n        }\n    }\n\n    $widgets_manager->register( new WPIDE_Elementor_Notice_Widget() );\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Use this as a starting point for your own widgets: add controls in register_controls() and print them in render()."
        },
        {
            "id": "elementor-unregister-unused-widgets",
            "version": 1,
            "title": "Remove Unused Widgets From the Elementor Panel",
            "description": "Takes widgets you never use, such as the old WordPress sidebar widgets, out of the Elementor panel so editors find the right ones faster.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "widgets",
                "editor",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Widget names to remove.\n$wpide_el_unused_widgets = array(\n    'wp-widget-pages',\n    'wp-widget-calendar',\n    'wp-widget-archives',\n    'wp-widget-meta',\n    'wp-widget-rss',\n    'wp-widget-tag_cloud',\n    'wp-widget-recent-comments',\n    'read-more',\n);\n\nadd_action( 'elementor/widgets/register', function ( $widgets_manager ) use ( $wpide_el_unused_widgets ) {\n    foreach ( $wpide_el_unused_widgets as $name ) {\n        if ( method_exists( $widgets_manager, 'unregister' ) ) {\n            $widgets_manager->unregister( $name );\n        }\n    }\n}, 99 );",
            "requires": [
                "elementor"
            ],
            "notes": "Widget names appear in the panel's HTML as data-widget_type, for example \"wp-widget-calendar\". Existing uses of a removed widget stop rendering."
        },
        {
            "id": "elementor-dynamic-tag-current-year",
            "version": 1,
            "title": "Elementor Dynamic Tag: Current Year",
            "description": "Adds a \"Current Year\" dynamic tag to Elementor Pro so copyright lines and headings update themselves every January.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "dynamic-tags",
                "date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/dynamic_tags/register', function ( $dynamic_tags ) {\n    if ( ! class_exists( '\\Elementor\\Core\\DynamicTags\\Tag' ) || ! class_exists( '\\Elementor\\Modules\\DynamicTags\\Module' ) ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Elementor_Year_Tag' ) ) {\n        class WPIDE_Elementor_Year_Tag extends \\Elementor\\Core\\DynamicTags\\Tag {\n            public function get_name() {\n                return 'wpide-current-year';\n            }\n\n            public function get_title() {\n                return 'Current Year';\n            }\n\n            public function get_group() {\n                return 'wpide-site';\n            }\n\n            public function get_categories() {\n                return array( \\Elementor\\Modules\\DynamicTags\\Module::TEXT_CATEGORY );\n            }\n\n            public function render() {\n                echo esc_html( wp_date( 'Y' ) );\n            }\n        }\n    }\n\n    $dynamic_tags->register_group( 'wpide-site', array( 'title' => 'Site Details' ) );\n    $dynamic_tags->register( new WPIDE_Elementor_Year_Tag() );\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Dynamic tags need Elementor Pro. Find the tag under \"Site Details\" in the dynamic tags list."
        },
        {
            "id": "elementor-dynamic-tag-visitor-name",
            "version": 1,
            "title": "Elementor Dynamic Tag: Visitor's First Name",
            "description": "Adds a dynamic tag that shows the logged-in visitor's first name (or a fallback word like \"there\"), for greetings such as \"Welcome back, Anna\".",
            "category": "page-builders",
            "tags": [
                "elementor",
                "dynamic-tags",
                "users",
                "personalization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/dynamic_tags/register', function ( $dynamic_tags ) {\n    if ( ! class_exists( '\\Elementor\\Core\\DynamicTags\\Tag' ) || ! class_exists( '\\Elementor\\Modules\\DynamicTags\\Module' ) ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Elementor_First_Name_Tag' ) ) {\n        class WPIDE_Elementor_First_Name_Tag extends \\Elementor\\Core\\DynamicTags\\Tag {\n            public function get_name() {\n                return 'wpide-first-name';\n            }\n\n            public function get_title() {\n                return 'Visitor First Name';\n            }\n\n            public function get_group() {\n                return 'wpide-visitor';\n            }\n\n            public function get_categories() {\n                return array( \\Elementor\\Modules\\DynamicTags\\Module::TEXT_CATEGORY );\n            }\n\n            protected function register_controls() {\n                $this->add_control( 'fallback_name', array(\n                    'label'   => 'Fallback for guests',\n                    'type'    => \\Elementor\\Controls_Manager::TEXT,\n                    'default' => 'there',\n                ) );\n            }\n\n            public function render() {\n                $user = wp_get_current_user();\n                $name = $user->exists() ? ( $user->first_name ? $user->first_name : $user->display_name ) : $this->get_settings( 'fallback_name' );\n\n                echo esc_html( $name );\n            }\n        }\n    }\n\n    $dynamic_tags->register_group( 'wpide-visitor', array( 'title' => 'Visitor' ) );\n    $dynamic_tags->register( new WPIDE_Elementor_First_Name_Tag() );\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Needs Elementor Pro. Pages using it should not be served from a full-page cache to logged-in users."
        },
        {
            "id": "elementor-dynamic-tag-url-parameter",
            "version": 1,
            "title": "Elementor Dynamic Tag: URL Parameter",
            "description": "Adds a dynamic tag that prints a value from the page address, such as ?city=Paris, so landing pages can echo campaign keywords in headings.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "dynamic-tags",
                "landing-pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/dynamic_tags/register', function ( $dynamic_tags ) {\n    if ( ! class_exists( '\\Elementor\\Core\\DynamicTags\\Tag' ) || ! class_exists( '\\Elementor\\Modules\\DynamicTags\\Module' ) ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Elementor_Url_Param_Tag' ) ) {\n        class WPIDE_Elementor_Url_Param_Tag extends \\Elementor\\Core\\DynamicTags\\Tag {\n            public function get_name() {\n                return 'wpide-url-param';\n            }\n\n            public function get_title() {\n                return 'URL Parameter';\n            }\n\n            public function get_group() {\n                return 'wpide-visitor';\n            }\n\n            public function get_categories() {\n                return array( \\Elementor\\Modules\\DynamicTags\\Module::TEXT_CATEGORY );\n            }\n\n            protected function register_controls() {\n                $this->add_control( 'param', array( 'label' => 'Parameter name', 'type' => \\Elementor\\Controls_Manager::TEXT, 'default' => 'city' ) );\n                $this->add_control( 'default_value', array( 'label' => 'When missing', 'type' => \\Elementor\\Controls_Manager::TEXT, 'default' => '' ) );\n            }\n\n            public function render() {\n                $param = sanitize_key( (string) $this->get_settings( 'param' ) );\n                $value = ( $param && isset( $_GET[ $param ] ) ) ? sanitize_text_field( wp_unslash( $_GET[ $param ] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n\n                if ( '' === $value ) {\n                    $value = (string) $this->get_settings( 'default_value' );\n                }\n\n                echo esc_html( mb_substr( $value, 0, 60 ) );\n            }\n        }\n    }\n\n    $dynamic_tags->register_group( 'wpide-visitor', array( 'title' => 'Visitor' ) );\n    $dynamic_tags->register( new WPIDE_Elementor_Url_Param_Tag() );\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Needs Elementor Pro. The value is limited to 60 plain-text characters and escaped before it is shown."
        },
        {
            "id": "elementor-query-exclude-current-post",
            "version": 1,
            "title": "Elementor Posts Query: Exclude the Current Post",
            "description": "Adds a custom query ID \"wpide_exclude_current\" for Elementor Pro's Posts and Loop Grid widgets that leaves out the post being viewed, ideal for \"More articles\" sections.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "posts",
                "related"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_exclude_current', function ( $query ) {\n    $current = get_queried_object_id();\n\n    if ( ! $current || ! is_singular() ) {\n        return;\n    }\n\n    $exclude = (array) $query->get( 'post__not_in' );\n    $exclude[] = $current;\n    $query->set( 'post__not_in', array_map( 'absint', $exclude ) );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "In the widget's Query section, type wpide_exclude_current in the Query ID field."
        },
        {
            "id": "elementor-query-related-by-category",
            "version": 1,
            "title": "Elementor Posts Query: Related Posts From the Same Category",
            "description": "Adds a custom query ID \"wpide_related\" that shows posts sharing a category with the current post, newest first, without the current post itself.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "related",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_related', function ( $query ) {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $post_id = get_queried_object_id();\n    $terms   = wp_get_post_terms( $post_id, 'category', array( 'fields' => 'ids' ) );\n\n    if ( is_wp_error( $terms ) || empty( $terms ) ) {\n        return;\n    }\n\n    $query->set( 'category__in', $terms );\n    $query->set( 'post__not_in', array( $post_id ) );\n    $query->set( 'ignore_sticky_posts', true );\n    $query->set( 'no_found_rows', true );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Type wpide_related in the Query ID field of a Posts or Loop Grid widget in your single post template."
        },
        {
            "id": "elementor-query-upcoming-by-date-field",
            "version": 1,
            "title": "Elementor Posts Query: Upcoming Items by a Date Field",
            "description": "Adds a custom query ID \"wpide_upcoming\" that shows only items whose date field (for example an event date) is today or later, soonest first.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "events",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Custom field that holds the date, stored as Ymd.\n$wpide_el_date_field = 'event_date';\n\nadd_action( 'elementor/query/wpide_upcoming', function ( $query ) use ( $wpide_el_date_field ) {\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array(\n        'key'     => $wpide_el_date_field,\n        'value'   => wp_date( 'Ymd' ),\n        'compare' => '>=',\n        'type'    => 'NUMERIC',\n    );\n\n    $query->set( 'meta_query', $meta_query );\n    $query->set( 'meta_key', $wpide_el_date_field );\n    $query->set( 'orderby', 'meta_value_num' );\n    $query->set( 'order', 'ASC' );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "The field must store dates as Ymd (20261231), the ACF date picker default. Change the meta key to your field name."
        },
        {
            "id": "elementor-query-most-commented",
            "version": 1,
            "title": "Elementor Posts Query: Most Discussed Posts",
            "description": "Adds a custom query ID \"wpide_popular\" that orders posts by number of comments, to build a \"Most discussed\" block with Elementor Pro.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "popular",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_popular', function ( $query ) {\n    $query->set( 'orderby', array( 'comment_count' => 'DESC', 'date' => 'DESC' ) );\n    $query->set( 'ignore_sticky_posts', true );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Type wpide_popular in the Query ID field. Set the number of posts in the widget as usual."
        },
        {
            "id": "elementor-query-same-author",
            "version": 1,
            "title": "Elementor Posts Query: More Posts by the Same Author",
            "description": "Adds a custom query ID \"wpide_same_author\" that lists other posts by the author of the post being viewed, for \"More from this writer\" sections.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_same_author', function ( $query ) {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post ) {\n        return;\n    }\n\n    $query->set( 'author', (int) $post->post_author );\n    $query->set( 'post__not_in', array( $post->ID ) );\n    $query->set( 'ignore_sticky_posts', true );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Type wpide_same_author in the Query ID field of a widget in your single post template."
        },
        {
            "id": "elementor-form-block-free-email",
            "version": 1,
            "title": "Elementor Forms: Require a Business Email Address",
            "description": "Rejects Elementor Pro form submissions that use free email providers such as Gmail or Yahoo, with a clear message under the field, for B2B lead forms.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "forms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Free email domains to refuse.\n$wpide_el_free_domains = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'mail.com', 'proton.me', 'protonmail.com' );\n$wpide_el_free_message = 'Please use your work email address.';\n\nadd_action( 'elementor_pro/forms/validation/email', function ( $field, $record, $ajax_handler ) use ( $wpide_el_free_domains, $wpide_el_free_message ) {\n    $email = isset( $field['value'] ) ? strtolower( trim( (string) $field['value'] ) ) : '';\n\n    if ( '' === $email || false === strpos( $email, '@' ) ) {\n        return;\n    }\n\n    $domain = substr( strrchr( $email, '@' ), 1 );\n\n    if ( in_array( $domain, $wpide_el_free_domains, true ) ) {\n        $ajax_handler->add_error( $field['id'], $wpide_el_free_message );\n    }\n}, 10, 3 );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Applies to every Email field in every Elementor form. Edit the domain list to suit your audience."
        },
        {
            "id": "elementor-form-block-links",
            "version": 1,
            "title": "Elementor Forms: Reject Messages Containing Links",
            "description": "Refuses Elementor Pro form submissions whose text areas contain web links, which stops most contact-form spam without a captcha.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "forms",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How many links a message may contain.\n$wpide_el_max_links    = 0;\n$wpide_el_link_message = 'Please remove links from your message.';\n\nadd_action( 'elementor_pro/forms/validation', function ( $record, $ajax_handler ) use ( $wpide_el_max_links, $wpide_el_link_message ) {\n    $fields = $record->get( 'fields' );\n\n    foreach ( (array) $fields as $id => $field ) {\n        if ( ! isset( $field['type'], $field['value'] ) || 'textarea' !== $field['type'] ) {\n            continue;\n        }\n\n        $links = preg_match_all( '#(https?://|www\\.)\\S+#i', (string) $field['value'] );\n\n        if ( $links > $wpide_el_max_links ) {\n            $ajax_handler->add_error( $id, $wpide_el_link_message );\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Raise $wpide_el_max_links to 1 if customers often need to paste a single link."
        },
        {
            "id": "elementor-form-submit-datalayer",
            "version": 1,
            "title": "Elementor Forms: Send Submissions to the dataLayer",
            "description": "Pushes an \"elementor_form_submit\" event with the form name to the Google Tag Manager dataLayer each time an Elementor Pro form is sent successfully, for conversion tracking.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "forms",
                "analytics",
                "gtm"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\nwindow.addEventListener('DOMContentLoaded', function () {\n    if (!window.jQuery) { return; }\n    window.jQuery(document).on('submit_success', function (event) {\n        var form = event.target;\n        window.dataLayer = window.dataLayer || [];\n        window.dataLayer.push({\n            event: 'elementor_form_submit',\n            form_name: form && form.getAttribute ? (form.getAttribute('name') || '') : '',\n            form_id: form && form.querySelector ? ((form.querySelector('input[name=\"form_id\"]') || {}).value || '') : ''\n        });\n    });\n});\n</script>",
            "requires": [
                "elementor"
            ],
            "notes": "In Tag Manager, create a Custom Event trigger named elementor_form_submit; the form_name variable holds the form's name."
        },
        {
            "id": "elementor-popup-close-after-submit",
            "version": 1,
            "title": "Close an Elementor Popup After Its Form Is Sent",
            "description": "Closes the Elementor Pro popup a few seconds after the form inside it is submitted successfully, so visitors see the thank-you message and then return to the page.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "popups",
                "forms"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\nwindow.addEventListener('DOMContentLoaded', function () {\n    var delay = 2500; // Time to show the success message first.\n    if (!window.jQuery) { return; }\n    window.jQuery(document).on('submit_success', function (event) {\n        var form = event.target;\n        var modal = form && form.closest ? form.closest('.elementor-popup-modal') : null;\n        if (!modal) { return; }\n        setTimeout(function () {\n            var close = modal.querySelector('.dialog-close-button');\n            if (close) { close.click(); }\n        }, delay);\n    });\n});\n</script>",
            "requires": [
                "elementor"
            ],
            "notes": "Change the delay (in milliseconds) at the top of the script."
        },
        {
            "id": "elementor-popup-from-url-hash",
            "version": 1,
            "title": "Open an Elementor Popup From a Link or URL Hash",
            "description": "Opens an Elementor Pro popup when the address ends in #popup-123 (123 being the popup ID), so you can link to a popup from emails, menus or other pages.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "popups",
                "links"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function openFromHash() {\n        var match = /^#popup-(\\d+)$/.exec(window.location.hash);\n        var pro = window.elementorProFrontend;\n        if (!match || !pro || !pro.modules || !pro.modules.popup) { return; }\n        pro.modules.popup.showPopup({ id: parseInt(match[1], 10) });\n    }\n    window.addEventListener('load', openFromHash);\n    window.addEventListener('hashchange', openFromHash);\n})();\n</script>",
            "requires": [
                "elementor"
            ],
            "notes": "Use the popup's ID from Templates > Popups. Set the popup's display conditions to include the pages where links point."
        },
        {
            "id": "elementor-template-shortcode",
            "version": 1,
            "title": "Elementor Template Shortcode for the Free Version",
            "description": "Adds a [wpide_elementor_template id=\"123\"] shortcode that shows any saved Elementor template inside posts, widgets or other builders, without Elementor Pro.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "templates",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_elementor_template', function ( $atts ) {\n    static $depth = 0;\n\n    $atts = shortcode_atts( array( 'id' => 0 ), $atts, 'wpide_elementor_template' );\n    $id   = absint( $atts['id'] );\n\n    if ( ! $id || $depth > 2 || ! class_exists( '\\Elementor\\Plugin' ) ) {\n        return '';\n    }\n\n    if ( 'elementor_library' !== get_post_type( $id ) || 'publish' !== get_post_status( $id ) ) {\n        return '';\n    }\n\n    $depth++;\n    $html = \\Elementor\\Plugin::instance()->frontend->get_builder_content_for_display( $id, true );\n    $depth--;\n\n    return $html;\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Find template IDs under Templates > Saved Templates. Only published templates are shown."
        },
        {
            "id": "elementor-library-private",
            "version": 1,
            "title": "Keep Elementor Templates Out of Search Engines",
            "description": "Sends visitors who open a saved Elementor template's own address back to the homepage and marks those pages noindex, so loose template URLs never show up in Google.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "seo",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_singular( 'elementor_library' ) || current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    if ( isset( $_GET['elementor-preview'] ) || isset( $_GET['preview'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    wp_safe_redirect( home_url( '/' ), 301 );\n    exit;\n} );\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular( 'elementor_library' ) ) {\n        $robots['noindex']  = true;\n        $robots['nofollow'] = true;\n    }\n\n    return $robots;\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Logged-in editors and Elementor previews are not redirected."
        },
        {
            "id": "elementor-built-with-column",
            "version": 1,
            "title": "\"Elementor\" Column in the Pages List",
            "description": "Adds a column to the Pages screen that shows which pages are built with Elementor, so you know before you click whether to open Elementor or the block editor.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the column.\n$wpide_el_column_types = array( 'page' );\n\nadd_action( 'admin_init', function () use ( $wpide_el_column_types ) {\n    foreach ( $wpide_el_column_types as $type ) {\n        add_filter( \"manage_{$type}_posts_columns\", function ( $columns ) {\n            $columns['wpide_elementor'] = 'Elementor';\n            return $columns;\n        } );\n\n        add_action( \"manage_{$type}_posts_custom_column\", function ( $column, $post_id ) {\n            if ( 'wpide_elementor' !== $column ) {\n                return;\n            }\n\n            if ( 'builder' === get_post_meta( $post_id, '_elementor_edit_mode', true ) ) {\n                echo '<span class=\"dashicons dashicons-yes\" aria-hidden=\"true\"></span><span class=\"screen-reader-text\">Built with Elementor</span>';\n            } else {\n                echo '<span aria-hidden=\"true\">&mdash;</span>';\n            }\n        }, 10, 2 );\n    }\n} );\n\nadd_action( 'admin_head', function () {\n    echo '<style>.column-wpide_elementor{width:90px;text-align:center!important;}</style>';\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Add post to $wpide_el_column_types to show the column on the Posts screen too."
        },
        {
            "id": "elementor-visibility-login-classes",
            "version": 1,
            "title": "Show Elementor Sections Only to Logged-In or Logged-Out Visitors",
            "description": "Hides any Elementor container, section or widget with the CSS class \"wpide-logged-in\" from guests, and anything with \"wpide-logged-out\" from members, without Elementor Pro.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "visibility",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_el_login_visibility = function ( $should_render, $element ) {\n    if ( ! $should_render || ! is_object( $element ) || ! method_exists( $element, 'get_settings' ) ) {\n        return $should_render;\n    }\n\n    if ( class_exists( '\\Elementor\\Plugin' ) && \\Elementor\\Plugin::$instance->editor && \\Elementor\\Plugin::$instance->editor->is_edit_mode() ) {\n        return $should_render;\n    }\n\n    $classes = ' ' . $element->get_settings( '_css_classes' ) . ' ' . $element->get_settings( 'css_classes' ) . ' ';\n\n    if ( false !== strpos( $classes, ' wpide-logged-in ' ) && ! is_user_logged_in() ) {\n        return false;\n    }\n\n    if ( false !== strpos( $classes, ' wpide-logged-out ' ) && is_user_logged_in() ) {\n        return false;\n    }\n\n    return $should_render;\n};\n\nforeach ( array( 'widget', 'section', 'column', 'container' ) as $wpide_el_type ) {\n    add_filter( \"elementor/frontend/{$wpide_el_type}/should_render\", $wpide_el_login_visibility, 10, 2 );\n}",
            "requires": [
                "elementor"
            ],
            "notes": "Add the class under Advanced > CSS Classes. Elements stay visible in the editor so you can still edit them."
        },
        {
            "id": "elementor-register-theme-locations",
            "version": 1,
            "title": "Register Elementor Pro Theme Locations",
            "description": "Registers Elementor Pro's header, footer, single and archive locations for themes that do not do it themselves, so Theme Builder templates can replace those areas.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "theme-builder",
                "themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/theme/register_locations', function ( $theme_manager ) {\n    if ( method_exists( $theme_manager, 'register_all_core_location' ) ) {\n        $theme_manager->register_all_core_location();\n    }\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Needs Elementor Pro. Hello Elementor, Astra, GeneratePress and most popular themes already register these."
        },
        {
            "id": "elementor-add-font-names",
            "version": 1,
            "title": "Add Your Own Font Names to Elementor's Font List",
            "description": "Adds self-hosted or theme-loaded fonts to the typography font dropdown in Elementor, so editors can pick them without typing names.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "fonts",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Font family names as they appear in your CSS.\n$wpide_el_font_names = array( 'Inter', 'Source Serif 4', 'JetBrains Mono' );\n\nadd_filter( 'elementor/fonts/additional_fonts', function ( $fonts ) use ( $wpide_el_font_names ) {\n    foreach ( $wpide_el_font_names as $name ) {\n        $fonts[ $name ] = 'system';\n    }\n\n    return $fonts;\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "This only adds the names; load the font files yourself (theme, Elementor Pro custom fonts or @font-face)."
        },
        {
            "id": "elementor-hide-google-fonts-list",
            "version": 1,
            "title": "Remove Google Fonts From Elementor's Font Picker",
            "description": "Takes the Google Fonts and Early Access groups out of Elementor's font dropdown, so designers stick to system and self-hosted fonts.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "fonts",
                "privacy",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'elementor/fonts/groups', function ( $groups ) {\n    unset( $groups['googlefonts'], $groups['earlyaccess'] );\n\n    return $groups;\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Fonts already chosen in existing designs keep loading; pair it with \"Stop Elementor From Loading Google Fonts\"."
        },
        {
            "id": "elementor-templates-menu-admins-only",
            "version": 1,
            "title": "Hide Elementor Templates From Non-Administrators",
            "description": "Removes the Templates menu Elementor adds to wp-admin for everyone except administrators and blocks direct access to the list, protecting headers, footers and popups.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "templates",
                "roles",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Capability required to manage templates.\n$wpide_el_templates_capability = 'manage_options';\n\nadd_action( 'admin_menu', function () use ( $wpide_el_templates_capability ) {\n    if ( ! current_user_can( $wpide_el_templates_capability ) ) {\n        remove_menu_page( 'edit.php?post_type=elementor_library' );\n    }\n}, 999 );\n\nadd_action( 'load-edit.php', function () use ( $wpide_el_templates_capability ) {\n    if ( isset( $_GET['post_type'] ) && 'elementor_library' === $_GET['post_type'] && ! current_user_can( $wpide_el_templates_capability ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        wp_die( esc_html__( 'Sorry, you are not allowed to access this page.' ), 403 );\n    }\n} );",
            "requires": [
                "elementor"
            ],
            "notes": ""
        },
        {
            "id": "elementor-theme-condition-logged-in",
            "version": 1,
            "title": "Elementor Theme Builder Condition: Logged-In Visitors",
            "description": "Adds a \"Logged-in visitors\" display condition under Entire Site in Elementor Pro's Theme Builder, so you can show a different header or footer to members.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "theme-builder",
                "conditions",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/theme/register_conditions', function ( $conditions_manager ) {\n    if ( ! class_exists( '\\ElementorPro\\Modules\\ThemeBuilder\\Conditions\\Condition_Base' ) ) {\n        return;\n    }\n\n    if ( ! class_exists( 'WPIDE_Elementor_Logged_In_Condition' ) ) {\n        class WPIDE_Elementor_Logged_In_Condition extends \\ElementorPro\\Modules\\ThemeBuilder\\Conditions\\Condition_Base {\n            public static function get_type() {\n                return 'general';\n            }\n\n            public function get_name() {\n                return 'wpide_logged_in';\n            }\n\n            public function get_label() {\n                return 'Logged-in visitors';\n            }\n\n            public function check( $args ) {\n                return is_user_logged_in();\n            }\n        }\n    }\n\n    $general = $conditions_manager->get_condition( 'general' );\n\n    if ( $general ) {\n        $general->register_sub_condition( new WPIDE_Elementor_Logged_In_Condition() );\n    }\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Needs Elementor Pro. Re-save the template's conditions after enabling this snippet."
        },
        {
            "id": "elementor-no-block-css-on-elementor-pages",
            "version": 1,
            "title": "Skip Block Editor Styles on Elementor Pages",
            "description": "Stops the block library stylesheet loading on pages built entirely with Elementor, since those pages contain no blocks, which removes one render-blocking file.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "performance",
                "css",
                "gutenberg"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    if ( 'builder' !== get_post_meta( get_queried_object_id(), '_elementor_edit_mode', true ) ) {\n        return;\n    }\n\n    wp_dequeue_style( 'wp-block-library' );\n    wp_dequeue_style( 'wp-block-library-theme' );\n    wp_dequeue_style( 'classic-theme-styles' );\n}, 100 );",
            "requires": [
                "elementor"
            ],
            "notes": "Leave it off if your Elementor header or footer templates contain WordPress blocks."
        },
        {
            "id": "divi-builder-custom-post-types",
            "version": 1,
            "title": "Enable the Divi Builder for Custom Post Types",
            "description": "Makes the Divi Builder available on the custom post types you list, such as portfolios, case studies or courses, even when they are not ticked in Divi's settings.",
            "category": "page-builders",
            "tags": [
                "divi",
                "post-types",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that should get the Divi Builder.\n$wpide_divi_post_types = array( 'portfolio', 'case_study' );\n\nadd_filter( 'et_builder_post_types', function ( $post_types ) use ( $wpide_divi_post_types ) {\n    foreach ( $wpide_divi_post_types as $type ) {\n        if ( post_type_exists( $type ) && ! in_array( $type, $post_types, true ) ) {\n            $post_types[] = $type;\n        }\n    }\n\n    return $post_types;\n} );",
            "requires": [
                "divi"
            ],
            "notes": "Use post type slugs. The post type must support the editor."
        },
        {
            "id": "divi-disable-google-fonts",
            "version": 1,
            "title": "Stop Divi From Loading Google Fonts",
            "description": "Turns off Divi's Google Fonts option and removes the default Open Sans stylesheet, so no font requests go to Google from your Divi site.",
            "category": "page-builders",
            "tags": [
                "divi",
                "fonts",
                "privacy",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'option_et_divi', function ( $options ) {\n    if ( is_array( $options ) ) {\n        $options['et_use_google_fonts'] = 'off';\n    }\n\n    return $options;\n} );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    wp_dequeue_style( 'divi-fonts' );\n}, 20 );",
            "requires": [
                "divi"
            ],
            "notes": "Text falls back to your theme's other fonts; upload your own fonts in Divi if you need a specific typeface."
        },
        {
            "id": "divi-rename-projects",
            "version": 1,
            "title": "Rename Divi \"Projects\" to \"Portfolio\"",
            "description": "Renames Divi's Projects post type to Portfolio everywhere in wp-admin and changes its address from /project/ to /portfolio/.",
            "category": "page-builders",
            "tags": [
                "divi",
                "projects",
                "portfolio",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Singular and plural names, and the URL slug.\n$wpide_divi_project_singular = 'Portfolio Item';\n$wpide_divi_project_plural   = 'Portfolio';\n$wpide_divi_project_slug     = 'portfolio';\n\nadd_filter( 'et_project_posttype_args', function ( $args ) use ( $wpide_divi_project_singular, $wpide_divi_project_plural, $wpide_divi_project_slug ) {\n    $args['labels'] = array_merge( isset( $args['labels'] ) ? (array) $args['labels'] : array(), array(\n        'name'          => $wpide_divi_project_plural,\n        'singular_name' => $wpide_divi_project_singular,\n        'menu_name'     => $wpide_divi_project_plural,\n        'all_items'     => 'All ' . $wpide_divi_project_plural,\n        'add_new_item'  => 'Add New ' . $wpide_divi_project_singular,\n        'edit_item'     => 'Edit ' . $wpide_divi_project_singular,\n        'view_item'     => 'View ' . $wpide_divi_project_singular,\n        'search_items'  => 'Search ' . $wpide_divi_project_plural,\n    ) );\n    $args['rewrite'] = array( 'slug' => $wpide_divi_project_slug, 'with_front' => false );\n\n    return $args;\n} );",
            "requires": [
                "divi"
            ],
            "notes": "Visit Settings > Permalinks and click Save once after enabling this so the new addresses work."
        },
        {
            "id": "divi-hide-projects",
            "version": 1,
            "title": "Hide Divi's Projects Post Type",
            "description": "Hides the Projects post type that Divi adds, from the admin menu, menus, search and the front end, for sites that never use it.",
            "category": "page-builders",
            "tags": [
                "divi",
                "projects",
                "cleanup",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'et_project_posttype_args', function ( $args ) {\n    $args['public']              = false;\n    $args['publicly_queryable']  = false;\n    $args['show_ui']             = false;\n    $args['show_in_menu']        = false;\n    $args['show_in_nav_menus']   = false;\n    $args['show_in_admin_bar']   = false;\n    $args['exclude_from_search'] = true;\n    $args['has_archive']         = false;\n\n    return $args;\n} );",
            "requires": [
                "divi"
            ],
            "notes": "Existing projects are kept in the database and come back if you remove this snippet."
        },
        {
            "id": "divi-message-before-main-content",
            "version": 1,
            "title": "Site-Wide Message Above Divi Page Content",
            "description": "Shows a short message, such as holiday hours or a shipping notice, at the top of every page's main content area in the Divi theme.",
            "category": "page-builders",
            "tags": [
                "divi",
                "notice",
                "banner"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Message to show. Basic HTML such as <a> and <strong> is allowed.\n$wpide_divi_message = 'Our office is closed from <strong>Dec 24 to Jan 2</strong>. Orders ship again on Jan 3.';\n\nadd_action( 'et_before_main_content', function () use ( $wpide_divi_message ) {\n    if ( '' === trim( $wpide_divi_message ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-divi-message\" role=\"status\" style=\"background:#fff8e5;border-bottom:1px solid #f0d58c;padding:12px 20px;text-align:center;\">' . wp_kses_post( $wpide_divi_message ) . '</div>';\n} );",
            "requires": [
                "divi"
            ],
            "notes": "Leave the message empty to switch it off without deactivating the snippet."
        },
        {
            "id": "divi-posts-without-sidebar",
            "version": 1,
            "title": "Full-Width Blog Posts in Divi",
            "description": "Removes the sidebar from single blog posts in the Divi theme and gives the content the full width, without editing each post's layout setting.",
            "category": "page-builders",
            "tags": [
                "divi",
                "sidebar",
                "layout",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that should always be full width.\n$wpide_divi_full_width_types = array( 'post' );\n\nadd_filter( 'get_post_metadata', function ( $value, $object_id, $meta_key ) use ( $wpide_divi_full_width_types ) {\n    if ( '_et_pb_page_layout' !== $meta_key || is_admin() ) {\n        return $value;\n    }\n\n    if ( in_array( get_post_type( $object_id ), $wpide_divi_full_width_types, true ) ) {\n        return array( 'et_full_width_page' );\n    }\n\n    return $value;\n}, 10, 3 );",
            "requires": [
                "divi"
            ],
            "notes": "Change post to another post type slug, or add more, in $wpide_divi_full_width_types."
        },
        {
            "id": "divi-mobile-submenu-toggles",
            "version": 1,
            "title": "Collapsible Submenus in the Divi Mobile Menu",
            "description": "Folds submenus in Divi's mobile menu and adds a plus/minus button to open each one, so long menus no longer fill several screens on phones.",
            "category": "page-builders",
            "tags": [
                "divi",
                "mobile-menu",
                "navigation"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.et_mobile_menu .menu-item-has-children{position:relative;}\n.et_mobile_menu .menu-item-has-children > .sub-menu{display:none!important;padding-left:0;}\n.et_mobile_menu .menu-item-has-children.wpide-open > .sub-menu{display:block!important;}\n.et_mobile_menu .menu-item-has-children > a{padding-right:56px;}\n.wpide-divi-subtoggle{position:absolute;top:4px;right:4px;width:40px;height:40px;border:0;border-radius:4px;background:rgba(0,0,0,.05);font-size:20px;line-height:1;cursor:pointer;color:inherit;}\n</style>\n<script>\n(function () {\n    function setup(menu) {\n        if (menu.getAttribute('data-wpide-toggles')) { return; }\n        menu.setAttribute('data-wpide-toggles', '1');\n        menu.querySelectorAll('.menu-item-has-children').forEach(function (item) {\n            var button = document.createElement('button');\n            button.type = 'button';\n            button.className = 'wpide-divi-subtoggle';\n            button.setAttribute('aria-expanded', 'false');\n            button.setAttribute('aria-label', 'Show submenu');\n            button.textContent = '+';\n            button.addEventListener('click', function (e) {\n                e.preventDefault();\n                e.stopPropagation();\n                var open = item.classList.toggle('wpide-open');\n                button.setAttribute('aria-expanded', open ? 'true' : 'false');\n                button.textContent = open ? '−' : '+';\n            });\n            item.insertBefore(button, item.querySelector('.sub-menu'));\n        });\n    }\n    function scan() { document.querySelectorAll('.et_mobile_menu').forEach(setup); }\n    window.addEventListener('load', scan);\n    document.addEventListener('click', function (e) {\n        if (e.target.closest && e.target.closest('.mobile_menu_bar, .mobile_nav')) { setTimeout(scan, 50); }\n    });\n})();\n</script>",
            "requires": [
                "divi"
            ],
            "notes": "Works with the default Divi header and the Divi Theme Builder menu module."
        },
        {
            "id": "divi-open-toggle-from-hash",
            "version": 1,
            "title": "Open a Divi Toggle or Accordion From the URL",
            "description": "Opens the Divi toggle or accordion item whose CSS ID matches the address hash (for example #shipping) and scrolls to it, so you can link straight to an answer.",
            "category": "page-builders",
            "tags": [
                "divi",
                "accordion",
                "toggle",
                "links"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function openTarget() {\n        var id = decodeURIComponent(window.location.hash.slice(1));\n        if (!id) { return; }\n        var target = document.getElementById(id);\n        var toggle = target && (target.classList.contains('et_pb_toggle') ? target : target.querySelector('.et_pb_toggle'));\n        if (!toggle) { return; }\n        if (toggle.classList.contains('et_pb_toggle_close')) {\n            var title = toggle.querySelector('.et_pb_toggle_title');\n            if (title) { title.click(); }\n        }\n        setTimeout(function () { toggle.scrollIntoView({ block: 'start', behavior: 'smooth' }); }, 300);\n    }\n    window.addEventListener('load', openTarget);\n    window.addEventListener('hashchange', openTarget);\n})();\n</script>",
            "requires": [
                "divi"
            ],
            "notes": "Give the toggle or accordion item a CSS ID in its Advanced tab, then link to page-url#that-id."
        },
        {
            "id": "divi-accordions-start-closed",
            "version": 1,
            "title": "Divi Accordions That Start Closed",
            "description": "Makes every Divi accordion load with all items closed instead of the first one open, and lets visitors close an open item again by clicking its title.",
            "category": "page-builders",
            "tags": [
                "divi",
                "accordion",
                "faq"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\ndocument.addEventListener('DOMContentLoaded', function () {\n    document.querySelectorAll('.et_pb_accordion:not(.wpide-keep-open) .et_pb_toggle_open').forEach(function (item) {\n        item.classList.remove('et_pb_toggle_open');\n        item.classList.add('et_pb_toggle_close');\n    });\n    document.addEventListener('click', function (e) {\n        var title = e.target.closest ? e.target.closest('.et_pb_accordion:not(.wpide-keep-open) .et_pb_toggle_open .et_pb_toggle_title') : null;\n        if (!title) { return; }\n        var item = title.closest('.et_pb_toggle');\n        var content = item.querySelector('.et_pb_toggle_content');\n        e.stopImmediatePropagation();\n        item.classList.remove('et_pb_toggle_open');\n        item.classList.add('et_pb_toggle_close');\n        if (content) { content.style.display = ''; }\n    }, true);\n});\n</script>",
            "requires": [
                "divi"
            ],
            "notes": "Add the class wpide-keep-open to an accordion module to keep Divi's default behavior for it."
        },
        {
            "id": "divi-smaller-logo-fixed-header",
            "version": 1,
            "title": "Smaller Logo in the Divi Fixed Header",
            "description": "Shrinks the logo smoothly when Divi's header becomes fixed while scrolling, so the sticky header takes less room on screen.",
            "category": "page-builders",
            "tags": [
                "divi",
                "header",
                "logo"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "#logo {\n    transition: max-height 0.3s ease, transform 0.3s ease;\n}\n\n.et-fixed-header #logo {\n    max-height: 60% !important;\n}\n\n@media (prefers-reduced-motion: reduce) {\n    #logo {\n        transition: none;\n    }\n}",
            "requires": [
                "divi"
            ],
            "notes": "Change 60% to make the scrolled logo bigger or smaller. Applies to Divi's default header, not Theme Builder headers."
        },
        {
            "id": "divi-builder-access-by-role",
            "version": 1,
            "title": "Keep Selected Roles Out of the Divi Visual Builder",
            "description": "Sends users without the capability you choose back to the normal page when they try to open the Divi Visual Builder, and hides the \"Enable Visual Builder\" admin bar link from them.",
            "category": "page-builders",
            "tags": [
                "divi",
                "roles",
                "permissions",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Capability needed to use the Visual Builder.\n$wpide_divi_builder_capability = 'manage_options';\n\nadd_action( 'template_redirect', function () use ( $wpide_divi_builder_capability ) {\n    if ( empty( $_GET['et_fb'] ) || ! is_user_logged_in() || current_user_can( $wpide_divi_builder_capability ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    wp_safe_redirect( remove_query_arg( array( 'et_fb', 'PageSpeed' ) ) );\n    exit;\n} );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_divi_builder_capability ) {\n    if ( ! current_user_can( $wpide_divi_builder_capability ) ) {\n        $wp_admin_bar->remove_node( 'et-use-visual-builder' );\n    }\n}, 999 );",
            "requires": [
                "divi"
            ],
            "notes": "Divi's Role Editor gives finer control; this is a quick way to lock layouts for everyone below the chosen capability."
        },
        {
            "id": "divi-header-call-button",
            "version": 1,
            "title": "Call Button in the Divi Header",
            "description": "Adds a tap-to-call phone button next to the menu in Divi's default header, which is especially handy for local businesses on mobile.",
            "category": "page-builders",
            "tags": [
                "divi",
                "header",
                "phone",
                "mobile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Phone number to dial and the button text.\n$wpide_divi_phone       = '+1 555 010 0199';\n$wpide_divi_phone_label = 'Call us';\n\nadd_action( 'et_header_top', function () use ( $wpide_divi_phone, $wpide_divi_phone_label ) {\n    $tel = preg_replace( '/[^0-9+]/', '', $wpide_divi_phone );\n\n    if ( '' === $tel ) {\n        return;\n    }\n\n    printf(\n        '<a class=\"wpide-divi-call\" href=\"tel:%1$s\" style=\"float:right;margin:0 0 0 18px;padding:8px 16px;border-radius:999px;background:#2ea3f2;color:#fff;font-weight:600;line-height:1.2;position:relative;top:50%%;transform:translateY(25%%);\">%2$s</a>',\n        esc_attr( $tel ),\n        esc_html( $wpide_divi_phone_label )\n    );\n} );",
            "requires": [
                "divi"
            ],
            "notes": "Uses Divi's et_header_top hook, so it appears in the default header, not in Theme Builder headers."
        },
        {
            "id": "divi-no-block-css-on-divi-pages",
            "version": 1,
            "title": "Skip Block Editor Styles on Divi Builder Pages",
            "description": "Stops the block library stylesheet loading on pages built with the Divi Builder, which do not use blocks, so each of those pages loads one stylesheet less.",
            "category": "page-builders",
            "tags": [
                "divi",
                "performance",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    if ( 'on' !== get_post_meta( get_queried_object_id(), '_et_pb_use_builder', true ) ) {\n        return;\n    }\n\n    wp_dequeue_style( 'wp-block-library' );\n    wp_dequeue_style( 'wp-block-library-theme' );\n    wp_dequeue_style( 'classic-theme-styles' );\n}, 100 );",
            "requires": [
                "divi"
            ],
            "notes": "Leave it off if you mix Divi layouts with WordPress blocks on the same page."
        },
        {
            "id": "beaver-builder-custom-post-types",
            "version": 1,
            "title": "Enable Beaver Builder for Custom Post Types",
            "description": "Turns Beaver Builder on for the custom post types you list, in addition to the ones ticked in its settings.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "post-types",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that should get Beaver Builder.\n$wpide_bb_post_types = array( 'portfolio', 'landing' );\n\nadd_filter( 'fl_builder_post_types', function ( $post_types ) use ( $wpide_bb_post_types ) {\n    return array_values( array_unique( array_merge( (array) $post_types, $wpide_bb_post_types ) ) );\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Use post type slugs; the post type must be public."
        },
        {
            "id": "beaver-builder-disable-google-fonts",
            "version": 1,
            "title": "Stop Beaver Builder From Loading Google Fonts",
            "description": "Prevents Beaver Builder modules from requesting fonts from Google, for privacy (GDPR) and faster pages.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "fonts",
                "privacy",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'fl_builder_google_fonts_pre_enqueue', function () {\n    return array();\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Modules set to a Google font fall back to the next font in the stack; add your own fonts with \"Add System Fonts to Beaver Builder\"."
        },
        {
            "id": "beaver-builder-system-fonts",
            "version": 1,
            "title": "Add Your Own Fonts to Beaver Builder",
            "description": "Adds self-hosted or theme fonts to Beaver Builder's font list, with their fallback stack and available weights, so editors can select them in any module.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "fonts",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Font name => fallback stack and weights.\n$wpide_bb_fonts = array(\n    'Inter'          => array( 'fallback' => 'system-ui, sans-serif', 'weights' => array( '400', '600', '700' ) ),\n    'Source Serif 4' => array( 'fallback' => 'Georgia, serif', 'weights' => array( '400', '700' ) ),\n);\n\nadd_filter( 'fl_builder_font_families_system', function ( $fonts ) use ( $wpide_bb_fonts ) {\n    return array_merge( (array) $fonts, $wpide_bb_fonts );\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "This adds the names to the list; load the font files yourself with @font-face or your theme."
        },
        {
            "id": "beaver-builder-color-presets",
            "version": 1,
            "title": "Brand Colors in the Beaver Builder Color Picker",
            "description": "Adds your brand colors as presets in every Beaver Builder color picker, so editors pick exact colors instead of guessing hex codes.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "colors",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Brand colors, hex without \"#\".\n$wpide_bb_colors = array( '1e3a8a', '2563eb', 'f59e0b', '111827', 'f9fafb' );\n\nadd_filter( 'fl_builder_color_presets', function ( $colors ) use ( $wpide_bb_colors ) {\n    return array_values( array_unique( array_merge( $wpide_bb_colors, (array) $colors ) ) );\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Colors are hex values without the # sign."
        },
        {
            "id": "beaver-builder-hide-modules",
            "version": 1,
            "title": "Hide Unused Beaver Builder Modules",
            "description": "Removes the modules you list from the Beaver Builder panel, so editors only see the building blocks your design system uses.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "modules",
                "editor",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Module slugs to hide.\n$wpide_bb_hidden_modules = array( 'audio', 'countdown', 'sidebar', 'widget' );\n\nadd_filter( 'fl_builder_register_module', function ( $enabled, $instance ) use ( $wpide_bb_hidden_modules ) {\n    if ( is_object( $instance ) && isset( $instance->slug ) && in_array( $instance->slug, $wpide_bb_hidden_modules, true ) ) {\n        return false;\n    }\n\n    return $enabled;\n}, 10, 2 );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Module slugs are the folder names in the plugin's modules directory, such as audio, countdown or sidebar. Layouts already using a hidden module stop showing it."
        },
        {
            "id": "beaver-builder-disable-inline-editing",
            "version": 1,
            "title": "Turn Off Inline Text Editing in Beaver Builder",
            "description": "Disables click-to-type text editing on the page in Beaver Builder, so text is only changed in the settings panel and is not edited by accident while dragging.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fl_inline_editing_enabled', '__return_false' );",
            "requires": [
                "beaver-builder"
            ],
            "notes": ""
        },
        {
            "id": "beaver-builder-access-by-role",
            "version": 1,
            "title": "Keep Selected Roles Out of Beaver Builder",
            "description": "Sends users without the chosen capability back to the normal page if they try to open Beaver Builder, and hides its admin bar link from them.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "roles",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Capability needed to open the builder.\n$wpide_bb_capability = 'manage_options';\n\nadd_action( 'template_redirect', function () use ( $wpide_bb_capability ) {\n    if ( ! isset( $_GET['fl_builder'] ) || ! is_user_logged_in() || current_user_can( $wpide_bb_capability ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    wp_safe_redirect( remove_query_arg( array( 'fl_builder', 'fl_builder_ui' ) ) );\n    exit;\n}, 1 );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_bb_capability ) {\n    if ( ! current_user_can( $wpide_bb_capability ) ) {\n        $wp_admin_bar->remove_node( 'fl-builder-frontend-edit-link' );\n    }\n}, 999 );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Beaver Builder Pro's user access settings offer finer control; this is a quick lock for one capability."
        },
        {
            "id": "beaver-builder-visibility-login-classes",
            "version": 1,
            "title": "Show Beaver Builder Rows Only to Logged-In or Logged-Out Visitors",
            "description": "Hides any Beaver Builder row, column or module with the class \"wpide-logged-in\" from guests, and anything with \"wpide-logged-out\" from members.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "visibility",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'fl_builder_is_node_visible', function ( $is_visible, $node ) {\n    if ( ! $is_visible || ! is_object( $node ) || empty( $node->settings->class ) ) {\n        return $is_visible;\n    }\n\n    if ( class_exists( 'FLBuilderModel' ) && FLBuilderModel::is_builder_active() ) {\n        return $is_visible;\n    }\n\n    $classes = ' ' . $node->settings->class . ' ';\n\n    if ( false !== strpos( $classes, ' wpide-logged-in ' ) && ! is_user_logged_in() ) {\n        return false;\n    }\n\n    if ( false !== strpos( $classes, ' wpide-logged-out ' ) && is_user_logged_in() ) {\n        return false;\n    }\n\n    return $is_visible;\n}, 10, 2 );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Add the class under Advanced > HTML Element > Class. Everything stays visible while the builder is open."
        },
        {
            "id": "beaver-builder-loop-exclude-current",
            "version": 1,
            "title": "Beaver Builder Post Modules: Leave Out the Current Post",
            "description": "Removes the post being viewed from Beaver Builder Posts, Post Slider and Post Carousel modules, so \"latest posts\" blocks on articles never repeat the article itself.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "posts",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'fl_builder_loop_query_args', function ( $args ) {\n    if ( ! is_singular() ) {\n        return $args;\n    }\n\n    $exclude   = isset( $args['post__not_in'] ) ? (array) $args['post__not_in'] : array();\n    $exclude[] = get_queried_object_id();\n\n    $args['post__not_in'] = array_values( array_filter( array_map( 'absint', $exclude ) ) );\n\n    return $args;\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Applies to every Beaver Builder post module on single posts and pages."
        },
        {
            "id": "beaver-builder-layout-css",
            "version": 1,
            "title": "Add CSS to Every Beaver Builder Layout",
            "description": "Appends your own CSS to the stylesheet Beaver Builder generates for each layout, so it is cached and loaded together with the layout instead of on every page.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "css",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// CSS added to each Beaver Builder layout stylesheet.\n$wpide_bb_layout_css = '.fl-builder-content .fl-button{border-radius:999px;}.fl-builder-content .fl-module-heading .fl-heading{text-wrap:balance;}';\n\nadd_filter( 'fl_builder_render_css', function ( $css ) use ( $wpide_bb_layout_css ) {\n    return $css . \"\\n/* Site additions */\\n\" . wp_strip_all_tags( $wpide_bb_layout_css );\n} );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Clear the Beaver Builder cache (Settings > Beaver Builder > Tools) after changing the CSS."
        },
        {
            "id": "bricks-query-exclude-current-post",
            "version": 1,
            "title": "Bricks Query Loop: Exclude the Current Post",
            "description": "Leaves the post being viewed out of the Bricks query loops you choose, so \"More articles\" sections in single post templates never repeat the current article.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "query-loop",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Bricks element IDs whose query loop should skip the current post.\n$wpide_bricks_exclude_ids = array( 'abcdef' );\n\nadd_filter( 'bricks/posts/query_vars', function ( $query_vars, $settings, $element_id ) use ( $wpide_bricks_exclude_ids ) {\n    if ( ! in_array( $element_id, $wpide_bricks_exclude_ids, true ) || ! is_singular() ) {\n        return $query_vars;\n    }\n\n    $exclude   = isset( $query_vars['post__not_in'] ) ? (array) $query_vars['post__not_in'] : array();\n    $exclude[] = get_queried_object_id();\n\n    $query_vars['post__not_in'] = array_values( array_filter( array_map( 'absint', $exclude ) ) );\n\n    return $query_vars;\n}, 10, 3 );",
            "requires": [
                "bricks"
            ],
            "notes": "Put the element IDs of your query loop elements (the six-letter ID shown in Bricks, without \"brxe-\") in the list."
        },
        {
            "id": "bricks-query-upcoming-by-date-field",
            "version": 1,
            "title": "Bricks Query Loop: Upcoming Items by a Date Field",
            "description": "Limits the chosen Bricks query loops to items whose date field is today or later and sorts them soonest first, for event and webinar lists.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "query-loop",
                "events",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Query loop element IDs and the date field (stored as Ymd).\n$wpide_bricks_upcoming_ids  = array( 'abcdef' );\n$wpide_bricks_upcoming_meta = 'event_date';\n\nadd_filter( 'bricks/posts/query_vars', function ( $query_vars, $settings, $element_id ) use ( $wpide_bricks_upcoming_ids, $wpide_bricks_upcoming_meta ) {\n    if ( ! in_array( $element_id, $wpide_bricks_upcoming_ids, true ) ) {\n        return $query_vars;\n    }\n\n    $meta_query   = isset( $query_vars['meta_query'] ) ? (array) $query_vars['meta_query'] : array();\n    $meta_query[] = array(\n        'key'     => $wpide_bricks_upcoming_meta,\n        'value'   => wp_date( 'Ymd' ),\n        'compare' => '>=',\n        'type'    => 'NUMERIC',\n    );\n\n    $query_vars['meta_query'] = $meta_query;\n    $query_vars['meta_key']   = $wpide_bricks_upcoming_meta;\n    $query_vars['orderby']    = 'meta_value_num';\n    $query_vars['order']      = 'ASC';\n\n    return $query_vars;\n}, 10, 3 );",
            "requires": [
                "bricks"
            ],
            "notes": "The date field must store Ymd values (ACF date picker default). Set the element IDs and field name at the top."
        },
        {
            "id": "bricks-echo-function-allowlist",
            "version": 1,
            "title": "Allow Your Functions in Bricks {echo} Tags",
            "description": "Adds the PHP functions you trust to Bricks' allow list for {echo:function_name} dynamic data tags, which Bricks requires before it runs them.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "dynamic-data",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Function names allowed in {echo:...} tags.\n$wpide_bricks_echo_functions = array( 'wpide_current_year', 'get_bloginfo' );\n\nadd_filter( 'bricks/code/echo_function_names', function ( $names ) use ( $wpide_bricks_echo_functions ) {\n    return array_values( array_unique( array_merge( (array) $names, $wpide_bricks_echo_functions ) ) );\n} );\n\nif ( ! function_exists( 'wpide_current_year' ) ) {\n    function wpide_current_year() {\n        return wp_date( 'Y' );\n    }\n}",
            "requires": [
                "bricks"
            ],
            "notes": "Only list functions you wrote or fully trust, that take no unsafe input and return a string."
        },
        {
            "id": "bricks-dynamic-tag-current-year",
            "version": 1,
            "title": "Bricks Dynamic Tag: {wpide_year}",
            "description": "Adds a {wpide_year} dynamic data tag to Bricks that outputs the current year, for footer copyright lines that update themselves.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "dynamic-data",
                "date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'bricks/dynamic_tags_list', function ( $tags ) {\n    $tags[] = array( 'name' => '{wpide_year}', 'label' => 'Current year', 'group' => 'Site' );\n\n    return $tags;\n} );\n\nadd_filter( 'bricks/dynamic_data/render_tag', function ( $tag, $post = null, $context = 'text' ) {\n    $name = is_string( $tag ) ? trim( $tag, '{}' ) : $tag;\n\n    return 'wpide_year' === $name ? wp_date( 'Y' ) : $tag;\n}, 20, 3 );\n\n$wpide_bricks_year_replace = function ( $content ) {\n    if ( ! is_string( $content ) || false === strpos( $content, '{wpide_year}' ) ) {\n        return $content;\n    }\n\n    return str_replace( '{wpide_year}', wp_date( 'Y' ), $content );\n};\n\nadd_filter( 'bricks/dynamic_data/render_content', $wpide_bricks_year_replace, 20 );\nadd_filter( 'bricks/frontend/render_data', $wpide_bricks_year_replace, 20 );",
            "requires": [
                "bricks"
            ],
            "notes": "Find it in the dynamic data list under \"Site\". You can also type {wpide_year} into any text field."
        },
        {
            "id": "bricks-dynamic-tag-reading-time",
            "version": 1,
            "title": "Bricks Dynamic Tag: {wpide_reading_time}",
            "description": "Adds a {wpide_reading_time} dynamic data tag that shows the estimated reading time of the current post (\"5 min read\"), for single templates and query loops.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "dynamic-data",
                "reading-time"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed in words per minute.\n$wpide_bricks_wpm = 220;\n\n$wpide_bricks_reading_time = function ( $post_id ) use ( $wpide_bricks_wpm ) {\n    $words = str_word_count( wp_strip_all_tags( (string) get_post_field( 'post_content', $post_id ) ) );\n\n    return sprintf( '%d min read', max( 1, (int) ceil( $words / max( 1, $wpide_bricks_wpm ) ) ) );\n};\n\nadd_filter( 'bricks/dynamic_tags_list', function ( $tags ) {\n    $tags[] = array( 'name' => '{wpide_reading_time}', 'label' => 'Reading time', 'group' => 'Post' );\n\n    return $tags;\n} );\n\nadd_filter( 'bricks/dynamic_data/render_tag', function ( $tag, $post = null, $context = 'text' ) use ( $wpide_bricks_reading_time ) {\n    $name = is_string( $tag ) ? trim( $tag, '{}' ) : $tag;\n\n    if ( 'wpide_reading_time' !== $name ) {\n        return $tag;\n    }\n\n    return $wpide_bricks_reading_time( $post instanceof WP_Post ? $post->ID : get_the_ID() );\n}, 20, 3 );\n\n$wpide_bricks_reading_replace = function ( $content, $post = null ) use ( $wpide_bricks_reading_time ) {\n    if ( ! is_string( $content ) || false === strpos( $content, '{wpide_reading_time}' ) ) {\n        return $content;\n    }\n\n    return str_replace( '{wpide_reading_time}', $wpide_bricks_reading_time( $post instanceof WP_Post ? $post->ID : get_the_ID() ), $content );\n};\n\nadd_filter( 'bricks/dynamic_data/render_content', $wpide_bricks_reading_replace, 20, 2 );\nadd_filter( 'bricks/frontend/render_data', $wpide_bricks_reading_replace, 20, 2 );",
            "requires": [
                "bricks"
            ],
            "notes": "Change the words-per-minute value to match your audience."
        },
        {
            "id": "bricks-condition-url-parameter",
            "version": 1,
            "title": "Bricks Element Condition: URL Parameter",
            "description": "Adds a \"URL parameter\" condition to Bricks element conditions, so you can show an element only when the address contains something like ?ref=newsletter.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "conditions",
                "landing-pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'bricks/conditions/groups', function ( $groups ) {\n    $groups[] = array( 'name' => 'wpide', 'label' => 'Site rules' );\n\n    return $groups;\n} );\n\nadd_filter( 'bricks/conditions/options', function ( $options ) {\n    $options[] = array(\n        'key'     => 'wpide_url_param',\n        'label'   => 'URL parameter',\n        'group'   => 'wpide',\n        'compare' => array(\n            'type'        => 'select',\n            'options'     => array( '==' => 'is', '!=' => 'is not' ),\n            'placeholder' => 'is',\n        ),\n        'value'   => array( 'type' => 'text', 'placeholder' => 'ref=newsletter' ),\n    );\n\n    return $options;\n} );\n\nadd_filter( 'bricks/conditions/result', function ( $result, $condition_key, $condition ) {\n    if ( 'wpide_url_param' !== $condition_key ) {\n        return $result;\n    }\n\n    $rule    = isset( $condition['value'] ) ? (string) $condition['value'] : '';\n    $compare = isset( $condition['compare'] ) ? $condition['compare'] : '==';\n    $parts   = explode( '=', $rule, 2 );\n    $name    = sanitize_key( $parts[0] );\n    $current = ( $name && isset( $_GET[ $name ] ) ) ? sanitize_text_field( wp_unslash( $_GET[ $name ] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n    $match   = isset( $parts[1] ) ? ( null !== $current && $current === $parts[1] ) : null !== $current;\n\n    return '!=' === $compare ? ! $match : $match;\n}, 10, 3 );",
            "requires": [
                "bricks"
            ],
            "notes": "Enter the value as name=value (for example ref=newsletter), or just the name to test that the parameter exists."
        },
        {
            "id": "bricks-condition-opening-hours",
            "version": 1,
            "title": "Bricks Element Condition: During Opening Hours",
            "description": "Adds a \"During opening hours\" condition to Bricks, so a \"Call us now\" button or live chat block only shows while your business is open.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "conditions",
                "business-hours"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Opening hours per weekday (1 = Monday ... 7 = Sunday), 24-hour \"HH:MM\".\n$wpide_bricks_hours = array(\n    1 => array( '09:00', '17:30' ),\n    2 => array( '09:00', '17:30' ),\n    3 => array( '09:00', '17:30' ),\n    4 => array( '09:00', '17:30' ),\n    5 => array( '09:00', '16:00' ),\n);\n\nadd_filter( 'bricks/conditions/groups', function ( $groups ) {\n    foreach ( $groups as $group ) {\n        if ( isset( $group['name'] ) && 'wpide' === $group['name'] ) {\n            return $groups;\n        }\n    }\n\n    $groups[] = array( 'name' => 'wpide', 'label' => 'Site rules' );\n\n    return $groups;\n} );\n\nadd_filter( 'bricks/conditions/options', function ( $options ) {\n    $options[] = array(\n        'key'     => 'wpide_open_now',\n        'label'   => 'During opening hours',\n        'group'   => 'wpide',\n        'compare' => array(\n            'type'        => 'select',\n            'options'     => array( '==' => 'is', '!=' => 'is not' ),\n            'placeholder' => 'is',\n        ),\n        'value'   => array( 'type' => 'select', 'options' => array( 'open' => 'open' ), 'placeholder' => 'open' ),\n    );\n\n    return $options;\n} );\n\nadd_filter( 'bricks/conditions/result', function ( $result, $condition_key, $condition ) use ( $wpide_bricks_hours ) {\n    if ( 'wpide_open_now' !== $condition_key ) {\n        return $result;\n    }\n\n    $now   = new DateTime( 'now', wp_timezone() );\n    $day   = (int) $now->format( 'N' );\n    $time  = $now->format( 'H:i' );\n    $open  = isset( $wpide_bricks_hours[ $day ] ) && $time >= $wpide_bricks_hours[ $day ][0] && $time < $wpide_bricks_hours[ $day ][1];\n\n    return ( isset( $condition['compare'] ) && '!=' === $condition['compare'] ) ? ! $open : $open;\n}, 10, 3 );",
            "requires": [
                "bricks"
            ],
            "notes": "Hours use the site timezone from Settings > General. Pages using it should not be cached for long."
        },
        {
            "id": "bricks-visibility-login-classes",
            "version": 1,
            "title": "Show Bricks Elements Only to Logged-In or Logged-Out Visitors",
            "description": "Skips rendering any Bricks element with the CSS class \"wpide-logged-in\" for guests and \"wpide-logged-out\" for members, a quick alternative to setting conditions on each element.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "visibility",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'bricks/element/render', function ( $render, $element ) {\n    if ( ! $render || ! is_object( $element ) || empty( $element->settings['_cssClasses'] ) ) {\n        return $render;\n    }\n\n    if ( function_exists( 'bricks_is_builder' ) && bricks_is_builder() ) {\n        return $render;\n    }\n\n    $classes = ' ' . $element->settings['_cssClasses'] . ' ';\n\n    if ( false !== strpos( $classes, ' wpide-logged-in ' ) && ! is_user_logged_in() ) {\n        return false;\n    }\n\n    if ( false !== strpos( $classes, ' wpide-logged-out ' ) && is_user_logged_in() ) {\n        return false;\n    }\n\n    return $render;\n}, 10, 2 );",
            "requires": [
                "bricks"
            ],
            "notes": "Add the class in the element's Style > CSS > CSS classes field (not a global class)."
        },
        {
            "id": "bricks-disable-seo-output",
            "version": 1,
            "title": "Turn Off Bricks SEO and Open Graph Tags",
            "description": "Stops Bricks from printing its own SEO meta and Open Graph tags, avoiding duplicate tags when Yoast, Rank Math or another SEO plugin handles them.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "seo",
                "open-graph"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'bricks/frontend/disable_seo', '__return_true' );\nadd_filter( 'bricks/frontend/disable_opengraph', '__return_true' );",
            "requires": [
                "bricks"
            ],
            "notes": ""
        },
        {
            "id": "bricks-builder-access-by-role",
            "version": 1,
            "title": "Keep Selected Roles Out of the Bricks Builder",
            "description": "Sends users without the chosen capability back to the page when they try to open the Bricks builder and hides its admin bar link, as an extra lock on top of Bricks' builder access settings.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "roles",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Capability needed to open the builder.\n$wpide_bricks_capability = 'manage_options';\n\nadd_action( 'template_redirect', function () use ( $wpide_bricks_capability ) {\n    if ( ! isset( $_GET['bricks'] ) || ! is_user_logged_in() || current_user_can( $wpide_bricks_capability ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    wp_safe_redirect( remove_query_arg( 'bricks' ) );\n    exit;\n}, 1 );\n\nadd_action( 'admin_bar_menu', function ( $wp_admin_bar ) use ( $wpide_bricks_capability ) {\n    if ( ! current_user_can( $wpide_bricks_capability ) ) {\n        $wp_admin_bar->remove_node( 'edit_with_bricks' );\n    }\n}, 999 );",
            "requires": [
                "bricks"
            ],
            "notes": "Change manage_options to edit_others_pages to let editors in too."
        },
        {
            "id": "bricks-popup-from-url-hash",
            "version": 1,
            "title": "Open a Bricks Popup From a Link or URL Hash",
            "description": "Opens a Bricks popup template when the address ends in #popup-123 (123 being the template ID) or a link to that hash is clicked, without setting up interactions.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "popups",
                "links"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function open(hash) {\n        var match = /^#popup-(\\d+)$/.exec(hash || '');\n        if (!match || typeof window.bricksOpenPopup !== 'function') { return false; }\n        window.bricksOpenPopup(parseInt(match[1], 10));\n        return true;\n    }\n    window.addEventListener('load', function () { open(window.location.hash); });\n    document.addEventListener('click', function (e) {\n        var link = e.target.closest ? e.target.closest('a[href*=\"#popup-\"]') : null;\n        if (link && link.hash && link.pathname === window.location.pathname && open(link.hash)) { e.preventDefault(); }\n    });\n})();\n</script>",
            "requires": [
                "bricks"
            ],
            "notes": "The popup template's conditions must include the page, so Bricks prints it."
        },
        {
            "id": "bricks-form-business-email",
            "version": 1,
            "title": "Bricks Forms: Require a Business Email Address",
            "description": "Rejects Bricks form submissions that use free email providers such as Gmail or Outlook, for B2B quote and demo request forms.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "forms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Free email domains to refuse, and the error shown.\n$wpide_bricks_free_domains = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'proton.me' );\n$wpide_bricks_free_message = 'Please use your work email address.';\n\nadd_filter( 'bricks/form/validate', function ( $errors, $form ) use ( $wpide_bricks_free_domains, $wpide_bricks_free_message ) {\n    if ( ! is_object( $form ) || ! method_exists( $form, 'get_fields' ) ) {\n        return $errors;\n    }\n\n    foreach ( (array) $form->get_fields() as $key => $value ) {\n        if ( 0 !== strpos( (string) $key, 'form-field-' ) || ! is_string( $value ) || ! is_email( $value ) ) {\n            continue;\n        }\n\n        $domain = strtolower( substr( strrchr( $value, '@' ), 1 ) );\n\n        if ( in_array( $domain, $wpide_bricks_free_domains, true ) ) {\n            $errors[] = $wpide_bricks_free_message;\n            break;\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "bricks"
            ],
            "notes": "Checks every email field of every Bricks form. Edit the domain list to suit your audience."
        },
        {
            "id": "bricks-allow-extra-html-tags",
            "version": 1,
            "title": "Allow Extra HTML Tags in Bricks",
            "description": "Adds HTML tags such as dialog, search and address to the list Bricks accepts in an element's custom tag setting, for more semantic markup.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "html",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra tag names to allow.\n$wpide_bricks_extra_tags = array( 'dialog', 'search', 'address', 'hgroup', 'menu' );\n\nadd_filter( 'bricks/allowed_html_tags', function ( $tags ) use ( $wpide_bricks_extra_tags ) {\n    return array_values( array_unique( array_merge( (array) $tags, $wpide_bricks_extra_tags ) ) );\n} );",
            "requires": [
                "bricks"
            ],
            "notes": "Only add real, non-executable HTML elements; never script or iframe."
        },
        {
            "id": "bricks-query-type-child-pages",
            "version": 1,
            "title": "Bricks Query Type: Child Pages of the Current Page",
            "description": "Adds a \"Child pages\" query type to Bricks query loops that lists the subpages of the page being viewed, in menu order, for section overview pages.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "query-loop",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'bricks/setup/control_options', function ( $options ) {\n    $options['queryTypes']['wpide_child_pages'] = 'Child pages';\n\n    return $options;\n} );\n\nadd_filter( 'bricks/query/run', function ( $results, $query_obj ) {\n    if ( ! is_object( $query_obj ) || 'wpide_child_pages' !== $query_obj->object_type ) {\n        return $results;\n    }\n\n    $parent = get_queried_object_id();\n\n    if ( ! $parent ) {\n        return array();\n    }\n\n    return get_posts( array(\n        'post_type'      => get_post_type( $parent ),\n        'post_parent'    => $parent,\n        'posts_per_page' => 50,\n        'orderby'        => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n        'no_found_rows'  => true,\n    ) );\n}, 10, 2 );\n\nadd_filter( 'bricks/query/loop_object', function ( $loop_object, $loop_key, $query_obj ) {\n    if ( is_object( $query_obj ) && 'wpide_child_pages' === $query_obj->object_type && $loop_object instanceof WP_Post ) {\n        global $post;\n        $post = $loop_object; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited\n        setup_postdata( $post );\n    }\n\n    return $loop_object;\n}, 10, 3 );",
            "requires": [
                "bricks"
            ],
            "notes": "Choose \"Child pages\" as the query type on any looping element; dynamic data like {post_title} works inside it."
        },
        {
            "id": "bricks-disable-google-fonts",
            "version": 1,
            "title": "Stop Bricks From Loading Google Fonts",
            "description": "Turns on Bricks' \"Disable Google Fonts\" setting from code, so neither the builder nor the front end requests fonts from Google.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "fonts",
                "privacy",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'option_bricks_global_settings', function ( $settings ) {\n    if ( is_array( $settings ) ) {\n        $settings['disableGoogleFonts'] = true;\n    }\n\n    return $settings;\n} );",
            "requires": [
                "bricks"
            ],
            "notes": "Upload the fonts you need under Bricks > Custom Fonts so headings keep their typeface."
        },
        {
            "id": "astra-no-sidebar-for-post-types",
            "version": 1,
            "title": "Astra: No Sidebar on Selected Post Types",
            "description": "Removes the sidebar in the Astra theme for the post types you list, such as products, courses or landing pages, without changing each item's settings.",
            "category": "page-builders",
            "tags": [
                "astra",
                "sidebar",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that should never show a sidebar.\n$wpide_astra_full_types = array( 'page', 'product' );\n\nadd_filter( 'astra_page_layout', function ( $layout ) use ( $wpide_astra_full_types ) {\n    if ( ! defined( 'ASTRA_THEME_VERSION' ) ) {\n        return $layout;\n    }\n\n    if ( is_singular( $wpide_astra_full_types ) || is_post_type_archive( $wpide_astra_full_types ) ) {\n        return 'no-sidebar';\n    }\n\n    return $layout;\n} );",
            "requires": [],
            "notes": "Only runs while Astra is the active theme. Per-post sidebar settings set in the editor are overridden for these types."
        },
        {
            "id": "astra-hide-featured-image-on-pages",
            "version": 1,
            "title": "Astra: Hide the Featured Image on Pages",
            "description": "Stops the Astra theme from showing the featured image at the top of pages, while it is still used for social sharing and page lists.",
            "category": "page-builders",
            "tags": [
                "astra",
                "featured-image",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'astra_featured_image_enabled', function ( $enabled ) {\n    if ( defined( 'ASTRA_THEME_VERSION' ) && is_page() ) {\n        return false;\n    }\n\n    return $enabled;\n} );",
            "requires": [],
            "notes": "Change is_page() to is_singular( 'post' ) to hide it on blog posts instead."
        },
        {
            "id": "astra-disable-post-navigation",
            "version": 1,
            "title": "Astra: Remove Previous/Next Links Under Posts",
            "description": "Removes the previous and next post links that the Astra theme adds below single posts, for a cleaner end of article or when you show related posts instead.",
            "category": "page-builders",
            "tags": [
                "astra",
                "posts",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'astra_single_post_navigation_enabled', function ( $enabled ) {\n    return defined( 'ASTRA_THEME_VERSION' ) ? false : $enabled;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "astra-read-more-text",
            "version": 1,
            "title": "Astra: Change the \"Read More\" Text",
            "description": "Changes the \"Read More\" link text that the Astra theme shows on blog archives to your own wording, such as \"Continue reading\".",
            "category": "page-builders",
            "tags": [
                "astra",
                "blog",
                "read-more"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// New link text.\n$wpide_astra_read_more = 'Continue reading';\n\nadd_filter( 'astra_post_read_more', function ( $text ) use ( $wpide_astra_read_more ) {\n    return defined( 'ASTRA_THEME_VERSION' ) ? esc_html( $wpide_astra_read_more ) : $text;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "astra-bar-above-header",
            "version": 1,
            "title": "Astra: Message Bar Above the Header",
            "description": "Prints a slim message bar above the Astra header on every page, for sales, shipping notes or event dates, using Astra's own header hook.",
            "category": "page-builders",
            "tags": [
                "astra",
                "header",
                "banner"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Message and colors.\n$wpide_astra_bar_text = 'Free shipping on orders over $50 &mdash; <a href=\"/shop/\">Shop now</a>';\n$wpide_astra_bar_bg   = '#111827';\n$wpide_astra_bar_fg   = '#ffffff';\n\nadd_action( 'astra_header_before', function () use ( $wpide_astra_bar_text, $wpide_astra_bar_bg, $wpide_astra_bar_fg ) {\n    if ( '' === trim( $wpide_astra_bar_text ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"wpide-astra-bar\" style=\"background:%1$s;color:%2$s;text-align:center;padding:8px 16px;font-size:14px;\"><style>.wpide-astra-bar a{color:inherit;text-decoration:underline;}</style>%3$s</div>',\n        esc_attr( $wpide_astra_bar_bg ),\n        esc_attr( $wpide_astra_bar_fg ),\n        wp_kses_post( $wpide_astra_bar_text )\n    );\n} );",
            "requires": [],
            "notes": "Leave the message empty to hide the bar. Colors are set in the inline style."
        },
        {
            "id": "astra-mobile-header-breakpoint",
            "version": 1,
            "title": "Astra: Change the Mobile Menu Breakpoint",
            "description": "Changes the screen width at which the Astra theme switches to the mobile (hamburger) menu, useful when a long menu wraps on tablets.",
            "category": "page-builders",
            "tags": [
                "astra",
                "mobile-menu",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Switch to the mobile menu below this width (px).\n$wpide_astra_breakpoint = 1100;\n\nadd_filter( 'astra_header_break_point', function () use ( $wpide_astra_breakpoint ) {\n    return absint( $wpide_astra_breakpoint );\n} );",
            "requires": [],
            "notes": "Width in pixels; Astra's default is 921. Clear any CSS cache after changing it."
        },
        {
            "id": "generatepress-custom-copyright",
            "version": 1,
            "title": "GeneratePress: Custom Footer Copyright",
            "description": "Replaces the \"Built with GeneratePress\" footer line with your own copyright text and the current year, without the premium plugin.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "footer",
                "copyright"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Footer text. {year} and {site} are replaced.\n$wpide_gp_copyright = '&copy; {year} {site}. All rights reserved.';\n\nadd_filter( 'generate_copyright', function () use ( $wpide_gp_copyright ) {\n    $text = str_replace(\n        array( '{year}', '{site}' ),\n        array( wp_date( 'Y' ), esc_html( get_bloginfo( 'name' ) ) ),\n        $wpide_gp_copyright\n    );\n\n    return wp_kses_post( $text );\n} );",
            "requires": [],
            "notes": "Use {year} and {site} as placeholders in the text."
        },
        {
            "id": "generatepress-no-sidebar-for-post-types",
            "version": 1,
            "title": "GeneratePress: No Sidebar on Selected Post Types",
            "description": "Removes the sidebar in GeneratePress for the post types and archives you list, for full-width shop, course or portfolio pages.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "sidebar",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that should be full width.\n$wpide_gp_full_types = array( 'product', 'page' );\n\nadd_filter( 'generate_sidebar_layout', function ( $layout ) use ( $wpide_gp_full_types ) {\n    if ( is_singular( $wpide_gp_full_types ) || is_post_type_archive( $wpide_gp_full_types ) ) {\n        return 'no-sidebar';\n    }\n\n    return $layout;\n} );",
            "requires": [],
            "notes": "Layout values GeneratePress accepts: no-sidebar, right-sidebar, left-sidebar, both-sidebars."
        },
        {
            "id": "generatepress-post-meta-items",
            "version": 1,
            "title": "GeneratePress: Choose the Post Meta Shown Under Titles",
            "description": "Sets which details appear under post titles in GeneratePress (date, author, categories, comments) in the order you want.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "posts",
                "meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Items under the title, in order.\n$wpide_gp_header_meta = array( 'date', 'categories' );\n\nadd_filter( 'generate_header_entry_meta_items', function () use ( $wpide_gp_header_meta ) {\n    return $wpide_gp_header_meta;\n} );",
            "requires": [],
            "notes": "Available items: date, author, categories, tags, comments-link. Remove an item to hide it."
        },
        {
            "id": "generatepress-remove-post-navigation",
            "version": 1,
            "title": "GeneratePress: Remove Previous/Next Links Under Posts",
            "description": "Removes the previous and next post links from the end of single posts in GeneratePress, keeping categories and tags.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "posts",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'generate_footer_entry_meta_items', function ( $items ) {\n    return array_values( array_diff( (array) $items, array( 'post-navigation' ) ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "generatepress-banner-after-header",
            "version": 1,
            "title": "GeneratePress: Notice Bar Below the Header",
            "description": "Adds a dismiss-free notice bar right below the GeneratePress header, for opening hours, sales or important updates.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "header",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Message to show below the header.\n$wpide_gp_notice = 'We are moving! From March 1 find us at <strong>12 Harbor Street</strong>.';\n\nadd_action( 'generate_after_header', function () use ( $wpide_gp_notice ) {\n    if ( '' === trim( $wpide_gp_notice ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-gp-notice grid-container\" role=\"status\" style=\"padding:10px 20px;background:#eef6ff;border-left:4px solid #1e73be;margin-top:20px;\">' . wp_kses_post( $wpide_gp_notice ) . '</div>';\n} );",
            "requires": [],
            "notes": "Basic HTML is allowed in the message; leave it empty to hide the bar."
        },
        {
            "id": "generatepress-mobile-menu-label",
            "version": 1,
            "title": "GeneratePress: Change the Mobile Menu Label",
            "description": "Changes the \"Menu\" text next to the hamburger icon in GeneratePress, or removes it so only the icon shows.",
            "category": "page-builders",
            "tags": [
                "generatepress",
                "mobile-menu",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Text next to the menu icon.\n$wpide_gp_menu_label = 'Browse';\n\nadd_filter( 'generate_mobile_menu_label', function () use ( $wpide_gp_menu_label ) {\n    return esc_html( $wpide_gp_menu_label );\n} );",
            "requires": [],
            "notes": "Set the label to an empty string to show the icon only; screen readers still announce the button."
        },
        {
            "id": "kadence-bar-above-header",
            "version": 1,
            "title": "Kadence: Message Bar Above the Header",
            "description": "Adds a slim announcement bar above the Kadence theme header on every page, using Kadence's own header hook.",
            "category": "page-builders",
            "tags": [
                "kadence",
                "header",
                "banner"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Message to show above the header.\n$wpide_kadence_bar = 'Holiday orders: order by <strong>Dec 18</strong> for delivery before Christmas.';\n\nadd_action( 'kadence_before_header', function () use ( $wpide_kadence_bar ) {\n    if ( '' === trim( $wpide_kadence_bar ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-kadence-bar\" style=\"background:#1f2937;color:#fff;text-align:center;font-size:14px;padding:8px 16px;\">' . wp_kses_post( $wpide_kadence_bar ) . '</div>';\n} );",
            "requires": [],
            "notes": "Leave the message empty to hide it. Edit the colors in the inline style."
        },
        {
            "id": "kadence-prefooter-cta",
            "version": 1,
            "title": "Kadence: Call-to-Action Band Above the Footer",
            "description": "Adds a full-width call-to-action band above the footer on every page of the Kadence theme, with a heading and button you set at the top.",
            "category": "page-builders",
            "tags": [
                "kadence",
                "footer",
                "cta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// CTA content and pages to skip.\n$wpide_kadence_cta      = array( 'heading' => 'Ready to start your project?', 'button' => 'Get a quote', 'url' => '/contact/' );\n$wpide_kadence_cta_skip = array();\n\nadd_action( 'kadence_before_footer', function () use ( $wpide_kadence_cta, $wpide_kadence_cta_skip ) {\n    if ( $wpide_kadence_cta_skip && is_page( $wpide_kadence_cta_skip ) ) {\n        return;\n    }\n\n    printf(\n        '<section class=\"wpide-kadence-cta\" style=\"padding:48px 20px;text-align:center;background:var(--global-palette1,#2b6cb0);color:#fff;\"><h2 style=\"color:inherit;margin:0 0 16px;\">%1$s</h2><a class=\"button\" href=\"%2$s\" style=\"background:#fff;color:#111;\">%3$s</a></section>',\n        esc_html( $wpide_kadence_cta['heading'] ),\n        esc_url( $wpide_kadence_cta['url'] ),\n        esc_html( $wpide_kadence_cta['button'] )\n    );\n} );",
            "requires": [],
            "notes": "Add page IDs to $wpide_kadence_cta_skip to hide the band on those pages (for example the contact page)."
        },
        {
            "id": "oceanwp-full-width-post-types",
            "version": 1,
            "title": "OceanWP: Full-Width Layout for Selected Post Types",
            "description": "Makes the post types you list use OceanWP's full-width layout without a sidebar, for shops, portfolios or courses.",
            "category": "page-builders",
            "tags": [
                "oceanwp",
                "layout",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that should be full width.\n$wpide_ocean_full_types = array( 'product', 'portfolio' );\n\nadd_filter( 'ocean_post_layout_class', function ( $class ) use ( $wpide_ocean_full_types ) {\n    if ( is_singular( $wpide_ocean_full_types ) || is_post_type_archive( $wpide_ocean_full_types ) ) {\n        return 'full-width';\n    }\n\n    return $class;\n} );",
            "requires": [],
            "notes": "OceanWP layout values: full-width, full-screen, left-sidebar, right-sidebar, both-sidebars."
        },
        {
            "id": "oceanwp-hide-page-header-on-pages",
            "version": 1,
            "title": "OceanWP: Hide the Page Title Bar on Pages",
            "description": "Hides the page header (title and breadcrumb bar) that OceanWP shows at the top of pages, so designed pages start right under the menu.",
            "category": "page-builders",
            "tags": [
                "oceanwp",
                "page-header",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'ocean_display_page_header', function ( $display ) {\n    return is_page() ? false : $display;\n} );",
            "requires": [],
            "notes": "Change is_page() to another condition, for example is_singular( 'product' ), to target other content."
        },
        {
            "id": "oceanwp-hide-top-bar-on-pages",
            "version": 1,
            "title": "OceanWP: Hide the Top Bar on Selected Pages",
            "description": "Hides OceanWP's top bar on the pages you list, such as checkout or landing pages, while keeping it on the rest of the site.",
            "category": "page-builders",
            "tags": [
                "oceanwp",
                "top-bar",
                "header"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Pages without the top bar.\n$wpide_ocean_no_topbar = array( 'checkout', 'landing' );\n\nadd_filter( 'ocean_display_top_bar', function ( $display ) use ( $wpide_ocean_no_topbar ) {\n    return is_page( $wpide_ocean_no_topbar ) ? false : $display;\n} );",
            "requires": [],
            "notes": "Use page IDs or slugs."
        },
        {
            "id": "oceanwp-notice-after-header",
            "version": 1,
            "title": "OceanWP: Notice Bar Below the Header",
            "description": "Shows a notice bar right after the OceanWP header on every page, for announcements that should be seen but not block the menu.",
            "category": "page-builders",
            "tags": [
                "oceanwp",
                "header",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Message to show.\n$wpide_ocean_notice = 'New: same-day pickup is now available in store.';\n\nadd_action( 'ocean_after_header', function () use ( $wpide_ocean_notice ) {\n    if ( '' === trim( $wpide_ocean_notice ) ) {\n        return;\n    }\n\n    echo '<div class=\"wpide-ocean-notice\" role=\"status\" style=\"background:#13aff0;color:#fff;text-align:center;padding:10px 16px;\">' . wp_kses_post( $wpide_ocean_notice ) . '</div>';\n} );",
            "requires": [],
            "notes": "Leave the message empty to hide it."
        },
        {
            "id": "child-theme-load-parent-style",
            "version": 1,
            "title": "Load the Parent Theme Stylesheet in a Child Theme",
            "description": "Loads the parent theme's style.css before the child theme's own stylesheet, for child themes created without the enqueue code in functions.php.",
            "category": "page-builders",
            "tags": [
                "child-theme",
                "themes",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! is_child_theme() || wp_style_is( 'parent-style', 'enqueued' ) ) {\n        return;\n    }\n\n    $parent = wp_get_theme( get_template() );\n\n    wp_enqueue_style( 'parent-style', get_template_directory_uri() . '/style.css', array(), $parent->get( 'Version' ) );\n\n    // Make sure the child stylesheet loads after the parent one.\n    $child = wp_styles()->query( get_stylesheet(), 'registered' ) ? get_stylesheet() : 'child-style';\n\n    if ( wp_styles()->query( $child, 'registered' ) ) {\n        wp_styles()->registered[ $child ]->deps[] = 'parent-style';\n    } else {\n        wp_enqueue_style( 'child-style', get_stylesheet_uri(), array( 'parent-style' ), wp_get_theme()->get( 'Version' ) );\n    }\n}, 5 );",
            "requires": [],
            "notes": "Does nothing when no child theme is active or the parent stylesheet is already loaded."
        },
        {
            "id": "menu-item-descriptions",
            "version": 1,
            "title": "Show Menu Item Descriptions in Navigation Menus",
            "description": "Prints the description you write for each menu item under its title in classic theme menus, handy for mega menus and service menus.",
            "category": "page-builders",
            "tags": [
                "menus",
                "navigation",
                "classic-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Menu locations that should show descriptions (empty = all).\n$wpide_menu_desc_locations = array( 'primary' );\n\nadd_filter( 'walker_nav_menu_start_el', function ( $item_output, $item, $depth, $args ) use ( $wpide_menu_desc_locations ) {\n    if ( empty( $item->description ) ) {\n        return $item_output;\n    }\n\n    if ( $wpide_menu_desc_locations && ( empty( $args->theme_location ) || ! in_array( $args->theme_location, $wpide_menu_desc_locations, true ) ) ) {\n        return $item_output;\n    }\n\n    $description = '<span class=\"menu-item-description\" style=\"display:block;font-size:.8em;opacity:.75;font-weight:normal;\">' . esc_html( $item->description ) . '</span>';\n\n    return preg_replace( '#</a>\\s*$#', $description . '</a>', $item_output, 1 );\n}, 10, 4 );",
            "requires": [],
            "notes": "Turn on \"Description\" under Screen Options in Appearance > Menus to edit descriptions. Limit it with $wpide_menu_desc_locations."
        },
        {
            "id": "logged-in-visitors-menu",
            "version": 1,
            "title": "Different Menu for Logged-In Visitors",
            "description": "Shows another navigation menu to logged-in users in the same theme location, for example with \"My account\" and \"Log out\" instead of \"Sign up\".",
            "category": "page-builders",
            "tags": [
                "menus",
                "members",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Theme location to swap and the menu to show to logged-in users.\n$wpide_member_menu_location = 'primary';\n$wpide_member_menu          = 'Members Menu';\n\nadd_filter( 'wp_nav_menu_args', function ( $args ) use ( $wpide_member_menu_location, $wpide_member_menu ) {\n    if ( ! is_user_logged_in() || empty( $args['theme_location'] ) || $wpide_member_menu_location !== $args['theme_location'] ) {\n        return $args;\n    }\n\n    if ( wp_get_nav_menu_object( $wpide_member_menu ) ) {\n        $args['menu'] = $wpide_member_menu;\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Create the menu under Appearance > Menus and put its name or slug in $wpide_member_menu. Works with classic themes."
        },
        {
            "id": "menu-current-parent-for-post-types",
            "version": 1,
            "title": "Highlight the Right Menu Item on Custom Post Type Pages",
            "description": "Marks a menu item as current when visitors view a single item of a custom post type, so the \"Portfolio\" or \"Events\" link stays highlighted instead of \"Blog\".",
            "category": "page-builders",
            "tags": [
                "menus",
                "post-types",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => path of the menu item to highlight.\n$wpide_menu_parent_map = array(\n    'portfolio' => '/portfolio/',\n    'event'     => '/events/',\n);\n\nadd_filter( 'nav_menu_css_class', function ( $classes, $item ) use ( $wpide_menu_parent_map ) {\n    $type = is_singular() ? get_post_type() : '';\n\n    if ( ! $type || ! isset( $wpide_menu_parent_map[ $type ] ) || empty( $item->url ) ) {\n        return $classes;\n    }\n\n    // Stop the blog page from looking active.\n    $classes = array_diff( $classes, array( 'current_page_parent' ) );\n\n    $path = wp_parse_url( $item->url, PHP_URL_PATH );\n\n    if ( $path && untrailingslashit( $path ) === untrailingslashit( $wpide_menu_parent_map[ $type ] ) ) {\n        $classes[] = 'current-menu-ancestor';\n        $classes[] = 'current_page_parent';\n    }\n\n    return array_values( $classes );\n}, 10, 2 );",
            "requires": [],
            "notes": "Map each post type to the URL path of the menu item that should light up."
        },
        {
            "id": "admin-theme-preview",
            "version": 1,
            "title": "Preview Another Theme as an Administrator",
            "description": "Lets administrators view the site with another installed theme by adding ?wpide_theme=theme-folder to any address, while visitors keep seeing the active theme.",
            "category": "page-builders",
            "tags": [
                "themes",
                "preview",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_preview_theme = function () {\n    static $theme = null;\n\n    if ( null !== $theme ) {\n        return $theme;\n    }\n\n    $theme = false;\n\n    if ( empty( $_GET['wpide_theme'] ) || ! function_exists( 'wp_get_current_user' ) || ! current_user_can( 'switch_themes' ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return $theme;\n    }\n\n    $candidate = wp_get_theme( sanitize_file_name( wp_unslash( $_GET['wpide_theme'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n\n    if ( $candidate->exists() && ! $candidate->errors() ) {\n        $theme = $candidate;\n    }\n\n    return $theme;\n};\n\nadd_action( 'setup_theme', function () use ( $wpide_preview_theme ) {\n    add_filter( 'stylesheet', function ( $stylesheet ) use ( $wpide_preview_theme ) {\n        $theme = $wpide_preview_theme();\n        return $theme ? $theme->get_stylesheet() : $stylesheet;\n    } );\n\n    add_filter( 'template', function ( $template ) use ( $wpide_preview_theme ) {\n        $theme = $wpide_preview_theme();\n        return $theme ? $theme->get_template() : $template;\n    } );\n}, 1 );",
            "requires": [],
            "notes": "Use the theme's folder name, for example ?wpide_theme=twentytwentyfour. The choice lasts for that page view only."
        },
        {
            "id": "classic-theme-editor-styles",
            "version": 1,
            "title": "Make the Editor Match Your Classic Theme",
            "description": "Loads an editor-style.css file from your (child) theme into the block editor and turns on editor styles, so posts look the same while writing as on the site.",
            "category": "page-builders",
            "tags": [
                "classic-themes",
                "editor",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// File name inside the active theme folder.\n$wpide_editor_style_file = 'editor-style.css';\n\nadd_action( 'after_setup_theme', function () use ( $wpide_editor_style_file ) {\n    if ( wp_is_block_theme() ) {\n        return;\n    }\n\n    if ( ! file_exists( get_stylesheet_directory() . '/' . $wpide_editor_style_file ) ) {\n        return;\n    }\n\n    add_theme_support( 'editor-styles' );\n    add_editor_style( $wpide_editor_style_file );\n}, 20 );",
            "requires": [],
            "notes": "Create editor-style.css in your theme folder with the typography and colors you want in the editor. Block themes do this automatically."
        },
        {
            "id": "classic-theme-custom-logo-support",
            "version": 1,
            "title": "Add Logo Upload to Themes That Lack It",
            "description": "Adds the \"Logo\" option to the Customizer for older classic themes that do not support a custom logo, with flexible width and height.",
            "category": "page-builders",
            "tags": [
                "classic-themes",
                "logo",
                "customizer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'after_setup_theme', function () {\n    if ( current_theme_supports( 'custom-logo' ) ) {\n        return;\n    }\n\n    add_theme_support( 'custom-logo', array(\n        'height'      => 120,\n        'width'       => 400,\n        'flex-height' => true,\n        'flex-width'  => true,\n        'unlink-homepage-logo' => true,\n    ) );\n}, 20 );",
            "requires": [],
            "notes": "Your theme still needs to print the logo, for example with the_custom_logo() in header.php."
        },
        {
            "id": "pattern-pricing-three-plans",
            "version": 1,
            "title": "Block Pattern: Three-Plan Pricing Table",
            "description": "Adds a \"Pricing: three plans\" pattern to the block inserter with a heading, three plan columns, feature lists and buttons that follow your theme's styles.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "pricing",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $plan = function ( $name, $price, $features, $button ) {\n        $items = '';\n        foreach ( $features as $feature ) {\n            $items .= '<!-- wp:list-item --><li>' . esc_html( $feature ) . '</li><!-- /wp:list-item -->';\n        }\n\n        return '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">' . esc_html( $name ) . '</h3><!-- /wp:heading -->'\n            . '<!-- wp:paragraph --><p><strong>' . esc_html( $price ) . '</strong> / month</p><!-- /wp:paragraph -->'\n            . '<!-- wp:list --><ul class=\"wp-block-list\">' . $items . '</ul><!-- /wp:list -->'\n            . '<!-- wp:buttons --><div class=\"wp-block-buttons\"><!-- wp:button --><div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"#\">' . esc_html( $button ) . '</a></div><!-- /wp:button --></div><!-- /wp:buttons --></div><!-- /wp:column -->';\n    };\n\n    $content = '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\">'\n        . '<!-- wp:heading {\"textAlign\":\"center\"} --><h2 class=\"wp-block-heading has-text-align-center\">Simple, honest pricing</h2><!-- /wp:heading -->'\n        . '<!-- wp:paragraph {\"align\":\"center\"} --><p class=\"has-text-align-center\">Pick the plan that fits. Upgrade or cancel at any time.</p><!-- /wp:paragraph -->'\n        . '<!-- wp:columns --><div class=\"wp-block-columns\">'\n        . $plan( 'Starter', '$19', array( '1 website', 'Email support', 'Basic reports' ), 'Choose Starter' )\n        . $plan( 'Growth', '$49', array( '5 websites', 'Priority support', 'Advanced reports' ), 'Choose Growth' )\n        . $plan( 'Agency', '$99', array( 'Unlimited websites', 'Phone support', 'White-label reports' ), 'Choose Agency' )\n        . '</div><!-- /wp:columns --></div><!-- /wp:group -->';\n\n    register_block_pattern( 'wpide/pricing-three-plans', array(\n        'title'         => 'Pricing: three plans',\n        'description'   => 'Three pricing columns with features and buttons.',\n        'categories'    => array( 'wpide-sections' ),\n        'keywords'      => array( 'pricing', 'plans', 'table' ),\n        'viewportWidth' => 1200,\n        'content'       => $content,\n    ) );\n} );",
            "requires": [],
            "notes": "Find it in the inserter under Patterns > Site Sections. Edit the plans and prices after inserting."
        },
        {
            "id": "pattern-faq-details",
            "version": 1,
            "title": "Block Pattern: FAQ With Expandable Answers",
            "description": "Adds an FAQ pattern built from core Details blocks, so visitors click a question to reveal its answer, with no plugin and no JavaScript.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "faq",
                "details",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $questions = array(\n        'How long does delivery take?'    => 'Most orders arrive within 3 to 5 working days.',\n        'Can I change my order?'          => 'Yes, reply to your confirmation email within 24 hours and we will update it.',\n        'Do you ship internationally?'    => 'We ship to most countries in Europe and North America.',\n        'What is your returns policy?'    => 'Return any unused item within 30 days for a full refund.',\n    );\n\n    $items = '';\n    foreach ( $questions as $question => $answer ) {\n        $items .= '<!-- wp:details --><details class=\"wp-block-details\"><summary>' . esc_html( $question ) . '</summary><!-- wp:paragraph --><p>' . esc_html( $answer ) . '</p><!-- /wp:paragraph --></details><!-- /wp:details -->';\n    }\n\n    register_block_pattern( 'wpide/faq-details', array(\n        'title'       => 'FAQ: expandable answers',\n        'description' => 'Frequently asked questions using Details blocks.',\n        'categories'  => array( 'wpide-sections' ),\n        'keywords'    => array( 'faq', 'questions', 'accordion' ),\n        'content'     => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading --><h2 class=\"wp-block-heading\">Frequently asked questions</h2><!-- /wp:heading -->' . $items . '</div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Add or remove questions by duplicating a Details block in the editor."
        },
        {
            "id": "pattern-testimonials-three",
            "version": 1,
            "title": "Block Pattern: Three Testimonials",
            "description": "Adds a testimonials pattern with three customer quotes side by side, built from core Quote blocks so it inherits your theme's quote styling.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "testimonials",
                "quotes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $quotes = array(\n        array( 'They rebuilt our site in two weeks and sales went up the month after.', 'Maria L., bakery owner' ),\n        array( 'Clear communication, fair prices and a result our whole team loves.', 'Tom R., operations lead' ),\n        array( 'The best support we have had from any agency. Highly recommended.', 'Aisha K., founder' ),\n    );\n\n    $columns = '';\n    foreach ( $quotes as $quote ) {\n        $columns .= '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:quote --><blockquote class=\"wp-block-quote\"><!-- wp:paragraph --><p>' . esc_html( $quote[0] ) . '</p><!-- /wp:paragraph --><cite>' . esc_html( $quote[1] ) . '</cite></blockquote><!-- /wp:quote --></div><!-- /wp:column -->';\n    }\n\n    register_block_pattern( 'wpide/testimonials-three', array(\n        'title'         => 'Testimonials: three quotes',\n        'description'   => 'Three customer quotes in columns.',\n        'categories'    => array( 'wpide-sections' ),\n        'keywords'      => array( 'testimonials', 'reviews', 'quotes' ),\n        'viewportWidth' => 1200,\n        'content'       => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading {\"textAlign\":\"center\"} --><h2 class=\"wp-block-heading has-text-align-center\">What our clients say</h2><!-- /wp:heading --><!-- wp:columns --><div class=\"wp-block-columns\">' . $columns . '</div><!-- /wp:columns --></div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "The columns stack automatically on phones."
        },
        {
            "id": "pattern-how-it-works-steps",
            "version": 1,
            "title": "Block Pattern: \"How It Works\" in Three Steps",
            "description": "Adds a pattern with a heading and three numbered steps in columns, a common section for service pages and onboarding.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "steps",
                "services"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $steps = array(\n        '1. Tell us what you need' => 'Fill in a short form or book a free 20-minute call.',\n        '2. Get a clear proposal'   => 'You receive a fixed price and timeline within two days.',\n        '3. We deliver'             => 'We keep you updated weekly until launch day.',\n    );\n\n    $columns = '';\n    foreach ( $steps as $title => $text ) {\n        $columns .= '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">' . esc_html( $title ) . '</h3><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html( $text ) . '</p><!-- /wp:paragraph --></div><!-- /wp:column -->';\n    }\n\n    register_block_pattern( 'wpide/how-it-works', array(\n        'title'         => 'How it works: three steps',\n        'description'   => 'Three numbered steps in columns.',\n        'categories'    => array( 'wpide-sections' ),\n        'keywords'      => array( 'steps', 'process', 'how it works' ),\n        'viewportWidth' => 1200,\n        'content'       => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading --><h2 class=\"wp-block-heading\">How it works</h2><!-- /wp:heading --><!-- wp:columns --><div class=\"wp-block-columns\">' . $columns . '</div><!-- /wp:columns --></div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pattern-cta-two-buttons",
            "version": 1,
            "title": "Block Pattern: Call to Action With Two Buttons",
            "description": "Adds a centered call-to-action pattern with a heading, a sentence and a main button plus an outline button, for the end of landing and service pages.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "cta",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $content = '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\">'\n        . '<!-- wp:heading {\"textAlign\":\"center\"} --><h2 class=\"wp-block-heading has-text-align-center\">Ready when you are</h2><!-- /wp:heading -->'\n        . '<!-- wp:paragraph {\"align\":\"center\"} --><p class=\"has-text-align-center\">Start your free 14-day trial. No credit card needed.</p><!-- /wp:paragraph -->'\n        . '<!-- wp:buttons {\"layout\":{\"type\":\"flex\",\"justifyContent\":\"center\"}} --><div class=\"wp-block-buttons\">'\n        . '<!-- wp:button --><div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"#\">Start free trial</a></div><!-- /wp:button -->'\n        . '<!-- wp:button {\"className\":\"is-style-outline\"} --><div class=\"wp-block-button is-style-outline\"><a class=\"wp-block-button__link wp-element-button\" href=\"#\">Talk to sales</a></div><!-- /wp:button -->'\n        . '</div><!-- /wp:buttons --></div><!-- /wp:group -->';\n\n    register_block_pattern( 'wpide/cta-two-buttons', array(\n        'title'       => 'Call to action: two buttons',\n        'description' => 'Centered heading, text and two buttons.',\n        'categories'  => array( 'wpide-sections', 'call-to-action' ),\n        'keywords'    => array( 'cta', 'signup', 'buttons' ),\n        'content'     => $content,\n    ) );\n} );",
            "requires": [],
            "notes": "Set a background color on the Group block after inserting to make the section stand out."
        },
        {
            "id": "pattern-feature-grid",
            "version": 1,
            "title": "Block Pattern: Six-Feature Grid",
            "description": "Adds a pattern with a heading and six short feature descriptions in two rows of three columns, for product and service overviews.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "features",
                "grid"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $features = array(\n        'Fast setup'       => 'Be up and running in minutes, not days.',\n        'Secure by default' => 'Encrypted data and daily off-site backups.',\n        'Friendly support'  => 'Real people answer within one business day.',\n        'Works everywhere'  => 'Looks great on phones, tablets and desktops.',\n        'Fair pricing'      => 'One simple plan, no hidden costs.',\n        'Easy to leave'     => 'Export everything whenever you like.',\n    );\n\n    $rows = '';\n    foreach ( array_chunk( $features, 3, true ) as $chunk ) {\n        $rows .= '<!-- wp:columns --><div class=\"wp-block-columns\">';\n        foreach ( $chunk as $title => $text ) {\n            $rows .= '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">' . esc_html( $title ) . '</h3><!-- /wp:heading --><!-- wp:paragraph --><p>' . esc_html( $text ) . '</p><!-- /wp:paragraph --></div><!-- /wp:column -->';\n        }\n        $rows .= '</div><!-- /wp:columns -->';\n    }\n\n    register_block_pattern( 'wpide/feature-grid', array(\n        'title'         => 'Features: six-item grid',\n        'description'   => 'Two rows of three short features.',\n        'categories'    => array( 'wpide-sections' ),\n        'keywords'      => array( 'features', 'benefits', 'grid' ),\n        'viewportWidth' => 1200,\n        'content'       => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading {\"textAlign\":\"center\"} --><h2 class=\"wp-block-heading has-text-align-center\">Why people choose us</h2><!-- /wp:heading -->' . $rows . '</div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pattern-stats-row",
            "version": 1,
            "title": "Block Pattern: Key Numbers Row",
            "description": "Adds a row of four big numbers with short labels (customers, years, rating, countries), a quick way to show social proof on any page.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "stats",
                "numbers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $stats = array( '2,400+' => 'Happy customers', '12' => 'Years in business', '4.9/5' => 'Average rating', '30' => 'Countries served' );\n\n    $columns = '';\n    foreach ( $stats as $number => $label ) {\n        $columns .= '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"textAlign\":\"center\",\"level\":3} --><h3 class=\"wp-block-heading has-text-align-center\">' . esc_html( $number ) . '</h3><!-- /wp:heading --><!-- wp:paragraph {\"align\":\"center\"} --><p class=\"has-text-align-center\">' . esc_html( $label ) . '</p><!-- /wp:paragraph --></div><!-- /wp:column -->';\n    }\n\n    register_block_pattern( 'wpide/stats-row', array(\n        'title'         => 'Key numbers: four stats',\n        'description'   => 'Four large numbers with labels.',\n        'categories'    => array( 'wpide-sections' ),\n        'keywords'      => array( 'stats', 'numbers', 'counter' ),\n        'viewportWidth' => 1200,\n        'content'       => '<!-- wp:columns {\"isStackedOnMobile\":false} --><div class=\"wp-block-columns is-not-stacked-on-mobile\">' . $columns . '</div><!-- /wp:columns -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Pair it with the animated counter snippet if you want the numbers to count up."
        },
        {
            "id": "pattern-contact-details",
            "version": 1,
            "title": "Block Pattern: Contact Details and Opening Hours",
            "description": "Adds a two-column contact section with address, phone and email on one side and opening hours on the other, ready to fill in.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "contact",
                "opening-hours"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $content = '<!-- wp:columns --><div class=\"wp-block-columns\">'\n        . '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">Visit or call us</h3><!-- /wp:heading -->'\n        . '<!-- wp:paragraph --><p>12 Harbor Street<br>Springfield, 01234</p><!-- /wp:paragraph -->'\n        . '<!-- wp:paragraph --><p><a href=\"tel:+15550100199\">+1 555 010 0199</a><br><a href=\"mailto:hello@example.com\">hello@example.com</a></p><!-- /wp:paragraph --></div><!-- /wp:column -->'\n        . '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">Opening hours</h3><!-- /wp:heading -->'\n        . '<!-- wp:list --><ul class=\"wp-block-list\"><!-- wp:list-item --><li>Monday to Friday: 9:00 to 17:30</li><!-- /wp:list-item --><!-- wp:list-item --><li>Saturday: 10:00 to 14:00</li><!-- /wp:list-item --><!-- wp:list-item --><li>Sunday: closed</li><!-- /wp:list-item --></ul><!-- /wp:list --></div><!-- /wp:column -->'\n        . '</div><!-- /wp:columns -->';\n\n    register_block_pattern( 'wpide/contact-details', array(\n        'title'       => 'Contact details and hours',\n        'description' => 'Address, phone, email and opening hours in two columns.',\n        'categories'  => array( 'wpide-sections' ),\n        'keywords'    => array( 'contact', 'address', 'hours' ),\n        'content'     => $content,\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pattern-404-helpful",
            "version": 1,
            "title": "Block Pattern: Helpful 404 Page Content",
            "description": "Adds a friendly 404 pattern with a message, a search box and a button home, offered when you edit the 404 template in the Site Editor.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "404",
                "site-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    $content = '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\">'\n        . '<!-- wp:heading {\"textAlign\":\"center\",\"level\":1} --><h1 class=\"wp-block-heading has-text-align-center\">We could not find that page</h1><!-- /wp:heading -->'\n        . '<!-- wp:paragraph {\"align\":\"center\"} --><p class=\"has-text-align-center\">The link may be old or mistyped. Try a search, or head back to the homepage.</p><!-- /wp:paragraph -->'\n        . '<!-- wp:search {\"label\":\"Search\",\"showLabel\":false,\"buttonText\":\"Search\"} /-->'\n        . '<!-- wp:buttons {\"layout\":{\"type\":\"flex\",\"justifyContent\":\"center\"}} --><div class=\"wp-block-buttons\"><!-- wp:button --><div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"/\">Back to the homepage</a></div><!-- /wp:button --></div><!-- /wp:buttons -->'\n        . '</div><!-- /wp:group -->';\n\n    register_block_pattern( 'wpide/404-helpful', array(\n        'title'         => '404: helpful message with search',\n        'description'   => 'Friendly not-found message, search box and home button.',\n        'categories'    => array( 'text' ),\n        'keywords'      => array( '404', 'not found', 'error' ),\n        'templateTypes' => array( '404' ),\n        'content'       => $content,\n    ) );\n} );",
            "requires": [],
            "notes": "Open Appearance > Editor > Templates > Page: 404 and pick it from the patterns offered for that template."
        },
        {
            "id": "pattern-starter-about-page",
            "version": 1,
            "title": "Starter Layout for New Pages: About Us",
            "description": "Offers an \"About us\" layout in the starter patterns window that opens when you create a new page, so editors start from a structure instead of a blank page.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "starter",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    $content = '<!-- wp:heading {\"level\":1} --><h1 class=\"wp-block-heading\">About us</h1><!-- /wp:heading -->'\n        . '<!-- wp:paragraph --><p>Start with one or two sentences about who you are and who you help.</p><!-- /wp:paragraph -->'\n        . '<!-- wp:heading --><h2 class=\"wp-block-heading\">Our story</h2><!-- /wp:heading -->'\n        . '<!-- wp:paragraph --><p>Tell how the business started, what problem you solve and what makes you different.</p><!-- /wp:paragraph -->'\n        . '<!-- wp:heading --><h2 class=\"wp-block-heading\">What we believe</h2><!-- /wp:heading -->'\n        . '<!-- wp:list --><ul class=\"wp-block-list\"><!-- wp:list-item --><li>A value that guides your work</li><!-- /wp:list-item --><!-- wp:list-item --><li>A promise to your customers</li><!-- /wp:list-item --><!-- wp:list-item --><li>Something you never compromise on</li><!-- /wp:list-item --></ul><!-- /wp:list -->'\n        . '<!-- wp:buttons --><div class=\"wp-block-buttons\"><!-- wp:button --><div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"/contact/\">Get in touch</a></div><!-- /wp:button --></div><!-- /wp:buttons -->';\n\n    register_block_pattern( 'wpide/starter-about', array(\n        'title'       => 'About us page',\n        'description' => 'Starter structure for an About page.',\n        'categories'  => array( 'about' ),\n        'blockTypes'  => array( 'core/post-content' ),\n        'postTypes'   => array( 'page' ),\n        'content'     => $content,\n    ) );\n} );",
            "requires": [],
            "notes": "Starter layouts appear when creating a page; they need the starter patterns window to be enabled."
        },
        {
            "id": "pattern-history-timeline",
            "version": 1,
            "title": "Block Pattern: Company History Timeline",
            "description": "Adds a timeline pattern with a year column and a description column for each milestone, for About and history pages.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "timeline",
                "about"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $milestones = array(\n        '2014' => 'Founded in a small garage with two people and one big idea.',\n        '2018' => 'Opened our first office and welcomed our 1,000th customer.',\n        '2023' => 'Launched internationally and grew the team to 40 people.',\n    );\n\n    $rows = '';\n    foreach ( $milestones as $year => $text ) {\n        $rows .= '<!-- wp:columns --><div class=\"wp-block-columns\"><!-- wp:column {\"width\":\"25%\"} --><div class=\"wp-block-column\" style=\"flex-basis:25%\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">' . esc_html( $year ) . '</h3><!-- /wp:heading --></div><!-- /wp:column --><!-- wp:column {\"width\":\"75%\"} --><div class=\"wp-block-column\" style=\"flex-basis:75%\"><!-- wp:paragraph --><p>' . esc_html( $text ) . '</p><!-- /wp:paragraph --></div><!-- /wp:column --></div><!-- /wp:columns -->';\n    }\n\n    register_block_pattern( 'wpide/history-timeline', array(\n        'title'       => 'Timeline: company history',\n        'description' => 'Milestones with a year and a description.',\n        'categories'  => array( 'wpide-sections', 'about' ),\n        'keywords'    => array( 'timeline', 'history', 'milestones' ),\n        'content'     => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading --><h2 class=\"wp-block-heading\">Our journey</h2><!-- /wp:heading -->' . $rows . '</div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Duplicate a row (Columns block) to add more milestones."
        },
        {
            "id": "remove-unused-pattern-categories",
            "version": 1,
            "title": "Remove Pattern Categories You Do Not Use",
            "description": "Removes the pattern categories you list (such as Gallery, Media or Portfolio) from the block inserter, so the pattern list is shorter and easier to scan.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "inserter",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Pattern category slugs to remove.\n$wpide_pattern_categories_to_remove = array( 'gallery', 'media', 'portfolio', 'team' );\n\nadd_action( 'init', function () use ( $wpide_pattern_categories_to_remove ) {\n    if ( ! class_exists( 'WP_Block_Pattern_Categories_Registry' ) ) {\n        return;\n    }\n\n    $registry = WP_Block_Pattern_Categories_Registry::get_instance();\n\n    foreach ( $wpide_pattern_categories_to_remove as $slug ) {\n        if ( $registry->is_registered( $slug ) ) {\n            unregister_block_pattern_category( $slug );\n        }\n    }\n}, 999 );",
            "requires": [],
            "notes": "Category slugs include buttons, columns, gallery, header, footer, text, query, featured, call-to-action, team, testimonials, contact, about, portfolio, media, posts, banner."
        },
        {
            "id": "block-styles-headings",
            "version": 1,
            "title": "Heading Block Styles: Eyebrow and Accent Underline",
            "description": "Adds two style options to the Heading block: a small uppercase \"eyebrow\" label for section kickers, and a heading with a short accent line underneath.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "headings",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/heading', array(\n        'name'         => 'wpide-eyebrow',\n        'label'        => 'Eyebrow',\n        'inline_style' => '.wp-block-heading.is-style-wpide-eyebrow{font-size:.8rem;font-weight:700;letter-spacing:.12em;text-transform:uppercase;opacity:.8;margin-bottom:.5em;}',\n    ) );\n\n    register_block_style( 'core/heading', array(\n        'name'         => 'wpide-accent-line',\n        'label'        => 'Accent line',\n        'inline_style' => '.wp-block-heading.is-style-wpide-accent-line::after{content:\"\";display:block;width:3rem;height:4px;margin-top:.4em;border-radius:2px;background:currentColor;opacity:.6;}.wp-block-heading.is-style-wpide-accent-line.has-text-align-center::after{margin-left:auto;margin-right:auto;}.wp-block-heading.is-style-wpide-accent-line.has-text-align-right::after{margin-left:auto;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Pick them in the block sidebar under Styles. The accent color follows currentColor, so it matches the text color you choose."
        },
        {
            "id": "block-styles-quotes",
            "version": 1,
            "title": "Quote Block Style: Big Quotation Mark",
            "description": "Adds a \"Big quotation mark\" style to Quote and Pullquote blocks that shows a large decorative quote sign and roomier text, for testimonials and highlights.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "quotes",
                "testimonials"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $css = '.is-style-wpide-big-mark{position:relative;padding-top:2.5rem!important;border:0!important;}'\n        . '.is-style-wpide-big-mark::before{content:\"\\201C\";position:absolute;top:-.5rem;left:0;font-family:Georgia,serif;font-size:5rem;line-height:1;opacity:.25;}'\n        . '.wp-block-pullquote.is-style-wpide-big-mark::before{left:50%;transform:translateX(-50%);}'\n        . '.is-style-wpide-big-mark p{font-size:1.2em;line-height:1.5;}';\n\n    foreach ( array( 'core/quote', 'core/pullquote' ) as $block ) {\n        register_block_style( $block, array(\n            'name'         => 'wpide-big-mark',\n            'label'        => 'Big quotation mark',\n            'inline_style' => $css,\n        ) );\n    }\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-styles-tables",
            "version": 1,
            "title": "Table Block Styles: Lined Rows and Hover Highlight",
            "description": "Adds two table styles: \"Lined\" with thin row dividers and no outer borders, and \"Hover rows\" that highlights the row under the mouse for easier reading.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "tables",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/table', array(\n        'name'         => 'wpide-lined',\n        'label'        => 'Lined',\n        'inline_style' => '.wp-block-table.is-style-wpide-lined td,.wp-block-table.is-style-wpide-lined th{border:0;border-bottom:1px solid rgba(0,0,0,.12);padding:.7em .5em;}.wp-block-table.is-style-wpide-lined thead{border-bottom:2px solid currentColor;}',\n    ) );\n\n    register_block_style( 'core/table', array(\n        'name'         => 'wpide-hover-rows',\n        'label'        => 'Hover rows',\n        'inline_style' => '.wp-block-table.is-style-wpide-hover-rows tbody tr{transition:background-color .15s ease;}.wp-block-table.is-style-wpide-hover-rows tbody tr:hover{background-color:rgba(0,0,0,.05);}',\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-styles-separators",
            "version": 1,
            "title": "Separator Block Styles: Gradient and Ornament",
            "description": "Adds a fading gradient line and a centered ornament (✦) style to the Separator block, for more elegant breaks between sections.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "separator",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/separator', array(\n        'name'         => 'wpide-gradient',\n        'label'        => 'Gradient',\n        'inline_style' => '.wp-block-separator.is-style-wpide-gradient{border:0;height:2px;background:linear-gradient(90deg,transparent,currentColor,transparent);opacity:.5;}',\n    ) );\n\n    register_block_style( 'core/separator', array(\n        'name'         => 'wpide-ornament',\n        'label'        => 'Ornament',\n        'inline_style' => '.wp-block-separator.is-style-wpide-ornament{border:0;height:auto;background:none!important;text-align:center;overflow:visible;opacity:.6;}.wp-block-separator.is-style-wpide-ornament::before{content:\"\\2726  \\2726  \\2726\";letter-spacing:.5em;font-size:1rem;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Change the ornament character in the CSS content value."
        },
        {
            "id": "block-styles-images",
            "version": 1,
            "title": "Image Block Styles: Polaroid, Frame and Tilt",
            "description": "Adds three photo styles to the Image block: a white polaroid card, a thin inset frame, and a playful slight tilt that straightens on hover.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "images",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $styles = array(\n        'wpide-polaroid' => array( 'Polaroid', '.wp-block-image.is-style-wpide-polaroid img{background:#fff;padding:10px 10px 36px;box-shadow:0 4px 16px rgba(0,0,0,.15);}' ),\n        'wpide-frame'    => array( 'Frame', '.wp-block-image.is-style-wpide-frame img{padding:8px;border:1px solid rgba(0,0,0,.2);outline:1px solid rgba(0,0,0,.08);outline-offset:-5px;}' ),\n        'wpide-tilt'     => array( 'Tilt', '.wp-block-image.is-style-wpide-tilt img{transform:rotate(-2deg);transition:transform .3s ease;box-shadow:0 6px 18px rgba(0,0,0,.15);}.wp-block-image.is-style-wpide-tilt img:hover{transform:rotate(0);}@media (prefers-reduced-motion:reduce){.wp-block-image.is-style-wpide-tilt img{transition:none;}}' ),\n    );\n\n    foreach ( $styles as $name => $style ) {\n        register_block_style( 'core/image', array(\n            'name'         => $name,\n            'label'        => $style[0],\n            'inline_style' => $style[1],\n        ) );\n    }\n} );",
            "requires": [],
            "notes": "The tilt animation is skipped for visitors who prefer reduced motion."
        },
        {
            "id": "block-styles-columns-dividers",
            "version": 1,
            "title": "Columns Block Style: Dividing Lines",
            "description": "Adds a \"Dividers\" style to the Columns block that draws a thin vertical line between columns on wide screens and a horizontal one when they stack on phones.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "columns",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/columns', array(\n        'name'         => 'wpide-dividers',\n        'label'        => 'Dividers',\n        'inline_style' => '.wp-block-columns.is-style-wpide-dividers>.wp-block-column+.wp-block-column{border-top:1px solid rgba(0,0,0,.15);padding-top:1.5em;}@media (min-width:782px){.wp-block-columns.is-style-wpide-dividers>.wp-block-column+.wp-block-column{border-top:0;padding-top:0;border-left:1px solid rgba(0,0,0,.15);padding-left:2em;}}',\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "block-styles-paragraph-callouts",
            "version": 1,
            "title": "Paragraph Block Styles: Tip, Note and Warning Callouts",
            "description": "Adds three callout styles to the Paragraph block (tip, note and warning) with a colored side border and label, for documentation and tutorials.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "callouts",
                "paragraph"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Style name => label, heading text, color.\n$wpide_callouts = array(\n    'wpide-tip'     => array( 'Tip callout', 'Tip', '#00a32a' ),\n    'wpide-note'    => array( 'Note callout', 'Note', '#2271b1' ),\n    'wpide-warning' => array( 'Warning callout', 'Warning', '#d63638' ),\n);\n\nadd_action( 'init', function () use ( $wpide_callouts ) {\n    foreach ( $wpide_callouts as $name => $callout ) {\n        $color = sanitize_hex_color( $callout[2] );\n        $label = str_replace( array( '\\\\', '\"' ), '', $callout[1] );\n\n        register_block_style( 'core/paragraph', array(\n            'name'         => $name,\n            'label'        => $callout[0],\n            'inline_style' => \"p.is-style-{$name}{border-left:4px solid {$color};background:color-mix(in srgb,{$color} 8%,transparent);padding:.9em 1.1em;border-radius:4px;}p.is-style-{$name}::before{content:\\\"{$label}\\\";display:block;font-weight:700;font-size:.85em;text-transform:uppercase;letter-spacing:.06em;color:{$color};margin-bottom:.2em;}\",\n        ) );\n    }\n} );",
            "requires": [],
            "notes": "Edit the label text or colors in the $wpide_callouts array."
        },
        {
            "id": "block-styles-lists",
            "version": 1,
            "title": "List Block Styles: Arrows and Numbered Steps",
            "description": "Adds an \"Arrows\" style for bullet lists and a \"Steps\" style that turns numbered lists into large numbered circles, for features and how-to instructions.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "lists",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/list', array(\n        'name'         => 'wpide-arrows',\n        'label'        => 'Arrows',\n        'inline_style' => '.wp-block-list.is-style-wpide-arrows{list-style:none;padding-left:0;}.wp-block-list.is-style-wpide-arrows li{padding-left:1.6em;position:relative;}.wp-block-list.is-style-wpide-arrows li::before{content:\"\\2192\";position:absolute;left:0;font-weight:700;}',\n    ) );\n\n    register_block_style( 'core/list', array(\n        'name'         => 'wpide-steps',\n        'label'        => 'Steps',\n        'inline_style' => '.wp-block-list.is-style-wpide-steps{list-style:none;padding-left:0;counter-reset:wpide-step;}.wp-block-list.is-style-wpide-steps>li{counter-increment:wpide-step;position:relative;padding-left:3em;min-height:2.2em;margin-bottom:1em;}.wp-block-list.is-style-wpide-steps>li::before{content:counter(wpide-step);position:absolute;left:0;top:-.1em;width:2.2em;height:2.2em;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;border:2px solid currentColor;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Use \"Steps\" on a numbered list (ordered list) for the counters to make sense."
        },
        {
            "id": "block-style-navigation-underline",
            "version": 1,
            "title": "Navigation Block Style: Animated Underline on Hover",
            "description": "Adds an \"Underline on hover\" style to the Navigation block, where a line slides in under menu links on hover and stays under the current page link.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "navigation",
                "menus"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/navigation', array(\n        'name'         => 'wpide-underline',\n        'label'        => 'Underline on hover',\n        'inline_style' => '.wp-block-navigation.is-style-wpide-underline .wp-block-navigation-item__content{text-decoration:none;background-image:linear-gradient(currentColor,currentColor);background-size:0 2px;background-position:0 100%;background-repeat:no-repeat;transition:background-size .25s ease;}.wp-block-navigation.is-style-wpide-underline .wp-block-navigation-item__content:hover,.wp-block-navigation.is-style-wpide-underline .wp-block-navigation-item__content:focus-visible,.wp-block-navigation.is-style-wpide-underline .current-menu-item>.wp-block-navigation-item__content{background-size:100% 2px;}@media (prefers-reduced-motion:reduce){.wp-block-navigation.is-style-wpide-underline .wp-block-navigation-item__content{transition:none;}}',\n    ) );\n} );",
            "requires": [],
            "notes": "The slide animation is turned off for visitors who prefer reduced motion."
        },
        {
            "id": "section-styles-dark-accent",
            "version": 1,
            "title": "Section Styles: Dark and Soft Accent Sections",
            "description": "Adds \"Dark section\" and \"Soft accent section\" styles to Group, Columns and Cover blocks, which recolor the background, text, links, headings and buttons inside in one click (WordPress 6.6+).",
            "category": "gutenberg",
            "tags": [
                "section-styles",
                "block-styles",
                "colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( version_compare( get_bloginfo( 'version' ), '6.6', '<' ) ) {\n        return;\n    }\n\n    $blocks = array( 'core/group', 'core/columns', 'core/cover' );\n\n    register_block_style( $blocks, array(\n        'name'       => 'wpide-dark-section',\n        'label'      => 'Dark section',\n        'style_data' => array(\n            'color'    => array( 'background' => '#111827', 'text' => '#f3f4f6' ),\n            'spacing'  => array( 'padding' => array( 'top' => '3rem', 'bottom' => '3rem', 'left' => '1.5rem', 'right' => '1.5rem' ) ),\n            'elements' => array(\n                'heading' => array( 'color' => array( 'text' => '#ffffff' ) ),\n                'link'    => array( 'color' => array( 'text' => '#93c5fd' ) ),\n                'button'  => array( 'color' => array( 'background' => '#f9fafb', 'text' => '#111827' ) ),\n            ),\n        ),\n    ) );\n\n    register_block_style( $blocks, array(\n        'name'       => 'wpide-accent-section',\n        'label'      => 'Soft accent section',\n        'style_data' => array(\n            'color'    => array( 'background' => '#eef2ff', 'text' => '#1e1b4b' ),\n            'spacing'  => array( 'padding' => array( 'top' => '3rem', 'bottom' => '3rem', 'left' => '1.5rem', 'right' => '1.5rem' ) ),\n            'elements' => array(\n                'link'   => array( 'color' => array( 'text' => '#4338ca' ) ),\n                'button' => array( 'color' => array( 'background' => '#4338ca', 'text' => '#ffffff' ) ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Edit the colors in the style_data arrays to match your brand. Older WordPress versions are skipped."
        },
        {
            "id": "block-variations-notice-and-card",
            "version": 1,
            "title": "Block Variations: Notice Box and Card",
            "description": "Adds \"Notice box\" and \"Card\" to the inserter as ready-made Group block variations with starter content, so editors insert consistent components in one click.",
            "category": "gutenberg",
            "tags": [
                "variations",
                "group",
                "components"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'get_block_type_variations', function ( $variations, $block_type ) {\n    if ( 'core/group' !== $block_type->name ) {\n        return $variations;\n    }\n\n    $variations[] = array(\n        'name'        => 'wpide-notice',\n        'title'       => 'Notice box',\n        'description' => 'A highlighted box for important information.',\n        'scope'       => array( 'inserter' ),\n        'icon'        => 'info',\n        'attributes'  => array( 'className' => 'wpide-notice', 'layout' => array( 'type' => 'constrained' ) ),\n        'innerBlocks' => array(\n            array( 'core/paragraph', array( 'content' => '<strong>Note:</strong> add your message here.' ) ),\n        ),\n    );\n\n    $variations[] = array(\n        'name'        => 'wpide-card',\n        'title'       => 'Card',\n        'description' => 'Heading, short text and a button.',\n        'scope'       => array( 'inserter' ),\n        'icon'        => 'id-alt',\n        'attributes'  => array( 'className' => 'wpide-card', 'layout' => array( 'type' => 'constrained' ) ),\n        'innerBlocks' => array(\n            array( 'core/heading', array( 'level' => 3, 'content' => 'Card title' ) ),\n            array( 'core/paragraph', array( 'content' => 'One or two sentences about this item.' ) ),\n            array( 'core/buttons', array(), array( array( 'core/button', array( 'text' => 'Learn more' ) ) ) ),\n        ),\n    );\n\n    return $variations;\n}, 10, 2 );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-group-variations', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-group-variations' );\n    wp_add_inline_style( 'wpide-group-variations', '.wpide-notice{border-left:4px solid #2271b1;background:rgba(34,113,177,.07);padding:1em 1.25em;border-radius:4px;}.wpide-card{padding:1.5em;border-radius:10px;box-shadow:0 2px 12px rgba(0,0,0,.08);}' );\n} );",
            "requires": [],
            "notes": "Uses the class names wpide-notice and wpide-card; style them in your theme or with a CSS snippet. Needs WordPress 6.5+."
        },
        {
            "id": "register-landing-page-template",
            "version": 1,
            "title": "\"Landing Page\" Template Without Header and Footer",
            "description": "Registers a \"Landing page\" block template for pages that shows only the page content, with no site header, footer or title, for campaign and sales pages (WordPress 6.7+).",
            "category": "gutenberg",
            "tags": [
                "templates",
                "landing-pages",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_template' ) || ! wp_is_block_theme() ) {\n        return;\n    }\n\n    register_block_template( 'wpide//landing-page', array(\n        'title'       => 'Landing page',\n        'description' => 'Page content only, without header, footer or title.',\n        'post_types'  => array( 'page' ),\n        'content'     => '<!-- wp:group {\"tagName\":\"main\",\"layout\":{\"type\":\"constrained\"}} --><main class=\"wp-block-group\"><!-- wp:post-content {\"layout\":{\"type\":\"constrained\"}} /--></main><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Choose it in the page sidebar under Template. Works with block themes; older WordPress versions are skipped."
        },
        {
            "id": "register-page-no-title-template",
            "version": 1,
            "title": "\"Page Without Title\" Template for Block Themes",
            "description": "Registers a page template that keeps the theme's header and footer but hides the page title, for pages that start with their own hero section (WordPress 6.7+).",
            "category": "gutenberg",
            "tags": [
                "templates",
                "pages",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_template' ) || ! wp_is_block_theme() ) {\n        return;\n    }\n\n    register_block_template( 'wpide//page-no-title', array(\n        'title'       => 'Page without title',\n        'description' => 'Header, page content and footer, with no title.',\n        'post_types'  => array( 'page' ),\n        'content'     => '<!-- wp:template-part {\"slug\":\"header\",\"tagName\":\"header\"} /--><!-- wp:group {\"tagName\":\"main\",\"layout\":{\"type\":\"constrained\"}} --><main class=\"wp-block-group\"><!-- wp:post-content {\"layout\":{\"type\":\"constrained\"}} /--></main><!-- /wp:group --><!-- wp:template-part {\"slug\":\"footer\",\"tagName\":\"footer\"} /-->',\n    ) );\n} );",
            "requires": [],
            "notes": "Pick it under Template in the page sidebar. It uses your theme's \"header\" and \"footer\" template parts."
        },
        {
            "id": "template-part-area-sidebar",
            "version": 1,
            "title": "Add a \"Sidebar\" Area for Template Parts",
            "description": "Adds a \"Sidebar\" area next to Header and Footer in the Site Editor, so sidebar template parts get their own section, icon and the right HTML tag.",
            "category": "gutenberg",
            "tags": [
                "template-parts",
                "site-editor",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'default_wp_template_part_areas', function ( $areas ) {\n    foreach ( $areas as $area ) {\n        if ( isset( $area['area'] ) && 'sidebar' === $area['area'] ) {\n            return $areas;\n        }\n    }\n\n    $areas[] = array(\n        'area'        => 'sidebar',\n        'area_tag'    => 'aside',\n        'label'       => 'Sidebar',\n        'description' => 'Content shown beside the main column, such as widgets or calls to action.',\n        'icon'        => 'sidebar',\n    );\n\n    return $areas;\n} );",
            "requires": [],
            "notes": "Create a template part and choose \"Sidebar\" as its area in the Advanced panel."
        },
        {
            "id": "block-hooks-author-bio-after-content",
            "version": 1,
            "title": "Add the Author Bio After Posts in Block Themes",
            "description": "Automatically inserts the Author Biography block after the post content in your block theme's single post template, without editing the template.",
            "category": "gutenberg",
            "tags": [
                "block-hooks",
                "authors",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'hooked_block_types', function ( $hooked, $position, $anchor, $context ) {\n    if ( 'after' !== $position || 'core/post-content' !== $anchor ) {\n        return $hooked;\n    }\n\n    if ( ! $context instanceof WP_Block_Template || 'single' !== $context->slug ) {\n        return $hooked;\n    }\n\n    $hooked[] = 'core/post-author-biography';\n\n    return $hooked;\n}, 10, 4 );",
            "requires": [],
            "notes": "You can move or remove the inserted block in the Site Editor like any other block. Needs WordPress 6.4+."
        },
        {
            "id": "block-hooks-loginout-in-navigation",
            "version": 1,
            "title": "Log In/Out Link at the End of the Navigation Block",
            "description": "Adds a Log in / Log out link as the last item of your block theme's header navigation automatically, using block hooks.",
            "category": "gutenberg",
            "tags": [
                "block-hooks",
                "navigation",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'hooked_block_types', function ( $hooked, $position, $anchor, $context ) {\n    if ( 'last_child' !== $position || 'core/navigation' !== $anchor ) {\n        return $hooked;\n    }\n\n    if ( $context instanceof WP_Block_Template && 'header' !== $context->area ) {\n        return $hooked;\n    }\n\n    $hooked[] = 'core/loginout';\n\n    return $hooked;\n}, 10, 4 );",
            "requires": [],
            "notes": "Applies to Navigation blocks inside the header template part. Remove it in the Site Editor if you change your mind. Needs WordPress 6.5+."
        },
        {
            "id": "query-loop-exclude-current-post",
            "version": 1,
            "title": "Query Loop Block: Exclude the Current Post",
            "description": "Removes the post being viewed from any Query Loop block that has the class \"wpide-exclude-current\", for \"More posts\" sections under articles.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "posts",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_query_exclude = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_exclude ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_exclude['active'] = false !== strpos( $classes, ' wpide-exclude-current ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_exclude ) {\n    if ( ! $wpide_query_exclude['active'] || ! is_singular() ) {\n        return $query;\n    }\n\n    $exclude   = isset( $query['post__not_in'] ) ? (array) $query['post__not_in'] : array();\n    $exclude[] = get_queried_object_id();\n\n    $query['post__not_in'] = array_values( array_filter( array_map( 'absint', $exclude ) ) );\n\n    return $query;\n} );",
            "requires": [],
            "notes": "Add wpide-exclude-current under Advanced > Additional CSS class(es) on the Query Loop block, with \"Inherit query\" turned off."
        },
        {
            "id": "query-loop-most-commented",
            "version": 1,
            "title": "Query Loop Block: Most Discussed Posts",
            "description": "Sorts any Query Loop block with the class \"wpide-most-commented\" by number of comments, for a \"Most discussed\" list without a plugin.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "popular",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_query_popular = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_popular ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_popular['active'] = false !== strpos( $classes, ' wpide-most-commented ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_popular ) {\n    if ( ! $wpide_query_popular['active'] ) {\n        return $query;\n    }\n\n    $query['orderby']             = array( 'comment_count' => 'DESC', 'date' => 'DESC' );\n    $query['ignore_sticky_posts'] = true;\n\n    return $query;\n} );",
            "requires": [],
            "notes": "Add wpide-most-commented to the Query Loop's Additional CSS class(es) and turn off \"Inherit query\"."
        },
        {
            "id": "query-loop-with-featured-image",
            "version": 1,
            "title": "Query Loop Block: Only Posts With a Featured Image",
            "description": "Limits any Query Loop block with the class \"wpide-has-thumbnail\" to posts that have a featured image, so image grids never show empty cards.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "featured-image",
                "grid"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_query_thumbs = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_thumbs ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_thumbs['active'] = false !== strpos( $classes, ' wpide-has-thumbnail ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_thumbs ) {\n    if ( ! $wpide_query_thumbs['active'] ) {\n        return $query;\n    }\n\n    $meta_query   = isset( $query['meta_query'] ) ? (array) $query['meta_query'] : array();\n    $meta_query[] = array( 'key' => '_thumbnail_id', 'compare' => 'EXISTS' );\n\n    $query['meta_query'] = $meta_query;\n\n    return $query;\n} );",
            "requires": [],
            "notes": "Add wpide-has-thumbnail to the Query Loop's Additional CSS class(es)."
        },
        {
            "id": "no-edit-as-html-for-non-admins",
            "version": 1,
            "title": "Turn Off \"Edit as HTML\" on Blocks for Non-Administrators",
            "description": "Removes the \"Edit as HTML\" option from block menus for everyone except administrators, so authors cannot break block markup by hand.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "permissions",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_type_metadata', function ( $metadata ) {\n    if ( current_user_can( 'manage_options' ) || empty( $metadata['name'] ) ) {\n        return $metadata;\n    }\n\n    $metadata['supports']         = isset( $metadata['supports'] ) ? (array) $metadata['supports'] : array();\n    $metadata['supports']['html'] = false;\n\n    return $metadata;\n} );",
            "requires": [],
            "notes": "Administrators keep the option. The Code editor mode is a separate setting."
        },
        {
            "id": "no-custom-css-classes-for-non-admins",
            "version": 1,
            "title": "Hide the \"Additional CSS Class\" Field From Non-Administrators",
            "description": "Removes the Additional CSS class(es) field from the block Advanced panel for non-administrators, keeping authors to the styles you have designed.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "permissions",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_type_metadata', function ( $metadata ) {\n    if ( current_user_can( 'manage_options' ) || empty( $metadata['name'] ) ) {\n        return $metadata;\n    }\n\n    $metadata['supports']                    = isset( $metadata['supports'] ) ? (array) $metadata['supports'] : array();\n    $metadata['supports']['customClassName'] = false;\n\n    return $metadata;\n} );",
            "requires": [],
            "notes": "Classes already added to blocks are kept."
        },
        {
            "id": "highlight-format-button",
            "version": 1,
            "title": "Highlight (Marker) Button in the Block Toolbar",
            "description": "Adds a \"Highlight\" button to the text formatting toolbar that wraps selected words in a <mark> tag with a marker-pen look, for emphasis in posts.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "formatting",
                "rich-text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-highlight-format', false, array( 'wp-rich-text', 'wp-block-editor', 'wp-element' ), '1.0', true );\n    wp_enqueue_script( 'wpide-highlight-format' );\n    wp_add_inline_script( 'wpide-highlight-format', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    wp.richText.registerFormatType( 'wpide/highlight', {\n        title: 'Highlight',\n        tagName: 'mark',\n        className: 'wpide-highlight',\n        edit: function ( props ) {\n            return el( wp.blockEditor.RichTextToolbarButton, {\n                icon: 'admin-customizer',\n                title: 'Highlight',\n                isActive: props.isActive,\n                onClick: function () {\n                    props.onChange( wp.richText.toggleFormat( props.value, { type: 'wpide/highlight' } ) );\n                }\n            } );\n        }\n    } );\n} )( window.wp );\nJS\n    );\n} );\n\n$wpide_highlight_css = 'mark.wpide-highlight{background:linear-gradient(transparent 55%,#fde68a 55%);color:inherit;padding:0 .1em;}';\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_highlight_css ) {\n    wp_register_style( 'wpide-highlight', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-highlight' );\n    wp_add_inline_style( 'wpide-highlight', $wpide_highlight_css );\n} );\n\nadd_action( 'enqueue_block_assets', function () use ( $wpide_highlight_css ) {\n    if ( is_admin() ) {\n        wp_register_style( 'wpide-highlight-editor', false, array(), '1.0' );\n        wp_enqueue_style( 'wpide-highlight-editor' );\n        wp_add_inline_style( 'wpide-highlight-editor', $wpide_highlight_css );\n    }\n} );",
            "requires": [],
            "notes": "Find it in the toolbar's \"More\" (chevron) menu. Change the color in the inline CSS."
        },
        {
            "id": "keyboard-key-format-button",
            "version": 1,
            "title": "Keyboard Key (kbd) Button in the Block Toolbar",
            "description": "Adds a \"Keyboard key\" format to the text toolbar that wraps text in <kbd> and styles it like a key cap, for tutorials with shortcuts such as Ctrl + S.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "formatting",
                "documentation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-kbd-format', false, array( 'wp-rich-text', 'wp-block-editor', 'wp-element' ), '1.0', true );\n    wp_enqueue_script( 'wpide-kbd-format' );\n    wp_add_inline_script( 'wpide-kbd-format', <<<'JS'\n( function ( wp ) {\n    wp.richText.registerFormatType( 'wpide/kbd', {\n        title: 'Keyboard key',\n        tagName: 'kbd',\n        className: null,\n        edit: function ( props ) {\n            return wp.element.createElement( wp.blockEditor.RichTextToolbarButton, {\n                icon: 'editor-code',\n                title: 'Keyboard key',\n                isActive: props.isActive,\n                onClick: function () {\n                    props.onChange( wp.richText.toggleFormat( props.value, { type: 'wpide/kbd' } ) );\n                }\n            } );\n        }\n    } );\n} )( window.wp );\nJS\n    );\n} );\n\n$wpide_kbd_css = 'kbd{display:inline-block;padding:.1em .45em;font:600 .85em/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;border:1px solid #c3c4c7;border-bottom-width:3px;border-radius:4px;background:#f6f7f7;color:#1d2327;white-space:nowrap;}';\n\nadd_action( 'enqueue_block_assets', function () use ( $wpide_kbd_css ) {\n    wp_register_style( 'wpide-kbd', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-kbd' );\n    wp_add_inline_style( 'wpide-kbd', $wpide_kbd_css );\n} );",
            "requires": [],
            "notes": "Find it in the toolbar's \"More\" (chevron) menu."
        },
        {
            "id": "default-block-settings",
            "version": 1,
            "title": "Better Defaults for New Blocks",
            "description": "Changes the starting settings of blocks editors add: images link to nothing and open large, galleries crop evenly, columns stay side by side on phones, and new tables have header rows.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "defaults",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-block-defaults', false, array( 'wp-hooks', 'wp-blocks' ), '1.0', false );\n    wp_enqueue_script( 'wpide-block-defaults' );\n    wp_add_inline_script( 'wpide-block-defaults', <<<'JS'\n( function ( wp ) {\n    // Block name => attribute => new default value.\n    var defaults = {\n        'core/image': { sizeSlug: 'large', linkDestination: 'none' },\n        'core/gallery': { imageCrop: true, linkTo: 'none' },\n        'core/columns': { isStackedOnMobile: true },\n        'core/table': { hasFixedLayout: true }\n    };\n    wp.hooks.addFilter( 'blocks.registerBlockType', 'wpide/block-defaults', function ( settings, name ) {\n        if ( ! defaults[ name ] || ! settings.attributes ) {\n            return settings;\n        }\n        var attributes = Object.assign( {}, settings.attributes );\n        Object.keys( defaults[ name ] ).forEach( function ( key ) {\n            if ( attributes[ key ] ) {\n                attributes[ key ] = Object.assign( {}, attributes[ key ], { default: defaults[ name ][ key ] } );\n            }\n        } );\n        return Object.assign( {}, settings, { attributes: attributes } );\n    } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Only affects newly inserted blocks. Delete any line in the defaults object you do not want."
        },
        {
            "id": "cover-image-fetchpriority",
            "version": 1,
            "title": "Load the First Cover Block Image With High Priority",
            "description": "Marks the background image of the first Cover block on a page with fetchpriority=\"high\" and turns off lazy loading for it, which speeds up hero sections (better LCP).",
            "category": "gutenberg",
            "tags": [
                "performance",
                "cover",
                "images",
                "lcp"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'render_block_core/cover', function ( $content ) {\n    static $done = false;\n\n    if ( $done || is_admin() || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    if ( $tags->next_tag( array( 'tag_name' => 'img', 'class_name' => 'wp-block-cover__image-background' ) ) ) {\n        $tags->set_attribute( 'fetchpriority', 'high' );\n        $tags->remove_attribute( 'loading' );\n        $tags->set_attribute( 'decoding', 'async' );\n        $done = true;\n\n        return $tags->get_updated_html();\n    }\n\n    return $content;\n} );",
            "requires": [],
            "notes": "Only the first Cover block with an image element is changed; later ones keep lazy loading."
        },
        {
            "id": "allowed-blocks-per-role",
            "version": 1,
            "title": "Limit the Blocks Authors and Contributors Can Use",
            "description": "Gives authors and contributors a short list of everyday blocks (text, headings, lists, images, quotes, embeds) while editors and administrators keep every block.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "permissions",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Blocks available to authors and contributors.\n$wpide_role_blocks = array(\n    'core/paragraph', 'core/heading', 'core/list', 'core/list-item', 'core/quote', 'core/image',\n    'core/gallery', 'core/embed', 'core/separator', 'core/table', 'core/buttons', 'core/button', 'core/details',\n);\n\nadd_filter( 'allowed_block_types_all', function ( $allowed, $context ) use ( $wpide_role_blocks ) {\n    if ( current_user_can( 'edit_others_posts' ) ) {\n        return $allowed;\n    }\n\n    if ( empty( $context->post ) ) {\n        return $allowed;\n    }\n\n    return $wpide_role_blocks;\n}, 20, 2 );",
            "requires": [],
            "notes": "Users who can edit others' posts (editors and administrators) are not limited. Edit the block list to fit your site."
        },
        {
            "id": "block-binding-reading-time",
            "version": 1,
            "title": "Block Bindings Source: Reading Time",
            "description": "Registers a \"Reading time\" block bindings source, so a Paragraph or Heading in your templates can show \"5 min read\" for the current post (WordPress 6.5+).",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "reading-time",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed in words per minute.\n$wpide_binding_wpm = 220;\n\nadd_action( 'init', function () use ( $wpide_binding_wpm ) {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/reading-time', array(\n        'label'              => 'Reading time',\n        'uses_context'       => array( 'postId' ),\n        'get_value_callback' => function ( $source_args, $block_instance ) use ( $wpide_binding_wpm ) {\n            $post_id = isset( $block_instance->context['postId'] ) ? (int) $block_instance->context['postId'] : get_the_ID();\n            $words   = str_word_count( wp_strip_all_tags( (string) get_post_field( 'post_content', $post_id ) ) );\n\n            return esc_html( sprintf( '%d min read', max( 1, (int) ceil( $words / max( 1, $wpide_binding_wpm ) ) ) ) );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "In the Code editor, add {\"metadata\":{\"bindings\":{\"content\":{\"source\":\"wpide/reading-time\"}}}} to a Paragraph block's attributes."
        },
        {
            "id": "per-block-css-only-when-used",
            "version": 1,
            "title": "Custom CSS for Specific Blocks, Loaded Only Where Used",
            "description": "Lets you write extra CSS for individual block types (for example tables or quotes) that WordPress prints only on pages that actually contain those blocks.",
            "category": "gutenberg",
            "tags": [
                "css",
                "performance",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Block name => CSS for that block.\n$wpide_block_css = array(\n    'core/table' => '.wp-block-table table{font-size:.95em;}.wp-block-table th{text-align:left;}',\n    'core/quote' => '.wp-block-quote{border-left-width:4px;padding-left:1.25em;font-style:italic;}',\n    'core/code'  => '.wp-block-code{border-radius:6px;padding:1em 1.25em;overflow-x:auto;}',\n);\n\nadd_action( 'init', function () use ( $wpide_block_css ) {\n    if ( ! function_exists( 'wp_enqueue_block_style' ) ) {\n        return;\n    }\n\n    foreach ( $wpide_block_css as $block => $css ) {\n        $handle = 'wpide-block-' . sanitize_key( str_replace( '/', '-', $block ) );\n\n        wp_register_style( $handle, false, array(), '1.0' );\n        wp_add_inline_style( $handle, wp_strip_all_tags( $css ) );\n        wp_enqueue_block_style( $block, array( 'handle' => $handle ) );\n    }\n} );",
            "requires": [],
            "notes": "Works best with block themes, or classic themes that load block styles separately. Edit the CSS per block name."
        },
        {
            "id": "navigation-block-no-page-fallback",
            "version": 1,
            "title": "Stop the Navigation Block Listing All Pages",
            "description": "Prevents the Navigation block from automatically showing a list of every page when no menu is assigned, so unfinished headers stay empty instead of exposing pages.",
            "category": "gutenberg",
            "tags": [
                "navigation",
                "menus",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_core_navigation_render_fallback', '__return_empty_array' );",
            "requires": [],
            "notes": "Assign a menu to your Navigation block in the Site Editor; until then it renders nothing."
        },
        {
            "id": "template-part-shortcode-classic-themes",
            "version": 1,
            "title": "Template Part Shortcode for Classic Themes",
            "description": "Adds a [wpide_template_part slug=\"footer\"] shortcode that prints a block template part, so classic themes and page builders can reuse parts designed in the Site Editor.",
            "category": "gutenberg",
            "tags": [
                "template-parts",
                "shortcode",
                "classic-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Template part slugs that may be used.\n$wpide_allowed_parts = array( 'header', 'footer', 'sidebar', 'cta' );\n\nadd_shortcode( 'wpide_template_part', function ( $atts ) use ( $wpide_allowed_parts ) {\n    static $depth = 0;\n\n    $atts = shortcode_atts( array( 'slug' => '' ), $atts, 'wpide_template_part' );\n    $slug = sanitize_key( $atts['slug'] );\n\n    if ( ! $slug || ! in_array( $slug, $wpide_allowed_parts, true ) || $depth > 1 || ! function_exists( 'get_block_template' ) ) {\n        return '';\n    }\n\n    $part = get_block_template( get_stylesheet() . '//' . $slug, 'wp_template_part' );\n\n    if ( ! $part || empty( $part->content ) ) {\n        return '';\n    }\n\n    $depth++;\n    $html = do_blocks( $part->content );\n    $depth--;\n\n    return '<div class=\"wpide-template-part wpide-template-part-' . esc_attr( $slug ) . '\">' . $html . '</div>';\n} );",
            "requires": [],
            "notes": "Only slugs in the allowed list are rendered. Template parts must exist for the active theme (Appearance > Editor or the Patterns screen)."
        },
        {
            "id": "themejson-self-hosted-font",
            "version": 1,
            "title": "Register a Self-Hosted Font in theme.json",
            "description": "Adds a font family with your own font files (stored in uploads/fonts) to the theme's typography settings, so it appears in the editor's font picker and loads without Google.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "fonts",
                "typography",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Font settings. Files live in wp-content/uploads/fonts/.\n$wpide_font = array(\n    'name'     => 'Inter',\n    'slug'     => 'inter',\n    'fallback' => 'system-ui, -apple-system, sans-serif',\n    'files'    => array(\n        '400' => 'inter-400.woff2',\n        '700' => 'inter-700.woff2',\n    ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_font ) {\n    $uploads = wp_get_upload_dir();\n    $base    = trailingslashit( $uploads['baseurl'] ) . 'fonts/';\n    $faces   = array();\n\n    foreach ( $wpide_font['files'] as $weight => $file ) {\n        if ( ! file_exists( trailingslashit( $uploads['basedir'] ) . 'fonts/' . $file ) ) {\n            continue;\n        }\n\n        $faces[] = array(\n            'fontFamily'  => $wpide_font['name'],\n            'fontWeight'  => (string) $weight,\n            'fontStyle'   => 'normal',\n            'fontDisplay' => 'swap',\n            'src'         => array( $base . $file ),\n        );\n    }\n\n    if ( ! $faces ) {\n        return $theme_json;\n    }\n\n    $data     = $theme_json->get_data();\n    $families = isset( $data['settings']['typography']['fontFamilies'] ) ? (array) $data['settings']['typography']['fontFamilies'] : array();\n    $families = isset( $families['theme'] ) ? (array) $families['theme'] : $families;\n    $families = array_values( array_filter( $families, function ( $family ) use ( $wpide_font ) {\n        return ! isset( $family['slug'] ) || $family['slug'] !== $wpide_font['slug'];\n    } ) );\n\n    $families[] = array(\n        'name'       => $wpide_font['name'],\n        'slug'       => $wpide_font['slug'],\n        'fontFamily' => '\"' . $wpide_font['name'] . '\", ' . $wpide_font['fallback'],\n        'fontFace'   => $faces,\n    );\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'typography' => array( 'fontFamilies' => $families ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Upload the .woff2 files to wp-content/uploads/fonts/ first and match the file names below. Works with block themes and classic themes that use theme.json."
        },
        {
            "id": "themejson-spacing-scale",
            "version": 1,
            "title": "Custom Spacing Scale for Padding, Margin and Gaps",
            "description": "Replaces the spacing presets in the editor's padding, margin and block spacing controls with your own scale, so every page uses the same rhythm.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "spacing",
                "design-system"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Spacing presets: slug => [name, size].\n$wpide_spacing_sizes = array(\n    '20' => array( 'XS', '0.5rem' ),\n    '30' => array( 'S', '1rem' ),\n    '40' => array( 'M', 'clamp(1.25rem, 2vw, 1.75rem)' ),\n    '50' => array( 'L', 'clamp(2rem, 4vw, 3rem)' ),\n    '60' => array( 'XL', 'clamp(3rem, 6vw, 5rem)' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_spacing_sizes ) {\n    $sizes = array();\n\n    foreach ( $wpide_spacing_sizes as $slug => $size ) {\n        $sizes[] = array( 'slug' => (string) $slug, 'name' => $size[0], 'size' => $size[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'spacing' => array(\n                'spacingSizes'        => $sizes,\n                'defaultSpacingSizes' => false,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Sizes can use clamp() for fluid spacing. Keep slugs stable once used, or blocks lose their spacing."
        },
        {
            "id": "themejson-shadow-presets",
            "version": 1,
            "title": "Add Your Own Shadow Presets to the Editor",
            "description": "Adds soft, medium and lifted shadow options to the editor's Shadow control for buttons, images, groups and columns, next to the theme's own shadows.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "shadows",
                "design-system"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Shadow presets: slug => [name, CSS shadow].\n$wpide_shadows = array(\n    'wpide-soft'   => array( 'Soft', '0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06)' ),\n    'wpide-medium' => array( 'Medium', '0 6px 16px rgba(0,0,0,.10)' ),\n    'wpide-lifted' => array( 'Lifted', '0 18px 40px -12px rgba(0,0,0,.25)' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_shadows ) {\n    $data    = $theme_json->get_data();\n    $current = isset( $data['settings']['shadow']['presets'] ) ? (array) $data['settings']['shadow']['presets'] : array();\n    $current = isset( $current['theme'] ) ? (array) $current['theme'] : $current;\n    $by_slug = array();\n\n    foreach ( $current as $preset ) {\n        if ( isset( $preset['slug'] ) ) {\n            $by_slug[ $preset['slug'] ] = $preset;\n        }\n    }\n\n    foreach ( $wpide_shadows as $slug => $shadow ) {\n        $by_slug[ $slug ] = array( 'slug' => $slug, 'name' => $shadow[0], 'shadow' => $shadow[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'shadow' => array( 'presets' => array_values( $by_slug ) ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Shadow controls need WordPress 6.5+ and blocks that support shadows."
        },
        {
            "id": "themejson-brand-gradients",
            "version": 1,
            "title": "Add Brand Gradients to the Editor",
            "description": "Adds your brand gradients to the gradient picker for Group, Cover, Button and other blocks, so editors use on-brand backgrounds.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "gradients",
                "colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Gradients: slug => [name, CSS gradient].\n$wpide_gradients = array(\n    'wpide-sunrise' => array( 'Sunrise', 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)' ),\n    'wpide-ocean'   => array( 'Ocean', 'linear-gradient(135deg, #0ea5e9 0%, #1e3a8a 100%)' ),\n    'wpide-mist'    => array( 'Mist', 'linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%)' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_gradients ) {\n    $data    = $theme_json->get_data();\n    $current = isset( $data['settings']['color']['gradients'] ) ? (array) $data['settings']['color']['gradients'] : array();\n    $current = isset( $current['theme'] ) ? (array) $current['theme'] : $current;\n    $by_slug = array();\n\n    foreach ( $current as $preset ) {\n        if ( isset( $preset['slug'] ) ) {\n            $by_slug[ $preset['slug'] ] = $preset;\n        }\n    }\n\n    foreach ( $wpide_gradients as $slug => $gradient ) {\n        $by_slug[ $slug ] = array( 'slug' => $slug, 'name' => $gradient[0], 'gradient' => $gradient[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'color' => array( 'gradients' => array_values( $by_slug ) ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Any valid CSS gradient works. Existing theme gradients are kept."
        },
        {
            "id": "themejson-brand-duotone",
            "version": 1,
            "title": "Add Brand Duotone Filters for Images",
            "description": "Adds duotone image filters in your brand colors to the Image and Cover block toolbars, for a consistent photo style across the site.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "duotone",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Duotone presets: slug => [name, dark color, light color].\n$wpide_duotones = array(\n    'wpide-navy-cream' => array( 'Navy and cream', '#1e293b', '#fef3c7' ),\n    'wpide-teal-white' => array( 'Teal and white', '#134e4a', '#f0fdfa' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_duotones ) {\n    $data    = $theme_json->get_data();\n    $current = isset( $data['settings']['color']['duotone'] ) ? (array) $data['settings']['color']['duotone'] : array();\n    $current = isset( $current['theme'] ) ? (array) $current['theme'] : $current;\n    $by_slug = array();\n\n    foreach ( $current as $preset ) {\n        if ( isset( $preset['slug'] ) ) {\n            $by_slug[ $preset['slug'] ] = $preset;\n        }\n    }\n\n    foreach ( $wpide_duotones as $slug => $duotone ) {\n        $by_slug[ $slug ] = array( 'slug' => $slug, 'name' => $duotone[0], 'colors' => array( $duotone[1], $duotone[2] ) );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'color' => array( 'duotone' => array_values( $by_slug ) ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Each filter maps shadows to the first color and highlights to the second."
        },
        {
            "id": "themejson-link-styles",
            "version": 1,
            "title": "Site-Wide Link Color and Hover Style",
            "description": "Sets the color, underline and hover color of links across a block theme through theme.json, so links look the same in the editor and on the site.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "links",
                "colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Link colors.\n$wpide_link_color       = '#1d4ed8';\n$wpide_link_hover_color = '#1e3a8a';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_link_color, $wpide_link_hover_color ) {\n    return $theme_json->update_with( array(\n        'version' => WP_Theme_JSON::LATEST_SCHEMA,\n        'styles'  => array(\n            'elements' => array(\n                'link' => array(\n                    'color'      => array( 'text' => $wpide_link_color ),\n                    'typography' => array( 'textDecoration' => 'underline' ),\n                    ':hover'     => array(\n                        'color'      => array( 'text' => $wpide_link_hover_color ),\n                        'typography' => array( 'textDecoration' => 'none' ),\n                    ),\n                    ':focus'     => array( 'color' => array( 'text' => $wpide_link_hover_color ) ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Colors set later in Styles (Site Editor) still override these values."
        },
        {
            "id": "themejson-heading-styles",
            "version": 1,
            "title": "Tighter, Bolder Headings Through theme.json",
            "description": "Sets the weight, line height and letter spacing of all headings in a block theme, for a crisper, more modern look in the editor and on the site.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "headings",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Heading typography.\n$wpide_heading_weight         = '700';\n$wpide_heading_line_height    = '1.15';\n$wpide_heading_letter_spacing = '-0.02em';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_heading_weight, $wpide_heading_line_height, $wpide_heading_letter_spacing ) {\n    return $theme_json->update_with( array(\n        'version' => WP_Theme_JSON::LATEST_SCHEMA,\n        'styles'  => array(\n            'elements' => array(\n                'heading' => array(\n                    'typography' => array(\n                        'fontWeight'    => $wpide_heading_weight,\n                        'lineHeight'    => $wpide_heading_line_height,\n                        'letterSpacing' => $wpide_heading_letter_spacing,\n                    ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Adjust the three values at the top. Individual blocks can still override them."
        },
        {
            "id": "themejson-button-style",
            "version": 1,
            "title": "Rounded Buttons Through theme.json",
            "description": "Sets the shape, padding and weight of all buttons (Button block, search and form buttons) in a block theme through theme.json, including a darker hover color.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "buttons",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Button style.\n$wpide_button = array(\n    'radius'     => '999px',\n    'background' => '#111827',\n    'text'       => '#ffffff',\n    'hover'      => '#374151',\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_button ) {\n    return $theme_json->update_with( array(\n        'version' => WP_Theme_JSON::LATEST_SCHEMA,\n        'styles'  => array(\n            'elements' => array(\n                'button' => array(\n                    'border'     => array( 'radius' => $wpide_button['radius'] ),\n                    'color'      => array( 'background' => $wpide_button['background'], 'text' => $wpide_button['text'] ),\n                    'spacing'    => array( 'padding' => array( 'top' => '0.75em', 'bottom' => '0.75em', 'left' => '1.5em', 'right' => '1.5em' ) ),\n                    'typography' => array( 'fontWeight' => '600' ),\n                    ':hover'     => array( 'color' => array( 'background' => $wpide_button['hover'], 'text' => $wpide_button['text'] ) ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Set the radius to 4px for slightly rounded corners or 0 for square buttons."
        },
        {
            "id": "themejson-fluid-typography",
            "version": 1,
            "title": "Turn On Fluid Typography",
            "description": "Makes font sizes from the theme presets scale smoothly between phone and desktop widths, so large headings never overflow on small screens.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "typography",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Screen widths between which sizes scale.\n$wpide_fluid_min = '360px';\n$wpide_fluid_max = '1280px';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_fluid_min, $wpide_fluid_max ) {\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'typography' => array(\n                'fluid' => array(\n                    'minViewportWidth' => $wpide_fluid_min,\n                    'maxViewportWidth' => $wpide_fluid_max,\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Change the viewport range to control where scaling starts and stops."
        },
        {
            "id": "themejson-allowed-units",
            "version": 1,
            "title": "Limit the CSS Units Offered in the Editor",
            "description": "Reduces the unit dropdowns in spacing and size controls to the units you want (for example px, rem and %), keeping layouts consistent.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "units",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Units offered in spacing, width and height controls.\n$wpide_units = array( 'px', 'rem', '%', 'vw' );\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_units ) {\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'spacing' => array( 'units' => array_values( $wpide_units ) ) ),\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "themejson-image-aspect-ratios",
            "version": 1,
            "title": "Only Your Image Aspect Ratios in the Editor",
            "description": "Replaces the long list of aspect ratios offered for images and featured images with a short set you choose, so cropped images stay consistent.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "images",
                "aspect-ratio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Aspect ratios: slug => [name, ratio].\n$wpide_aspect_ratios = array(\n    'wpide-square'    => array( 'Square', '1' ),\n    'wpide-landscape' => array( 'Landscape 3:2', '3/2' ),\n    'wpide-wide'      => array( 'Wide 16:9', '16/9' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_aspect_ratios ) {\n    $ratios = array();\n\n    foreach ( $wpide_aspect_ratios as $slug => $ratio ) {\n        $ratios[] = array( 'slug' => $slug, 'name' => $ratio[0], 'ratio' => $ratio[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'dimensions' => array(\n                'aspectRatios'        => $ratios,\n                'defaultAspectRatios' => false,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.6+. Ratios are written as width/height."
        },
        {
            "id": "themejson-button-palette",
            "version": 1,
            "title": "Restrict Button Block Colors to Brand Colors",
            "description": "Gives the Button block its own small palette of brand colors and removes the custom color picker for it, so every button on the site stays on-brand.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "buttons",
                "colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Colors allowed for buttons: slug => [name, hex].\n$wpide_button_colors = array(\n    'wpide-btn-primary'   => array( 'Primary', '#1d4ed8' ),\n    'wpide-btn-secondary' => array( 'Secondary', '#111827' ),\n    'wpide-btn-light'     => array( 'Light', '#ffffff' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_button_colors ) {\n    $palette = array();\n\n    foreach ( $wpide_button_colors as $slug => $color ) {\n        $palette[] = array( 'slug' => $slug, 'name' => $color[0], 'color' => $color[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'blocks' => array(\n                'core/button' => array(\n                    'color' => array(\n                        'palette'        => $palette,\n                        'custom'         => false,\n                        'customGradient' => false,\n                        'defaultPalette' => false,\n                    ),\n                ),\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Other blocks keep the full theme palette."
        },
        {
            "id": "themejson-palette-per-post-type",
            "version": 1,
            "title": "Extra Color Palette for One Post Type",
            "description": "Adds extra colors to the editor palette only while editing a chosen post type (for example a landing page type), and prints the matching color classes on the front end.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "colors",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type that gets the extra colors, and the colors (slug => [name, hex]).\n$wpide_pt_palette_type   = 'page';\n$wpide_pt_palette_colors = array(\n    'wpide-campaign-red'  => array( 'Campaign red', '#dc2626' ),\n    'wpide-campaign-gold' => array( 'Campaign gold', '#ca8a04' ),\n);\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_pt_palette_type, $wpide_pt_palette_colors ) {\n    if ( ! is_admin() ) {\n        return $theme_json;\n    }\n\n    // phpcs:disable WordPress.Security.NonceVerification.Recommended\n    $type = '';\n    if ( ! empty( $_GET['post_type'] ) ) {\n        $type = sanitize_key( $_GET['post_type'] );\n    } elseif ( ! empty( $_GET['post'] ) ) {\n        $type = (string) get_post_type( absint( $_GET['post'] ) );\n    } elseif ( isset( $GLOBALS['pagenow'] ) && 'post-new.php' === $GLOBALS['pagenow'] ) {\n        $type = 'post';\n    }\n    // phpcs:enable\n\n    if ( $type !== $wpide_pt_palette_type ) {\n        return $theme_json;\n    }\n\n    $data    = $theme_json->get_data();\n    $palette = isset( $data['settings']['color']['palette'] ) ? (array) $data['settings']['color']['palette'] : array();\n    $palette = isset( $palette['theme'] ) ? (array) $palette['theme'] : $palette;\n\n    foreach ( $wpide_pt_palette_colors as $slug => $color ) {\n        $palette[] = array( 'slug' => $slug, 'name' => $color[0], 'color' => $color[1] );\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'color' => array( 'palette' => $palette ) ),\n    ) );\n} );\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_pt_palette_type, $wpide_pt_palette_colors ) {\n    if ( ! is_singular( $wpide_pt_palette_type ) ) {\n        return;\n    }\n\n    $css = '';\n    foreach ( $wpide_pt_palette_colors as $slug => $color ) {\n        $slug = sanitize_key( $slug );\n        $hex  = sanitize_hex_color( $color[1] );\n        $css .= \":root{--wp--preset--color--{$slug}:{$hex};}.has-{$slug}-color{color:var(--wp--preset--color--{$slug})!important;}.has-{$slug}-background-color{background-color:var(--wp--preset--color--{$slug})!important;}.has-{$slug}-border-color{border-color:var(--wp--preset--color--{$slug})!important;}\";\n    }\n\n    wp_register_style( 'wpide-pt-palette', false, array(), '1.0' );\n    wp_enqueue_style( 'wpide-pt-palette' );\n    wp_add_inline_style( 'wpide-pt-palette', $css );\n} );",
            "requires": [],
            "notes": "Set the post type and colors at the top. The theme palette is kept; these colors are added to it."
        },
        {
            "id": "themejson-lock-content-widths",
            "version": 1,
            "title": "Stop Non-Administrators Changing Content Widths",
            "description": "Hides the content width and wide width fields in the layout settings of Group and similar blocks for everyone except administrators, so page widths stay consistent.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "layout",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        return $theme_json;\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'layout' => array( 'allowCustomContentAndWideSize' => false ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.6+; older versions ignore the setting."
        },
        {
            "id": "themejson-disable-sticky-position",
            "version": 1,
            "title": "Remove the \"Sticky\" Position Option From Blocks",
            "description": "Hides the Position: Sticky setting from Group blocks in the editor, so editors cannot accidentally pin sections to the top of the screen.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "block-editor",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'position' => array( 'sticky' => false ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Blocks already set to sticky keep that setting."
        },
        {
            "id": "themejson-block-gap",
            "version": 1,
            "title": "Change the Default Space Between Blocks",
            "description": "Sets the default vertical gap between blocks across a block theme, for a tighter or airier rhythm without adjusting every Group block.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "spacing",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default gap between blocks.\n$wpide_block_gap = '1.5rem';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_block_gap ) {\n    return $theme_json->update_with( array(\n        'version' => WP_Theme_JSON::LATEST_SCHEMA,\n        'styles'  => array( 'spacing' => array( 'blockGap' => $wpide_block_gap ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Any CSS length works, including clamp() values."
        },
        {
            "id": "themejson-font-display",
            "version": 1,
            "title": "Show Text While Theme Fonts Load (font-display)",
            "description": "Changes the font-display setting of every font the theme registers in theme.json, so text shows immediately in a fallback font instead of staying invisible while web fonts load.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "fonts",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// font-display value: swap, optional, fallback or block.\n$wpide_font_display = 'swap';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_font_display ) {\n    $data     = $theme_json->get_data();\n    $families = isset( $data['settings']['typography']['fontFamilies'] ) ? (array) $data['settings']['typography']['fontFamilies'] : array();\n    $families = isset( $families['theme'] ) ? (array) $families['theme'] : $families;\n    $changed  = false;\n\n    foreach ( $families as $i => $family ) {\n        if ( empty( $family['fontFace'] ) || ! is_array( $family['fontFace'] ) ) {\n            continue;\n        }\n\n        foreach ( $family['fontFace'] as $j => $face ) {\n            $families[ $i ]['fontFace'][ $j ]['fontDisplay'] = $wpide_font_display;\n            $changed = true;\n        }\n    }\n\n    if ( ! $changed ) {\n        return $theme_json;\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'typography' => array( 'fontFamilies' => $families ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Use swap to always switch to the web font, or optional to keep the fallback on slow connections and avoid layout shifts."
        },
        {
            "id": "themejson-limit-theme-fonts",
            "version": 1,
            "title": "Keep Only the Theme Fonts You Use",
            "description": "Removes the font families you do not use from the theme's font list, so editors see a short list and the unused fonts are never loaded.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "fonts",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Font family slugs to keep.\n$wpide_keep_font_slugs = array( 'manrope', 'fira-code' );\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_keep_font_slugs ) {\n    $data     = $theme_json->get_data();\n    $families = isset( $data['settings']['typography']['fontFamilies'] ) ? (array) $data['settings']['typography']['fontFamilies'] : array();\n    $families = isset( $families['theme'] ) ? (array) $families['theme'] : $families;\n\n    if ( ! $families ) {\n        return $theme_json;\n    }\n\n    $kept = array_values( array_filter( $families, function ( $family ) use ( $wpide_keep_font_slugs ) {\n        return isset( $family['slug'] ) && in_array( $family['slug'], $wpide_keep_font_slugs, true );\n    } ) );\n\n    // Never leave the theme without fonts because of a typo.\n    if ( ! $kept ) {\n        return $theme_json;\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array( 'typography' => array( 'fontFamilies' => $kept ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Font slugs are shown in the theme's theme.json (settings.typography.fontFamilies). Text set to a removed font falls back to the default font."
        },
        {
            "id": "themejson-simple-editor-for-authors",
            "version": 1,
            "title": "Hide Design Controls From Authors and Contributors",
            "description": "Hides color, typography, spacing and border controls in the block editor for authors and contributors, so they focus on writing while editors keep full design control.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "permissions",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_theme_json_data_theme', function ( $theme_json ) {\n    if ( ! is_user_logged_in() || current_user_can( 'edit_others_posts' ) ) {\n        return $theme_json;\n    }\n\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'color'      => array(\n                'background'     => false,\n                'text'           => false,\n                'link'           => false,\n                'custom'         => false,\n                'customGradient' => false,\n                'customDuotone'  => false,\n            ),\n            'typography' => array(\n                'customFontSize' => false,\n                'lineHeight'     => false,\n                'dropCap'        => false,\n                'fontStyle'      => false,\n                'fontWeight'     => false,\n                'letterSpacing'  => false,\n                'textTransform'  => false,\n            ),\n            'spacing'    => array(\n                'padding'  => false,\n                'margin'   => false,\n                'blockGap' => false,\n            ),\n            'border'     => array(\n                'color'  => false,\n                'radius' => false,\n                'style'  => false,\n                'width'  => false,\n            ),\n            'shadow'     => array( 'defaultPresets' => false ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Users who can edit others' posts (editors, administrators) keep all controls. Styles already applied to blocks remain."
        },
        {
            "id": "themejson-content-wide-widths",
            "version": 1,
            "title": "Change the Content and Wide Widths of a Block Theme",
            "description": "Sets how wide normal and \"wide\" content is in your block theme, on the site and in the editor, for example a narrower reading column with wider images.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "layout",
                "width"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Width of normal content and of wide-aligned blocks.\n$wpide_content_size = '720px';\n$wpide_wide_size    = '1200px';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_content_size, $wpide_wide_size ) {\n    return $theme_json->update_with( array(\n        'version'  => WP_Theme_JSON::LATEST_SCHEMA,\n        'settings' => array(\n            'layout' => array(\n                'contentSize' => $wpide_content_size,\n                'wideSize'    => $wpide_wide_size,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Values can be px, rem or %. Changes made in the Site Editor's Styles still take priority."
        },
        {
            "id": "themejson-body-typography",
            "version": 1,
            "title": "Set the Body Text Size and Line Height",
            "description": "Sets the default font size and line height of body text in a block theme through theme.json, for more comfortable reading on every page.",
            "category": "gutenberg",
            "tags": [
                "theme-json",
                "typography",
                "readability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Body text settings.\n$wpide_body_font_size   = 'clamp(1rem, 0.95rem + 0.25vw, 1.125rem)';\n$wpide_body_line_height = '1.65';\n\nadd_filter( 'wp_theme_json_data_theme', function ( $theme_json ) use ( $wpide_body_font_size, $wpide_body_line_height ) {\n    return $theme_json->update_with( array(\n        'version' => WP_Theme_JSON::LATEST_SCHEMA,\n        'styles'  => array(\n            'typography' => array(\n                'fontSize'   => $wpide_body_font_size,\n                'lineHeight' => $wpide_body_line_height,\n            ),\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "A clamp() size scales between phones and desktops; use a fixed value like 18px if you prefer."
        },
        {
            "id": "frontend-modal-shortcode",
            "version": 1,
            "title": "Modal Window Shortcode (No Cookies, No Library)",
            "description": "Adds a [wpide_modal button=\"Open\"]...[/wpide_modal] shortcode that shows a button which opens its content in an accessible modal window, built on the native dialog element.",
            "category": "frontend",
            "tags": [
                "modal",
                "popup",
                "shortcode",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-modal', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-modal', '.wpide-modal{max-width:min(640px,calc(100vw - 32px));width:100%;border:0;border-radius:12px;padding:0;box-shadow:0 20px 60px rgba(0,0,0,.3);}.wpide-modal::backdrop{background:rgba(0,0,0,.55);}.wpide-modal__inner{padding:24px 24px 20px;}.wpide-modal__close{position:absolute;top:8px;right:8px;width:40px;height:40px;border:0;border-radius:50%;background:transparent;font-size:24px;line-height:1;cursor:pointer;color:inherit;}.wpide-modal__close:hover{background:rgba(0,0,0,.06);}.wpide-modal h2{margin-top:0;padding-right:40px;}@media (prefers-reduced-motion:no-preference){.wpide-modal[open]{animation:wpide-modal-in .2s ease-out;}}@keyframes wpide-modal-in{from{opacity:0;transform:translateY(12px);}to{opacity:1;transform:none;}}' );\n\n    wp_register_script( 'wpide-modal', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-modal', \"document.addEventListener('click',function(e){var open=e.target.closest&&e.target.closest('[data-wpide-modal]');if(open){var d=document.getElementById(open.getAttribute('data-wpide-modal'));if(d&&d.showModal){e.preventDefault();d.showModal();}return;}if(e.target.classList&&e.target.classList.contains('wpide-modal')){e.target.close();}var c=e.target.closest&&e.target.closest('.wpide-modal__close');if(c){c.closest('dialog').close();}});\" );\n} );\n\nadd_shortcode( 'wpide_modal', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'button' => 'Open', 'title' => '' ), $atts, 'wpide_modal' );\n    $id   = 'wpide-modal-' . wp_unique_id();\n\n    wp_enqueue_style( 'wpide-modal' );\n    wp_enqueue_script( 'wpide-modal' );\n\n    $title = '' !== $atts['title'] ? '<h2 id=\"' . esc_attr( $id ) . '-title\">' . esc_html( $atts['title'] ) . '</h2>' : '';\n\n    return sprintf(\n        '<button type=\"button\" class=\"wp-element-button wpide-modal-open\" data-wpide-modal=\"%1$s\" aria-haspopup=\"dialog\">%2$s</button><dialog id=\"%1$s\" class=\"wpide-modal\"%3$s><div class=\"wpide-modal__inner\"><button type=\"button\" class=\"wpide-modal__close\" aria-label=\"Close\">&times;</button>%4$s%5$s</div></dialog>',\n        esc_attr( $id ),\n        esc_html( $atts['button'] ),\n        $title ? ' aria-labelledby=\"' . esc_attr( $id ) . '-title\"' : ' aria-label=\"' . esc_attr( $atts['button'] ) . '\"',\n        $title,\n        wp_kses_post( do_shortcode( shortcode_unautop( wpautop( trim( $content ) ) ) ) )\n    );\n} );",
            "requires": [],
            "notes": "Add title=\"...\" for a heading inside the modal. Escape, the close button and a click on the backdrop all close it; focus returns to the button."
        },
        {
            "id": "frontend-tabs-shortcode",
            "version": 1,
            "title": "Accessible Tabs Shortcode",
            "description": "Adds [wpide_tabs] and [wpide_tab title=\"...\"] shortcodes to build tabbed content that works with mouse, keyboard (arrow keys) and screen readers.",
            "category": "frontend",
            "tags": [
                "tabs",
                "shortcode",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-tabs', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-tabs', '.wpide-tabs__list{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid rgba(0,0,0,.15);margin-bottom:1em;}.wpide-tabs__tab{appearance:none;border:0;background:none;padding:.6em 1em;font:inherit;cursor:pointer;color:inherit;border-bottom:3px solid transparent;margin-bottom:-1px;}.wpide-tabs__tab[aria-selected=\"true\"]{border-bottom-color:currentColor;font-weight:600;}.wpide-tabs__tab:focus-visible{outline:2px solid currentColor;outline-offset:2px;}.wpide-tabs__panel[hidden]{display:none;}' );\n\n    wp_register_script( 'wpide-tabs', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-tabs', <<<'JS'\ndocument.querySelectorAll('.wpide-tabs').forEach(function (wrap) {\n    var panels = Array.prototype.slice.call(wrap.querySelectorAll(':scope > .wpide-tabs__panel'));\n    if (!panels.length) { return; }\n    var list = document.createElement('div');\n    list.className = 'wpide-tabs__list';\n    list.setAttribute('role', 'tablist');\n    var tabs = panels.map(function (panel, i) {\n        var tab = document.createElement('button');\n        tab.type = 'button';\n        tab.className = 'wpide-tabs__tab';\n        tab.id = panel.id + '-tab';\n        tab.textContent = panel.getAttribute('data-title') || ('Tab ' + (i + 1));\n        tab.setAttribute('role', 'tab');\n        tab.setAttribute('aria-controls', panel.id);\n        panel.setAttribute('role', 'tabpanel');\n        panel.setAttribute('aria-labelledby', tab.id);\n        panel.setAttribute('tabindex', '0');\n        list.appendChild(tab);\n        return tab;\n    });\n    function select(index, focus) {\n        tabs.forEach(function (tab, i) {\n            var on = i === index;\n            tab.setAttribute('aria-selected', on ? 'true' : 'false');\n            tab.tabIndex = on ? 0 : -1;\n            panels[i].hidden = !on;\n        });\n        if (focus) { tabs[index].focus(); }\n    }\n    list.addEventListener('click', function (e) {\n        var i = tabs.indexOf(e.target.closest('.wpide-tabs__tab'));\n        if (i > -1) { select(i, false); }\n    });\n    list.addEventListener('keydown', function (e) {\n        var i = tabs.indexOf(document.activeElement), n = tabs.length;\n        if (i < 0) { return; }\n        if (e.key === 'ArrowRight') { select((i + 1) % n, true); }\n        else if (e.key === 'ArrowLeft') { select((i - 1 + n) % n, true); }\n        else if (e.key === 'Home') { select(0, true); }\n        else if (e.key === 'End') { select(n - 1, true); }\n        else { return; }\n        e.preventDefault();\n    });\n    wrap.insertBefore(list, panels[0]);\n    select(0, false);\n});\nJS\n    );\n} );\n\nadd_shortcode( 'wpide_tabs', function ( $atts, $content = '' ) {\n    wp_enqueue_style( 'wpide-tabs' );\n    wp_enqueue_script( 'wpide-tabs' );\n\n    return '<div class=\"wpide-tabs\">' . do_shortcode( shortcode_unautop( trim( $content ) ) ) . '</div>';\n} );\n\nadd_shortcode( 'wpide_tab', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'title' => 'Tab' ), $atts, 'wpide_tab' );\n\n    return sprintf(\n        '<div class=\"wpide-tabs__panel\" id=\"%1$s\" data-title=\"%2$s\">%3$s</div>',\n        esc_attr( 'wpide-tab-' . wp_unique_id() ),\n        esc_attr( $atts['title'] ),\n        wp_kses_post( wpautop( do_shortcode( trim( $content ) ) ) )\n    );\n} );",
            "requires": [],
            "notes": "Example: [wpide_tabs][wpide_tab title=\"Overview\"]Text[/wpide_tab][wpide_tab title=\"Specs\"]Text[/wpide_tab][/wpide_tabs]. Without JavaScript all panels simply show one after another."
        },
        {
            "id": "frontend-image-compare-shortcode",
            "version": 1,
            "title": "Before/After Image Comparison Slider",
            "description": "Adds a [wpide_compare before=\"URL\" after=\"URL\"] shortcode that overlays two images with a draggable divider, perfect for renovations, retouching and redesigns.",
            "category": "frontend",
            "tags": [
                "images",
                "comparison",
                "slider",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-compare', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-compare', '.wpide-compare{position:relative;overflow:hidden;--pos:50%;user-select:none;line-height:0;border-radius:6px;}.wpide-compare img{display:block;width:100%;height:auto;}.wpide-compare__after{position:absolute;inset:0;clip-path:inset(0 0 0 var(--pos));}.wpide-compare__after img{height:100%;object-fit:cover;}.wpide-compare__line{position:absolute;top:0;bottom:0;left:var(--pos);width:3px;margin-left:-1.5px;background:#fff;box-shadow:0 0 6px rgba(0,0,0,.4);pointer-events:none;}.wpide-compare__line::after{content:\"\\2194\";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:40px;height:40px;border-radius:50%;background:#fff;color:#111;font:20px/40px sans-serif;text-align:center;}.wpide-compare input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize;}.wpide-compare input:focus-visible+.wpide-compare__line{background:#2271b1;}.wpide-compare__label{position:absolute;bottom:10px;padding:4px 8px;background:rgba(0,0,0,.6);color:#fff;font:600 12px/1.4 sans-serif;border-radius:3px;pointer-events:none;}' );\n\n    wp_register_script( 'wpide-compare', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-compare', \"document.addEventListener('input',function(e){if(e.target.matches&&e.target.matches('.wpide-compare input')){e.target.closest('.wpide-compare').style.setProperty('--pos',e.target.value+'%');}});\" );\n} );\n\nadd_shortcode( 'wpide_compare', function ( $atts ) {\n    $atts = shortcode_atts( array( 'before' => '', 'after' => '', 'before_label' => 'Before', 'after_label' => 'After', 'alt' => '' ), $atts, 'wpide_compare' );\n\n    $src = function ( $value ) {\n        return ctype_digit( (string) $value ) ? wp_get_attachment_image_url( (int) $value, 'large' ) : $value;\n    };\n\n    $before = $src( $atts['before'] );\n    $after  = $src( $atts['after'] );\n\n    if ( ! $before || ! $after ) {\n        return '';\n    }\n\n    wp_enqueue_style( 'wpide-compare' );\n    wp_enqueue_script( 'wpide-compare' );\n\n    return sprintf(\n        '<div class=\"wpide-compare\"><img src=\"%1$s\" alt=\"%3$s\" loading=\"lazy\" decoding=\"async\"><span class=\"wpide-compare__label\" style=\"left:10px;\">%4$s</span><div class=\"wpide-compare__after\" aria-hidden=\"true\"><img src=\"%2$s\" alt=\"\" loading=\"lazy\" decoding=\"async\"><span class=\"wpide-compare__label\" style=\"right:10px;\">%5$s</span></div><input type=\"range\" min=\"0\" max=\"100\" value=\"50\" aria-label=\"%6$s\"><span class=\"wpide-compare__line\"></span></div>',\n        esc_url( $before ),\n        esc_url( $after ),\n        esc_attr( $atts['alt'] ),\n        esc_html( $atts['before_label'] ),\n        esc_html( $atts['after_label'] ),\n        esc_attr( sprintf( 'Drag to compare %s and %s', $atts['before_label'], $atts['after_label'] ) )\n    );\n} );",
            "requires": [],
            "notes": "Both images should have the same size and aspect ratio. Media Library attachment IDs also work: before=\"123\" after=\"124\". The slider works with touch, mouse and keyboard."
        },
        {
            "id": "frontend-animated-counter-shortcode",
            "version": 1,
            "title": "Animated Number Counter Shortcode",
            "description": "Adds a [wpide_counter to=\"2500\" suffix=\"+\"] shortcode that counts up from zero when the number scrolls into view, for statistics and milestones.",
            "category": "frontend",
            "tags": [
                "counter",
                "animation",
                "shortcode",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_script( 'wpide-counter', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-counter', <<<'JS'\n(function () {\n    var counters = document.querySelectorAll('.wpide-counter[data-to]');\n    if (!counters.length) { return; }\n    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    function format(el, value) {\n        var decimals = parseInt(el.getAttribute('data-decimals') || '0', 10);\n        var text = value.toLocaleString(undefined, { minimumFractionDigits: decimals, maximumFractionDigits: decimals });\n        el.querySelector('.wpide-counter__value').textContent = text;\n    }\n    function run(el) {\n        var from = parseFloat(el.getAttribute('data-from')) || 0;\n        var to = parseFloat(el.getAttribute('data-to')) || 0;\n        var duration = parseInt(el.getAttribute('data-duration') || '1600', 10);\n        if (reduce) { format(el, to); return; }\n        var start = null;\n        function step(time) {\n            if (start === null) { start = time; }\n            var p = Math.min(1, (time - start) / duration);\n            var eased = 1 - Math.pow(1 - p, 3);\n            format(el, from + (to - from) * eased);\n            if (p < 1) { requestAnimationFrame(step); }\n        }\n        requestAnimationFrame(step);\n    }\n    if (!('IntersectionObserver' in window)) { counters.forEach(run); return; }\n    var io = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (entry.isIntersecting) { io.unobserve(entry.target); run(entry.target); }\n        });\n    }, { threshold: 0.4 });\n    counters.forEach(function (el) { format(el, parseFloat(el.getAttribute('data-from')) || 0); io.observe(el); });\n})();\nJS\n    );\n} );\n\nadd_shortcode( 'wpide_counter', function ( $atts ) {\n    $atts = shortcode_atts( array( 'from' => 0, 'to' => 100, 'prefix' => '', 'suffix' => '', 'duration' => 1600, 'decimals' => 0 ), $atts, 'wpide_counter' );\n\n    wp_enqueue_script( 'wpide-counter' );\n\n    $to       = (float) $atts['to'];\n    $decimals = min( 4, absint( $atts['decimals'] ) );\n\n    return sprintf(\n        '<span class=\"wpide-counter\" data-from=\"%1$s\" data-to=\"%2$s\" data-duration=\"%3$d\" data-decimals=\"%4$d\" style=\"font-variant-numeric:tabular-nums;\">%5$s<span class=\"wpide-counter__value\">%6$s</span>%7$s</span>',\n        esc_attr( (float) $atts['from'] ),\n        esc_attr( $to ),\n        absint( $atts['duration'] ),\n        $decimals,\n        esc_html( $atts['prefix'] ),\n        esc_html( number_format_i18n( $to, $decimals ) ),\n        esc_html( $atts['suffix'] )\n    );\n} );",
            "requires": [],
            "notes": "Options: from, to, prefix, suffix, duration (ms) and decimals. Visitors who prefer reduced motion see the final number straight away."
        },
        {
            "id": "frontend-typing-effect-shortcode",
            "version": 1,
            "title": "Typing Text Effect Shortcode",
            "description": "Adds a [wpide_typing words=\"faster|safer|simpler\"] shortcode that types and deletes each word in turn, for lively hero headlines like \"Make your site faster\".",
            "category": "frontend",
            "tags": [
                "animation",
                "typing",
                "headline",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-typing', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-typing', '.wpide-typing__text::after{content:\"\";display:inline-block;width:2px;height:1em;margin-left:2px;vertical-align:-.1em;background:currentColor;animation:wpide-caret 1s steps(1) infinite;}@keyframes wpide-caret{50%{opacity:0;}}@media (prefers-reduced-motion:reduce){.wpide-typing__text::after{display:none;}}.wpide-typing .screen-reader-text{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;}' );\n\n    wp_register_script( 'wpide-typing', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-typing', <<<'JS'\n(function () {\n    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    document.querySelectorAll('.wpide-typing').forEach(function (el) {\n        var words = JSON.parse(el.getAttribute('data-words') || '[]');\n        var out = el.querySelector('.wpide-typing__text');\n        if (!words.length || !out) { return; }\n        if (reduce) { out.textContent = words[0]; return; }\n        var w = 0, c = 0, deleting = false;\n        function tick() {\n            var word = words[w];\n            c += deleting ? -1 : 1;\n            out.textContent = word.slice(0, c);\n            var delay = deleting ? 45 : 90;\n            if (!deleting && c === word.length) { deleting = true; delay = 1600; }\n            else if (deleting && c === 0) { deleting = false; w = (w + 1) % words.length; delay = 350; }\n            setTimeout(tick, delay);\n        }\n        tick();\n    });\n})();\nJS\n    );\n} );\n\nadd_shortcode( 'wpide_typing', function ( $atts ) {\n    $atts  = shortcode_atts( array( 'words' => 'faster|safer|simpler' ), $atts, 'wpide_typing' );\n    $words = array_values( array_filter( array_map( 'trim', explode( '|', wp_strip_all_tags( $atts['words'] ) ) ) ) );\n\n    if ( ! $words ) {\n        return '';\n    }\n\n    wp_enqueue_style( 'wpide-typing' );\n    wp_enqueue_script( 'wpide-typing' );\n\n    return sprintf(\n        '<span class=\"wpide-typing\" data-words=\"%1$s\"><span class=\"screen-reader-text\">%2$s</span><span class=\"wpide-typing__text\" aria-hidden=\"true\">%3$s</span></span>',\n        esc_attr( wp_json_encode( $words ) ),\n        esc_html( implode( ', ', $words ) ),\n        esc_html( $words[0] )\n    );\n} );",
            "requires": [],
            "notes": "Separate words with a vertical bar. Screen readers read the full list once, and visitors who prefer reduced motion see the first word without animation."
        },
        {
            "id": "frontend-tooltip-shortcode",
            "version": 1,
            "title": "Tooltip Shortcode for Terms and Abbreviations",
            "description": "Adds a [wpide_tooltip tip=\"Explanation\"]term[/wpide_tooltip] shortcode that shows a small explanation bubble on hover, tap or keyboard focus.",
            "category": "frontend",
            "tags": [
                "tooltip",
                "glossary",
                "shortcode",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-tooltip', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-tooltip', '.wpide-tip{position:relative;border-bottom:1px dotted currentColor;cursor:help;}.wpide-tip:focus-visible{outline:2px solid currentColor;outline-offset:2px;}.wpide-tip__bubble{position:absolute;left:50%;bottom:calc(100% + 8px);transform:translateX(-50%);width:max-content;max-width:min(260px,80vw);padding:6px 10px;border-radius:6px;background:#1d2327;color:#fff;font-size:.85rem;line-height:1.4;font-weight:400;text-align:center;opacity:0;visibility:hidden;transition:opacity .15s ease;z-index:50;pointer-events:none;}.wpide-tip__bubble::after{content:\"\";position:absolute;top:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-top-color:#1d2327;}.wpide-tip:hover .wpide-tip__bubble,.wpide-tip:focus .wpide-tip__bubble,.wpide-tip:focus-within .wpide-tip__bubble{opacity:1;visibility:visible;}' );\n} );\n\nadd_shortcode( 'wpide_tooltip', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'tip' => '' ), $atts, 'wpide_tooltip' );\n\n    if ( '' === trim( $atts['tip'] ) || '' === trim( $content ) ) {\n        return esc_html( $content );\n    }\n\n    wp_enqueue_style( 'wpide-tooltip' );\n\n    $id = 'wpide-tip-' . wp_unique_id();\n\n    return sprintf(\n        '<span class=\"wpide-tip\" tabindex=\"0\" aria-describedby=\"%1$s\">%2$s<span class=\"wpide-tip__bubble\" role=\"tooltip\" id=\"%1$s\">%3$s</span></span>',\n        esc_attr( $id ),\n        esc_html( $content ),\n        esc_html( $atts['tip'] )\n    );\n} );",
            "requires": [],
            "notes": "The explanation is also exposed to screen readers. Keep tips short; long text belongs in the content."
        },
        {
            "id": "frontend-progress-bar-shortcode",
            "version": 1,
            "title": "Progress Bar Shortcode",
            "description": "Adds a [wpide_progress value=\"75\" label=\"Funding goal\"] shortcode that draws a labeled progress bar, for fundraising goals, skills or project status.",
            "category": "frontend",
            "tags": [
                "progress",
                "shortcode",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-progress', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-progress', '.wpide-progress{margin:1em 0;}.wpide-progress__head{display:flex;justify-content:space-between;gap:1em;font-size:.9em;margin-bottom:.35em;}.wpide-progress__track{height:12px;border-radius:999px;background:rgba(0,0,0,.08);overflow:hidden;}.wpide-progress__bar{height:100%;border-radius:inherit;background:var(--wpide-progress-color,#2271b1);width:var(--wpide-progress,0%);}@media (prefers-reduced-motion:no-preference){.wpide-progress__bar{animation:wpide-progress-fill 1.2s ease-out both;}}@keyframes wpide-progress-fill{from{width:0;}}' );\n} );\n\nadd_shortcode( 'wpide_progress', function ( $atts ) {\n    $atts  = shortcode_atts( array( 'value' => 0, 'max' => 100, 'label' => '', 'color' => '' ), $atts, 'wpide_progress' );\n    $max   = max( 1, (float) $atts['max'] );\n    $value = min( $max, max( 0, (float) $atts['value'] ) );\n    $pct   = round( $value / $max * 100, 1 );\n    $color = sanitize_hex_color( $atts['color'] );\n\n    wp_enqueue_style( 'wpide-progress' );\n\n    return sprintf(\n        '<div class=\"wpide-progress\" style=\"--wpide-progress:%1$s%%;%2$s\"><div class=\"wpide-progress__head\"><span>%3$s</span><span>%4$s%%</span></div><div class=\"wpide-progress__track\" role=\"progressbar\" aria-valuemin=\"0\" aria-valuemax=\"%5$s\" aria-valuenow=\"%6$s\"%7$s><div class=\"wpide-progress__bar\"></div></div></div>',\n        esc_attr( $pct ),\n        $color ? '--wpide-progress-color:' . esc_attr( $color ) . ';' : '',\n        esc_html( $atts['label'] ),\n        esc_html( number_format_i18n( $pct, $pct == (int) $pct ? 0 : 1 ) ), // phpcs:ignore Universal.Operators.StrictComparisons\n        esc_attr( $max ),\n        esc_attr( $value ),\n        $atts['label'] ? ' aria-label=\"' . esc_attr( $atts['label'] ) . '\"' : ''\n    );\n} );",
            "requires": [],
            "notes": "Add color=\"#16a34a\" to change the bar color. The bar fills with a short animation unless the visitor prefers reduced motion."
        },
        {
            "id": "frontend-copy-text-shortcode",
            "version": 1,
            "title": "Click-to-Copy Shortcode for Coupon Codes",
            "description": "Adds a [wpide_copy text=\"SAVE20\"] shortcode that shows the code in a dashed box with a Copy button, and confirms with \"Copied!\" when clicked.",
            "category": "frontend",
            "tags": [
                "coupon",
                "clipboard",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    wp_register_style( 'wpide-copy', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-copy', '.wpide-copy{display:inline-flex;align-items:stretch;border:2px dashed currentColor;border-radius:6px;overflow:hidden;max-width:100%;}.wpide-copy code{padding:.4em .8em;font-size:1.05em;font-weight:700;letter-spacing:.05em;background:none;overflow-wrap:anywhere;}.wpide-copy button{border:0;border-left:2px dashed currentColor;padding:.4em .9em;background:rgba(0,0,0,.05);color:inherit;font:inherit;font-weight:600;cursor:pointer;}.wpide-copy button:hover{background:rgba(0,0,0,.1);}' );\n\n    wp_register_script( 'wpide-copy', false, array(), '1.0', true );\n    wp_add_inline_script( 'wpide-copy', \"document.addEventListener('click',function(e){var b=e.target.closest&&e.target.closest('.wpide-copy button');if(!b){return;}var t=b.parentNode.querySelector('code').textContent;var done=function(){var o=b.textContent;b.textContent=b.getAttribute('data-done');setTimeout(function(){b.textContent=o;},1800);};if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(t).then(done);}else{var a=document.createElement('textarea');a.value=t;document.body.appendChild(a);a.select();try{document.execCommand('copy');done();}catch(x){}document.body.removeChild(a);}});\" );\n} );\n\nadd_shortcode( 'wpide_copy', function ( $atts ) {\n    $atts = shortcode_atts( array( 'text' => '', 'button' => 'Copy', 'done' => 'Copied!' ), $atts, 'wpide_copy' );\n\n    if ( '' === trim( $atts['text'] ) ) {\n        return '';\n    }\n\n    wp_enqueue_style( 'wpide-copy' );\n    wp_enqueue_script( 'wpide-copy' );\n\n    return sprintf(\n        '<span class=\"wpide-copy\"><code>%1$s</code><button type=\"button\" data-done=\"%3$s\" aria-live=\"polite\">%2$s</button></span>',\n        esc_html( $atts['text'] ),\n        esc_html( $atts['button'] ),\n        esc_attr( $atts['done'] )\n    );\n} );",
            "requires": [],
            "notes": "Use it for discount codes, commands, account numbers or any short text visitors need to paste elsewhere."
        },
        {
            "id": "frontend-pricing-period-toggle",
            "version": 1,
            "title": "Monthly/Yearly Pricing Toggle",
            "description": "Adds a switch that flips prices on your pricing page between monthly and yearly amounts, using simple data attributes on any element that shows a price.",
            "category": "frontend",
            "tags": [
                "pricing",
                "toggle",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-price-toggle{display:inline-flex;align-items:center;gap:.6em;font-weight:600;}\n.wpide-price-toggle button{position:relative;width:52px;height:28px;border:0;border-radius:999px;background:#c3c4c7;cursor:pointer;transition:background .2s;}\n.wpide-price-toggle button::after{content:\"\";position:absolute;top:3px;left:3px;width:22px;height:22px;border-radius:50%;background:#fff;transition:transform .2s;}\n.wpide-price-toggle button[aria-checked=\"true\"]{background:#2271b1;}\n.wpide-price-toggle button[aria-checked=\"true\"]::after{transform:translateX(24px);}\n@media (prefers-reduced-motion: reduce){.wpide-price-toggle button,.wpide-price-toggle button::after{transition:none;}}\n[data-wpide-period][hidden]{display:none!important;}\n</style>\n<script>\n(function () {\n    var holders = document.querySelectorAll('[data-wpide-price-toggle]');\n    if (!holders.length) { return; }\n    function apply(yearly) {\n        document.querySelectorAll('[data-monthly][data-yearly]').forEach(function (el) {\n            el.textContent = el.getAttribute(yearly ? 'data-yearly' : 'data-monthly');\n        });\n        document.querySelectorAll('[data-wpide-period]').forEach(function (el) {\n            el.hidden = el.getAttribute('data-wpide-period') !== (yearly ? 'yearly' : 'monthly');\n        });\n        document.querySelectorAll('.wpide-price-toggle button').forEach(function (b) { b.setAttribute('aria-checked', yearly ? 'true' : 'false'); });\n    }\n    holders.forEach(function (holder) {\n        holder.className += ' wpide-price-toggle';\n        holder.innerHTML = '<span>Monthly</span><button type=\"button\" role=\"switch\" aria-checked=\"false\" aria-label=\"Show yearly prices\"></button><span>Yearly</span>';\n        holder.querySelector('button').addEventListener('click', function () {\n            apply(this.getAttribute('aria-checked') !== 'true');\n        });\n    });\n    apply(false);\n})();\n</script>",
            "requires": [],
            "notes": "Add a Custom HTML block with <div data-wpide-price-toggle></div> where the switch should appear, and give price elements data-monthly=\"$19\" data-yearly=\"$190\" (Custom HTML or the block's HTML editing)."
        },
        {
            "id": "frontend-mobile-bottom-nav",
            "version": 1,
            "title": "App-Style Bottom Navigation Bar on Phones",
            "description": "Adds a \"Mobile bottom bar\" menu location and shows that menu as a fixed bar of up to five links at the bottom of the screen on phones, like a mobile app.",
            "category": "frontend",
            "tags": [
                "mobile",
                "navigation",
                "menus"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Screen width below which the bar shows (px).\n$wpide_bottom_nav_breakpoint = 768;\n\nadd_action( 'after_setup_theme', function () {\n    register_nav_menus( array( 'wpide-mobile-bottom' => 'Mobile bottom bar' ) );\n}, 20 );\n\nadd_action( 'wp_footer', function () use ( $wpide_bottom_nav_breakpoint ) {\n    if ( ! has_nav_menu( 'wpide-mobile-bottom' ) ) {\n        return;\n    }\n\n    $bp = absint( $wpide_bottom_nav_breakpoint );\n\n    echo '<style>.wpide-bottom-nav{display:none;}@media (max-width:' . esc_html( $bp - 1 ) . 'px){body{padding-bottom:calc(64px + env(safe-area-inset-bottom));}.wpide-bottom-nav{display:block;position:fixed;left:0;right:0;bottom:0;z-index:9990;background:#fff;border-top:1px solid rgba(0,0,0,.1);padding-bottom:env(safe-area-inset-bottom);box-shadow:0 -2px 10px rgba(0,0,0,.06);}.wpide-bottom-nav ul{display:flex;margin:0;padding:0;list-style:none;}.wpide-bottom-nav li{flex:1;}.wpide-bottom-nav a{display:flex;align-items:center;justify-content:center;min-height:64px;padding:6px;font-size:13px;font-weight:600;text-align:center;text-decoration:none;color:#1d2327;}.wpide-bottom-nav .current-menu-item>a{color:#2271b1;box-shadow:inset 0 3px 0 #2271b1;}}</style>';\n\n    wp_nav_menu( array(\n        'theme_location'  => 'wpide-mobile-bottom',\n        'container'       => 'nav',\n        'container_class' => 'wpide-bottom-nav',\n        'container_aria_label' => 'Quick links',\n        'depth'           => 1,\n        'fallback_cb'     => false,\n    ) );\n} );",
            "requires": [],
            "notes": "Create a short menu (3 to 5 items) in Appearance > Menus and assign it to \"Mobile bottom bar\". Works with classic themes; block themes can assign menus there too."
        },
        {
            "id": "frontend-load-more-button",
            "version": 1,
            "title": "\"Load More\" Button for Blog Archives",
            "description": "Replaces the numbered pagination on blog and category pages with a \"Load more posts\" button that adds the next page of posts without leaving the page.",
            "category": "frontend",
            "tags": [
                "pagination",
                "load-more",
                "archives",
                "javascript"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    var containerSel = '.wp-block-post-template, #main, .site-main';\n    var itemSel = '.wp-block-post-template > li, article.post, article.type-post';\n    var nextSel = '.wp-block-query-pagination-next, .nav-links a.next, a.next.page-numbers';\n    var pagerSel = '.wp-block-query-pagination, .navigation.pagination, .nav-links';\n    var next = document.querySelector(nextSel);\n    var container = document.querySelector(containerSel);\n    if (!next || !container || !window.fetch || !window.DOMParser) { return; }\n    var pager = next.closest(pagerSel) || next.parentNode;\n    var button = document.createElement('button');\n    button.type = 'button';\n    button.className = 'wp-element-button wpide-load-more';\n    button.textContent = 'Load more posts';\n    button.style.cssText = 'display:block;margin:2em auto;';\n    var status = document.createElement('p');\n    status.className = 'screen-reader-text';\n    status.setAttribute('aria-live', 'polite');\n    pager.parentNode.insertBefore(button, pager);\n    pager.parentNode.insertBefore(status, pager);\n    pager.style.display = 'none';\n    var url = next.href;\n    button.addEventListener('click', function () {\n        button.disabled = true;\n        button.textContent = 'Loading…';\n        fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.text(); }).then(function (html) {\n            var doc = new DOMParser().parseFromString(html, 'text/html');\n            var items = doc.querySelectorAll(itemSel);\n            var current = container.querySelectorAll(itemSel);\n            var anchor = current[current.length - 1];\n            items.forEach(function (item) {\n                var node = document.importNode(item, true);\n                if (anchor) { anchor.parentNode.insertBefore(node, anchor.nextSibling); anchor = node; } else { container.appendChild(node); }\n            });\n            status.textContent = items.length + ' more posts loaded';\n            var more = doc.querySelector(nextSel);\n            if (more && more.href) {\n                url = more.href;\n                button.disabled = false;\n                button.textContent = 'Load more posts';\n            } else {\n                button.remove();\n            }\n        }).catch(function () {\n            window.location.href = url;\n        });\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Set the container and post selectors at the top of the script to match your theme (defaults cover most classic and block themes). Without JavaScript the normal pagination stays."
        },
        {
            "id": "frontend-search-overlay",
            "version": 1,
            "title": "Full-Screen Search Overlay",
            "description": "Opens a full-screen search box when visitors click any link to #search (for example a menu item) and closes with Escape, giving small headers a roomy, focused search.",
            "category": "frontend",
            "tags": [
                "search",
                "overlay",
                "menus"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    ?>\n<style>#wpide-search-overlay{position:fixed;inset:0;z-index:100000;background:rgba(17,24,39,.94);display:flex;align-items:flex-start;justify-content:center;padding:15vh 20px 20px;}#wpide-search-overlay[hidden]{display:none;}</style>\n<div id=\"wpide-search-overlay\" role=\"dialog\" aria-modal=\"true\" aria-label=\"<?php echo esc_attr__( 'Search' ); ?>\" hidden>\n    <div style=\"width:100%;max-width:680px;\">\n        <?php get_search_form(); ?>\n        <p style=\"color:#d1d5db;margin-top:1em;font-size:14px;\"><?php echo esc_html__( 'Press Esc to close' ); ?></p>\n    </div>\n    <button type=\"button\" class=\"wpide-search-close\" aria-label=\"<?php echo esc_attr__( 'Close' ); ?>\" style=\"position:absolute;top:16px;right:16px;width:48px;height:48px;border:0;border-radius:50%;background:rgba(255,255,255,.12);color:#fff;font-size:28px;line-height:1;cursor:pointer;\">&times;</button>\n</div>\n<script>\n(function () {\n    var overlay = document.getElementById('wpide-search-overlay');\n    if (!overlay) { return; }\n    var last = null;\n    function open(e) {\n        e.preventDefault();\n        last = document.activeElement;\n        overlay.hidden = false;\n        document.documentElement.style.overflow = 'hidden';\n        var input = overlay.querySelector('input[type=\"search\"], input[name=\"s\"]');\n        if (input) { setTimeout(function () { input.focus(); }, 30); }\n    }\n    function close() {\n        overlay.hidden = true;\n        document.documentElement.style.overflow = '';\n        if (last) { last.focus(); }\n    }\n    document.addEventListener('click', function (e) {\n        var link = e.target.closest ? e.target.closest('a[href$=\"#search\"]') : null;\n        if (link) { open(e); return; }\n        if (e.target === overlay || (e.target.closest && e.target.closest('.wpide-search-close'))) { close(); }\n    });\n    document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !overlay.hidden) { close(); } });\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Add a Custom Link menu item with the URL #search and the label \"Search\". The overlay uses your theme's normal search form."
        },
        {
            "id": "frontend-logo-marquee",
            "version": 1,
            "title": "Scrolling Logo Strip (Marquee)",
            "description": "Turns any block with the class \"wpide-marquee\" (a Gallery, Row or Group of logos) into a smooth, endlessly scrolling strip that pauses on hover.",
            "category": "frontend",
            "tags": [
                "marquee",
                "logos",
                "animation"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-marquee{overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);mask-image:linear-gradient(90deg,transparent,#000 8%,#000 92%,transparent);}\n.wpide-marquee__track{display:flex;width:max-content;gap:var(--wpide-marquee-gap,48px);animation:wpide-marquee var(--wpide-marquee-speed,30s) linear infinite;}\n.wpide-marquee__track>*{flex:0 0 auto;margin:0!important;}\n.wpide-marquee:hover .wpide-marquee__track,.wpide-marquee:focus-within .wpide-marquee__track{animation-play-state:paused;}\n@keyframes wpide-marquee{to{transform:translateX(-50%);}}\n@media (prefers-reduced-motion:reduce){.wpide-marquee{-webkit-mask-image:none;mask-image:none;}.wpide-marquee__track{animation:none;flex-wrap:wrap;width:auto;justify-content:center;}.wpide-marquee__track>[aria-hidden=\"true\"]{display:none;}}\n</style>\n<script>\ndocument.querySelectorAll('.wpide-marquee').forEach(function (el) {\n    if (el.querySelector('.wpide-marquee__track')) { return; }\n    var track = document.createElement('div');\n    track.className = 'wpide-marquee__track';\n    var items = Array.prototype.slice.call(el.children);\n    items.forEach(function (child) { track.appendChild(child); });\n    items.forEach(function (child) {\n        var copy = child.cloneNode(true);\n        copy.setAttribute('aria-hidden', 'true');\n        copy.querySelectorAll('a, button').forEach(function (a) { a.setAttribute('tabindex', '-1'); });\n        track.appendChild(copy);\n    });\n    el.style.display = 'block';\n    el.appendChild(track);\n});\n</script>",
            "requires": [],
            "notes": "Add wpide-marquee under Advanced > Additional CSS class(es). Visitors who prefer reduced motion see a static, wrapping row instead."
        },
        {
            "id": "frontend-image-skeleton-loading",
            "version": 1,
            "title": "Shimmer Placeholders While Images Load",
            "description": "Shows a soft animated \"skeleton\" shimmer in the space of content images until each image has loaded, so pages feel faster and less jumpy on slow connections.",
            "category": "frontend",
            "tags": [
                "images",
                "loading",
                "skeleton",
                "performance"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-skeleton{background:linear-gradient(100deg,#eceff1 30%,#f6f7f8 50%,#eceff1 70%);background-size:300% 100%;animation:wpide-shimmer 1.4s ease-in-out infinite;}\n@keyframes wpide-shimmer{from{background-position:100% 0;}to{background-position:0 0;}}\n@media (prefers-reduced-motion:reduce){.wpide-skeleton{animation:none;}}\n</style>\n<script>\n(function () {\n    var images = document.querySelectorAll('.entry-content img, .wp-block-post-content img, .wp-block-post-featured-image img, .post-thumbnail img');\n    images.forEach(function (img) {\n        if (img.complete && img.naturalWidth) { return; }\n        img.classList.add('wpide-skeleton');\n        var done = function () { img.classList.remove('wpide-skeleton'); };\n        img.addEventListener('load', done, { once: true });\n        img.addEventListener('error', done, { once: true });\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Applies to images in post content and featured images. The shimmer is static for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-mega-menu-scss",
            "version": 1,
            "title": "Mega Menu Layout for Menu Items (SCSS)",
            "description": "Turns the dropdown of any menu item with the CSS class \"mega-menu\" into a wide, multi-column panel, in classic theme menus and the Navigation block.",
            "category": "frontend",
            "tags": [
                "menus",
                "mega-menu",
                "navigation",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$mega-width: min(960px, 92vw);\n$mega-columns: 4;\n$mega-gap: 8px 32px;\n$mega-bg: #ffffff;\n$mega-shadow: 0 16px 40px rgba(0, 0, 0, .14);\n\n@media (min-width: 1024px) {\n    .menu-item.mega-menu,\n    .wp-block-navigation-item.mega-menu {\n        position: static !important;\n\n        > .sub-menu,\n        > .wp-block-navigation__submenu-container {\n            left: 50% !important;\n            right: auto !important;\n            transform: translateX(-50%);\n            width: $mega-width !important;\n            display: grid !important;\n            grid-template-columns: repeat($mega-columns, 1fr);\n            gap: $mega-gap;\n            padding: 24px !important;\n            background: $mega-bg;\n            box-shadow: $mega-shadow;\n            border-radius: 10px;\n\n            > li {\n                width: auto !important;\n            }\n\n            .sub-menu,\n            .wp-block-navigation__submenu-container {\n                position: static !important;\n                display: block !important;\n                opacity: 1 !important;\n                visibility: visible !important;\n                box-shadow: none;\n                border: 0;\n                padding: 0 !important;\n                min-width: 0;\n            }\n        }\n    }\n}",
            "requires": [],
            "notes": "Add mega-menu to the top-level item's CSS Classes (enable the field in Appearance > Menus > Screen Options) or the Navigation submenu block's Additional CSS class. Only applies on screens 1024px and wider."
        },
        {
            "id": "frontend-close-mobile-nav-on-anchor",
            "version": 1,
            "title": "Close the Mobile Menu After Tapping an On-Page Link",
            "description": "Closes the Navigation block's full-screen mobile menu when a visitor taps a link to a section of the same page (like #pricing), so they actually see where they jumped to.",
            "category": "frontend",
            "tags": [
                "navigation",
                "mobile-menu",
                "one-page"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "document.addEventListener('click', function (e) {\n    var link = e.target.closest ? e.target.closest('.wp-block-navigation__responsive-container.is-menu-open a[href*=\"#\"]') : null;\n    if (!link || link.pathname !== window.location.pathname || !link.hash) { return; }\n    var container = link.closest('.wp-block-navigation__responsive-container');\n    var close = container && container.querySelector('.wp-block-navigation__responsive-container-close');\n    if (close) { close.click(); }\n});",
            "requires": [],
            "notes": "For block themes using the Navigation block. Links to other pages work as before."
        },
        {
            "id": "frontend-hide-header-on-scroll",
            "version": 1,
            "title": "Hide the Header on Scroll Down, Show It on Scroll Up",
            "description": "Slides the sticky site header out of view while visitors scroll down to read and brings it back as soon as they scroll up, giving content more room on small screens.",
            "category": "frontend",
            "tags": [
                "header",
                "sticky",
                "scroll",
                "mobile"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-autohide{position:sticky;top:0;z-index:999;transition:transform .25s ease;}\n.admin-bar .wpide-autohide{top:var(--wp-admin--admin-bar--height,32px);}\n.wpide-autohide.is-hidden{transform:translateY(-110%);}\n@media (prefers-reduced-motion:reduce){.wpide-autohide{transition:none;}}\n</style>\n<script>\n(function () {\n    var header = document.querySelector('header.wp-block-template-part, .site-header, #masthead, body > header');\n    if (!header) { return; }\n    header.classList.add('wpide-autohide');\n    var lastY = window.pageYOffset, ticking = false;\n    function update() {\n        var y = window.pageYOffset;\n        var hide = y > lastY && y > header.offsetHeight + 40 && !header.contains(document.activeElement);\n        header.classList.toggle('is-hidden', hide);\n        lastY = y;\n        ticking = false;\n    }\n    window.addEventListener('scroll', function () {\n        if (!ticking) { window.requestAnimationFrame(update); ticking = true; }\n    }, { passive: true });\n})();\n</script>",
            "requires": [],
            "notes": "Change the header selector in the script if your theme uses something other than a header element or .site-header."
        },
        {
            "id": "frontend-transparent-header-over-hero",
            "version": 1,
            "title": "Transparent Header Over the Homepage Hero",
            "description": "Lays the header over the first section of the homepage with a transparent background and white text, then switches to a solid header once visitors scroll.",
            "category": "frontend",
            "tags": [
                "header",
                "homepage",
                "hero",
                "scroll"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-overlay-header{position:fixed;top:0;left:0;right:0;z-index:999;background:transparent;color:#fff;transition:background-color .25s ease,color .25s ease,box-shadow .25s ease;}\n.admin-bar .wpide-overlay-header{top:var(--wp-admin--admin-bar--height,32px);}\n.wpide-overlay-header a{color:inherit;}\n.wpide-overlay-header.is-solid{background:#ffffff;color:#111827;box-shadow:0 2px 12px rgba(0,0,0,.08);}\n@media (prefers-reduced-motion:reduce){.wpide-overlay-header{transition:none;}}\n</style>\n<script>\n(function () {\n    if (!document.body.classList.contains('home')) { return; }\n    var header = document.querySelector('header.wp-block-template-part, .site-header, #masthead');\n    if (!header) { return; }\n    header.classList.add('wpide-overlay-header');\n    var toggle = function () { header.classList.toggle('is-solid', window.pageYOffset > 60); };\n    window.addEventListener('scroll', toggle, { passive: true });\n    toggle();\n})();\n</script>",
            "requires": [],
            "notes": "Works best when the homepage starts with a full-width Cover block. Adjust the solid colors and the header selector at the top."
        },
        {
            "id": "frontend-scrollspy-menu",
            "version": 1,
            "title": "Highlight the Menu Link of the Section in View",
            "description": "Marks the menu link that points to the section currently on screen (for one-page sites with #about, #services links), updating as visitors scroll.",
            "category": "frontend",
            "tags": [
                "navigation",
                "one-page",
                "scroll"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\na.is-current{font-weight:700;text-decoration:underline;text-underline-offset:.3em;}\n</style>\n<script>\n(function () {\n    if (!('IntersectionObserver' in window)) { return; }\n    var links = Array.prototype.filter.call(document.querySelectorAll('nav a[href*=\"#\"]'), function (a) {\n        return a.hash.length > 1 && a.pathname === window.location.pathname && document.getElementById(decodeURIComponent(a.hash.slice(1)));\n    });\n    if (!links.length) { return; }\n    var byId = {};\n    links.forEach(function (a) { var id = decodeURIComponent(a.hash.slice(1)); (byId[id] = byId[id] || []).push(a); });\n    var io = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (!entry.isIntersecting) { return; }\n            links.forEach(function (a) { a.classList.remove('is-current'); a.removeAttribute('aria-current'); });\n            (byId[entry.target.id] || []).forEach(function (a) { a.classList.add('is-current'); a.setAttribute('aria-current', 'location'); });\n        });\n    }, { rootMargin: '-45% 0px -50% 0px' });\n    Object.keys(byId).forEach(function (id) { io.observe(document.getElementById(id)); });\n})();\n</script>",
            "requires": [],
            "notes": "Give each section an HTML anchor (Advanced > HTML anchor) matching the menu links. The active link gets the class is-current and aria-current."
        },
        {
            "id": "frontend-exclusive-details-accordion",
            "version": 1,
            "title": "Open Only One Details Block at a Time",
            "description": "Makes neighboring Details blocks behave like an accordion: opening one question closes the others in the same group, which keeps long FAQs tidy.",
            "category": "frontend",
            "tags": [
                "details",
                "accordion",
                "faq"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "document.addEventListener('toggle', function (e) {\n    var details = e.target;\n    if (!details.matches || !details.matches('details.wp-block-details') || !details.open) { return; }\n    var parent = details.parentElement;\n    if (!parent || parent.classList.contains('wpide-multi-open')) { return; }\n    Array.prototype.forEach.call(parent.children, function (sibling) {\n        if (sibling !== details && sibling.matches('details.wp-block-details') && sibling.open) { sibling.open = false; }\n    });\n}, true);",
            "requires": [],
            "notes": "Applies to Details blocks that sit next to each other in the same parent block. Add the class wpide-multi-open to the parent to allow several open at once."
        },
        {
            "id": "frontend-floating-cta-button",
            "version": 1,
            "title": "Floating Call-to-Action Button After Scrolling",
            "description": "Shows a floating button in the bottom corner once visitors have scrolled past the first screen, for \"Book a call\", \"Get a quote\" or \"Shop now\" links.",
            "category": "frontend",
            "tags": [
                "cta",
                "button",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<a class=\"wpide-floating-cta\" href=\"/contact/\" hidden>Get a free quote</a>\n<style>\n.wpide-floating-cta{position:fixed;right:20px;bottom:20px;z-index:9980;padding:14px 22px;border-radius:999px;background:#111827;color:#fff!important;font-weight:600;text-decoration:none;box-shadow:0 8px 24px rgba(0,0,0,.25);opacity:0;transform:translateY(16px);transition:opacity .25s ease,transform .25s ease;}\n.wpide-floating-cta.is-visible{opacity:1;transform:none;}\n.wpide-floating-cta:focus-visible{outline:3px solid #2271b1;outline-offset:3px;}\n@media (prefers-reduced-motion:reduce){.wpide-floating-cta{transition:none;}}\n@media (max-width:600px){.wpide-floating-cta{left:16px;right:16px;bottom:16px;text-align:center;}}\n</style>\n<script>\n(function () {\n    var cta = document.querySelector('.wpide-floating-cta');\n    if (!cta || cta.pathname === window.location.pathname) { return; }\n    cta.hidden = false;\n    var update = function () { cta.classList.toggle('is-visible', window.pageYOffset > window.innerHeight * 0.8); };\n    window.addEventListener('scroll', update, { passive: true });\n    update();\n})();\n</script>",
            "requires": [],
            "notes": "Edit the link text and URL in the HTML. The button hides on the page it links to."
        },
        {
            "id": "frontend-masonry-gallery",
            "version": 1,
            "title": "Masonry Layout for Gallery Blocks",
            "description": "Shows images in any Gallery block with the class \"wpide-masonry\" in a Pinterest-style masonry layout, keeping each photo's natural shape instead of cropping it.",
            "category": "frontend",
            "tags": [
                "gallery",
                "masonry",
                "images",
                "layout"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wp-block-gallery.wpide-masonry {\n    display: block !important;\n    columns: 3 220px;\n    column-gap: var(--wp--style--gallery-gap-default, 16px);\n}\n\n.wp-block-gallery.wpide-masonry > .wp-block-image {\n    width: 100% !important;\n    margin: 0 0 var(--wp--style--gallery-gap-default, 16px) !important;\n    break-inside: avoid;\n}\n\n.wp-block-gallery.wpide-masonry > .wp-block-image img {\n    width: 100%;\n    height: auto !important;\n    object-fit: initial !important;\n}",
            "requires": [],
            "notes": "Add wpide-masonry to the Gallery block's Additional CSS class(es) and turn off \"Crop images\". Column count follows the available width."
        },
        {
            "id": "frontend-scroll-snap-carousel",
            "version": 1,
            "title": "Swipeable Carousel for Any Block",
            "description": "Turns a Group, Row, Gallery or Query Loop post list with the class \"wpide-carousel\" into a swipeable horizontal carousel with previous/next buttons, using native scroll snapping.",
            "category": "frontend",
            "tags": [
                "carousel",
                "slider",
                "gallery",
                "scroll-snap"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-carousel-wrap{position:relative;}\n.wpide-carousel{display:flex!important;flex-wrap:nowrap!important;gap:16px;overflow-x:auto;scroll-snap-type:x mandatory;scroll-behavior:smooth;scrollbar-width:none;padding-bottom:4px;}\n.wpide-carousel::-webkit-scrollbar{display:none;}\n.wpide-carousel>*{flex:0 0 var(--wpide-slide,80%)!important;max-width:none!important;scroll-snap-align:start;margin:0!important;}\n@media (min-width:782px){.wpide-carousel>*{flex-basis:var(--wpide-slide-desktop,calc(33.333% - 11px))!important;}}\n.wpide-carousel-btn{position:absolute;top:50%;transform:translateY(-50%);width:44px;height:44px;border:0;border-radius:50%;background:#fff;color:#111;box-shadow:0 2px 10px rgba(0,0,0,.2);font-size:22px;line-height:1;cursor:pointer;z-index:2;}\n.wpide-carousel-btn[disabled]{opacity:0;pointer-events:none;}\n.wpide-carousel-btn--prev{left:-10px;}.wpide-carousel-btn--next{right:-10px;}\n@media (prefers-reduced-motion:reduce){.wpide-carousel{scroll-behavior:auto;}}\n</style>\n<script>\ndocument.querySelectorAll('.wpide-carousel').forEach(function (track) {\n    var wrap = document.createElement('div');\n    wrap.className = 'wpide-carousel-wrap';\n    track.parentNode.insertBefore(wrap, track);\n    wrap.appendChild(track);\n    track.setAttribute('tabindex', '0');\n    track.setAttribute('role', 'region');\n    track.setAttribute('aria-label', track.getAttribute('aria-label') || 'Carousel');\n    function make(dir, label, text) {\n        var b = document.createElement('button');\n        b.type = 'button';\n        b.className = 'wpide-carousel-btn wpide-carousel-btn--' + dir;\n        b.setAttribute('aria-label', label);\n        b.textContent = text;\n        b.addEventListener('click', function () {\n            var step = track.firstElementChild ? track.firstElementChild.getBoundingClientRect().width + 16 : track.clientWidth;\n            track.scrollBy({ left: dir === 'next' ? step : -step });\n        });\n        wrap.appendChild(b);\n        return b;\n    }\n    var prev = make('prev', 'Previous', '\\u2039');\n    var next = make('next', 'Next', '\\u203A');\n    function update() {\n        prev.disabled = track.scrollLeft < 5;\n        next.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 5;\n    }\n    track.addEventListener('scroll', update, { passive: true });\n    window.addEventListener('resize', update);\n    update();\n});\n</script>",
            "requires": [],
            "notes": "Add wpide-carousel to the block's Additional CSS class(es). Set the slide width with the --wpide-slide variable (default 80% on phones, 33% on desktop)."
        },
        {
            "id": "frontend-show-more-clamp",
            "version": 1,
            "title": "\"Show More\" Button for Long Sections",
            "description": "Shortens any block with the class \"wpide-show-more\" to a preview height with a fade, and adds a \"Show more\" button that expands it, ideal for long descriptions and terms.",
            "category": "frontend",
            "tags": [
                "read-more",
                "collapse",
                "content"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-show-more.is-clamped{max-height:var(--wpide-preview,220px);overflow:hidden;position:relative;}\n.wpide-show-more.is-clamped::after{content:\"\";position:absolute;left:0;right:0;bottom:0;height:80px;background:linear-gradient(transparent,var(--wp--preset--color--base,#fff));pointer-events:none;}\n.wpide-show-more-btn{display:inline-block;margin-top:.5em;padding:.4em 0;border:0;background:none;color:inherit;font:inherit;font-weight:600;text-decoration:underline;cursor:pointer;}\n</style>\n<script>\ndocument.querySelectorAll('.wpide-show-more').forEach(function (block, i) {\n    var limit = parseInt(getComputedStyle(block).getPropertyValue('--wpide-preview'), 10) || 220;\n    if (block.scrollHeight <= limit + 60) { return; }\n    if (!block.id) { block.id = 'wpide-show-more-' + i; }\n    block.classList.add('is-clamped');\n    var btn = document.createElement('button');\n    btn.type = 'button';\n    btn.className = 'wpide-show-more-btn';\n    btn.setAttribute('aria-controls', block.id);\n    btn.setAttribute('aria-expanded', 'false');\n    btn.textContent = 'Show more';\n    btn.addEventListener('click', function () {\n        var open = block.classList.toggle('is-clamped') === false;\n        btn.setAttribute('aria-expanded', open ? 'true' : 'false');\n        btn.textContent = open ? 'Show less' : 'Show more';\n    });\n    block.insertAdjacentElement('afterend', btn);\n});\n</script>",
            "requires": [],
            "notes": "Change the preview height with the --wpide-preview CSS variable (default 220px). Content shorter than the preview is left alone."
        },
        {
            "id": "frontend-clamp-archive-titles",
            "version": 1,
            "title": "Limit Post Titles and Excerpts to a Few Lines in Grids",
            "description": "Cuts long post titles to two lines and excerpts to three lines in blog grids and Query Loop blocks, so cards line up neatly whatever the text length.",
            "category": "frontend",
            "tags": [
                "archives",
                "query-loop",
                "typography",
                "layout"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wp-block-post-template .wp-block-post-title,\n.blog .entry-title,\n.archive .entry-title {\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 2;\n    line-clamp: 2;\n    overflow: hidden;\n}\n\n.wp-block-post-template .wp-block-post-excerpt__excerpt,\n.blog .entry-summary p,\n.archive .entry-summary p {\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    -webkit-line-clamp: 3;\n    line-clamp: 3;\n    overflow: hidden;\n}",
            "requires": [],
            "notes": "Change the line counts in -webkit-line-clamp. Full titles still show on single posts."
        },
        {
            "id": "frontend-animated-link-underline",
            "version": 1,
            "title": "Animated Underline for Links in Posts",
            "description": "Replaces the plain underline on links inside post content with a thin line that thickens on hover, a subtle touch that keeps links easy to spot.",
            "category": "frontend",
            "tags": [
                "links",
                "typography",
                "hover"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".entry-content a:not(.wp-element-button):not(.wp-block-button__link),\n.wp-block-post-content a:not(.wp-element-button):not(.wp-block-button__link) {\n    text-decoration-line: underline;\n    text-decoration-thickness: 1px;\n    text-underline-offset: 0.2em;\n    transition: text-decoration-thickness 0.15s ease, text-underline-offset 0.15s ease;\n}\n\n.entry-content a:not(.wp-element-button):not(.wp-block-button__link):hover,\n.wp-block-post-content a:not(.wp-element-button):not(.wp-block-button__link):hover {\n    text-decoration-thickness: 3px;\n    text-underline-offset: 0.25em;\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .entry-content a,\n    .wp-block-post-content a {\n        transition: none;\n    }\n}",
            "requires": [],
            "notes": "Buttons are not affected. The transition is skipped for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-lazy-background-images",
            "version": 1,
            "title": "Lazy-Load Background Images",
            "description": "Loads background images only when their section is about to scroll into view, for any element marked with data-wpide-bg=\"image-url\", saving bandwidth on long landing pages.",
            "category": "frontend",
            "tags": [
                "lazy-load",
                "images",
                "performance"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    var els = document.querySelectorAll('[data-wpide-bg]');\n    if (!els.length) { return; }\n    function load(el) {\n        var url = el.getAttribute('data-wpide-bg');\n        if (url) { el.style.backgroundImage = 'url(\"' + url.replace(/[\"\\\\]/g, '') + '\")'; }\n        el.removeAttribute('data-wpide-bg');\n    }\n    if (!('IntersectionObserver' in window)) { els.forEach(load); return; }\n    var io = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (entry.isIntersecting) { io.unobserve(entry.target); load(entry.target); }\n        });\n    }, { rootMargin: '300px 0px' });\n    els.forEach(function (el) { io.observe(el); });\n})();",
            "requires": [],
            "notes": "Add data-wpide-bg=\"https://example.com/image.webp\" to an element in a Custom HTML block or theme template. Elements already on screen load immediately."
        },
        {
            "id": "frontend-sticky-footer",
            "version": 1,
            "title": "Keep the Footer at the Bottom on Short Pages",
            "description": "Stops the footer from floating halfway up the screen on pages with little content, by letting the main area grow to fill the window. Works with block and classic themes.",
            "category": "frontend",
            "tags": [
                "footer",
                "layout",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wp-site-blocks,\n#page.site {\n    display: flex;\n    flex-direction: column;\n    min-height: calc(100vh - var(--wp-admin--admin-bar--height, 0px));\n    min-height: calc(100dvh - var(--wp-admin--admin-bar--height, 0px));\n}\n\n.wp-site-blocks > footer,\n.wp-site-blocks > .wp-block-template-part:last-child,\n#page.site > .site-footer,\n#page.site > #colophon {\n    margin-top: auto;\n}",
            "requires": [],
            "notes": "Classic themes need a wrapper like #page around the content and footer, which most themes have."
        },
        {
            "id": "frontend-mobile-full-height-fix",
            "version": 1,
            "title": "Fix Full-Height Sections on Mobile Browsers",
            "description": "Makes Cover blocks and sections set to 100vh fit the visible screen on phones, where the browser's address bar otherwise hides the bottom of the section and its buttons.",
            "category": "frontend",
            "tags": [
                "mobile",
                "cover",
                "hero",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@supports (height: 100svh) {\n    .wp-block-cover[style*=\"min-height:100vh\"],\n    .wp-block-cover[style*=\"min-height: 100vh\"],\n    .wp-block-group[style*=\"min-height:100vh\"],\n    .wp-block-group[style*=\"min-height: 100vh\"] {\n        min-height: 100svh !important;\n    }\n\n    .admin-bar .wp-block-cover[style*=\"min-height:100vh\"],\n    .admin-bar .wp-block-cover[style*=\"min-height: 100vh\"] {\n        min-height: calc(100svh - var(--wp-admin--admin-bar--height, 0px)) !important;\n    }\n}",
            "requires": [],
            "notes": "Uses the small-viewport unit (svh), supported by all current browsers; older ones keep the original height."
        },
        {
            "id": "frontend-prevent-horizontal-scroll",
            "version": 1,
            "title": "Stop Sideways Scrolling on Phones",
            "description": "Prevents the whole page from scrolling sideways on phones when a wide image, embed or full-width block sticks out a few pixels past the screen edge.",
            "category": "frontend",
            "tags": [
                "mobile",
                "layout",
                "overflow",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "html,\nbody {\n    overflow-x: clip;\n}",
            "requires": [],
            "notes": "Uses overflow-x: clip, which does not break sticky headers the way overflow: hidden does. It hides the symptom; check which element is too wide if you can."
        },
        {
            "id": "frontend-anchor-offset-sticky-header",
            "version": 1,
            "title": "Stop Sticky Headers Covering Jump-Link Targets",
            "description": "Leaves space above headings and sections when visitors follow a #link, so the target is not hidden under a sticky header or the admin bar.",
            "category": "frontend",
            "tags": [
                "anchors",
                "sticky",
                "header",
                "scroll"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":root {\n    --wpide-header-offset: 90px;\n}\n\nhtml {\n    scroll-padding-top: var(--wpide-header-offset);\n}\n\n.admin-bar {\n    scroll-padding-top: calc(var(--wpide-header-offset) + var(--wp-admin--admin-bar--height, 32px));\n}",
            "requires": [],
            "notes": "Set --wpide-header-offset to the height of your sticky header."
        },
        {
            "id": "frontend-dropdown-keyboard-access",
            "version": 1,
            "title": "Open Dropdown Menus With the Keyboard",
            "description": "Shows a classic theme's dropdown submenu while a keyboard user tabs through it, for themes whose dropdowns only open on mouse hover.",
            "category": "frontend",
            "tags": [
                "menus",
                "keyboard",
                "accessibility",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".main-navigation .menu-item-has-children:focus-within > .sub-menu,\n#site-navigation .menu-item-has-children:focus-within > .sub-menu,\nnav .menu-item-has-children:focus-within > .sub-menu {\n    display: block;\n    left: auto;\n    opacity: 1;\n    visibility: visible;\n    clip: auto;\n    pointer-events: auto;\n}",
            "requires": [],
            "notes": "Adjust the selector if your theme's menu is not inside .main-navigation, #site-navigation or nav."
        },
        {
            "id": "frontend-password-visibility-toggle",
            "version": 1,
            "title": "Show/Hide Button on Front-End Password Fields",
            "description": "Adds an accessible \"Show\" button inside password fields on the front of the site (account, checkout and membership forms), so visitors can check what they typed.",
            "category": "frontend",
            "tags": [
                "forms",
                "passwords",
                "usability",
                "accessibility"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-pw-wrap{position:relative;display:block;}\n.wpide-pw-wrap input{padding-right:4.5em!important;width:100%;}\n.wpide-pw-toggle{position:absolute;top:50%;right:6px;transform:translateY(-50%);padding:4px 8px;border:0;border-radius:4px;background:transparent;color:inherit;font-size:.85em;font-weight:600;cursor:pointer;}\n.wpide-pw-toggle:focus-visible{outline:2px solid currentColor;}\n</style>\n<script>\ndocument.querySelectorAll('input[type=\"password\"]').forEach(function (input) {\n    if (input.closest('.password-input, .wp-pwd, .wpide-pw-wrap') || input.nextElementSibling && input.nextElementSibling.matches('button, .show-password-input')) { return; }\n    var wrap = document.createElement('span');\n    wrap.className = 'wpide-pw-wrap';\n    input.parentNode.insertBefore(wrap, input);\n    wrap.appendChild(input);\n    var btn = document.createElement('button');\n    btn.type = 'button';\n    btn.className = 'wpide-pw-toggle';\n    btn.textContent = 'Show';\n    btn.setAttribute('aria-pressed', 'false');\n    btn.setAttribute('aria-label', 'Show password');\n    btn.addEventListener('click', function () {\n        var show = input.type === 'password';\n        input.type = show ? 'text' : 'password';\n        btn.textContent = show ? 'Hide' : 'Show';\n        btn.setAttribute('aria-pressed', show ? 'true' : 'false');\n        btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');\n        input.focus();\n    });\n    wrap.appendChild(btn);\n});\n</script>",
            "requires": [],
            "notes": "Fields that already have a toggle (such as WooCommerce's) are skipped. The login page has its own built-in button."
        },
        {
            "id": "frontend-share-selected-text",
            "version": 1,
            "title": "Share or Copy a Highlighted Quote",
            "description": "Shows a small toolbar when readers highlight text in a post, with buttons to copy the quote with a link back or share it on X, Bluesky or LinkedIn.",
            "category": "frontend",
            "tags": [
                "sharing",
                "quotes",
                "social"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div class=\"wpide-quote-share\" hidden role=\"toolbar\" aria-label=\"Share selection\"></div>\n<style>\n.wpide-quote-share{position:absolute;z-index:9999;display:flex;gap:2px;padding:4px;border-radius:8px;background:#1d2327;box-shadow:0 6px 18px rgba(0,0,0,.25);transform:translate(-50%,-100%);}\n.wpide-quote-share[hidden]{display:none;}\n.wpide-quote-share button{border:0;background:none;color:#fff;font:600 13px/1 sans-serif;padding:8px 10px;border-radius:5px;cursor:pointer;}\n.wpide-quote-share button:hover,.wpide-quote-share button:focus-visible{background:rgba(255,255,255,.15);}\n</style>\n<script>\n(function () {\n    var bar = document.querySelector('.wpide-quote-share');\n    var area = document.querySelector('.entry-content, .wp-block-post-content');\n    if (!bar || !area || !window.getSelection) { return; }\n    var targets = [\n        ['X', 'https://x.com/intent/post?text='],\n        ['Bluesky', 'https://bsky.app/intent/compose?text='],\n        ['LinkedIn', 'https://www.linkedin.com/feed/?shareActive=true&text=']\n    ];\n    var text = '';\n    function quote() { return '\\u201C' + text.slice(0, 220) + (text.length > 220 ? '\\u2026' : '') + '\\u201D ' + window.location.href.split('#')[0]; }\n    var copy = document.createElement('button');\n    copy.type = 'button';\n    copy.textContent = 'Copy';\n    copy.addEventListener('click', function () {\n        if (navigator.clipboard) { navigator.clipboard.writeText(quote()); copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1500); }\n    });\n    bar.appendChild(copy);\n    targets.forEach(function (t) {\n        var b = document.createElement('button');\n        b.type = 'button';\n        b.textContent = t[0];\n        b.addEventListener('click', function () { window.open(t[1] + encodeURIComponent(quote()), '_blank', 'noopener,width=600,height=520'); });\n        bar.appendChild(b);\n    });\n    document.addEventListener('mouseup', function (e) {\n        if (bar.contains(e.target)) { return; }\n        setTimeout(function () {\n            var sel = window.getSelection();\n            text = sel ? sel.toString().trim() : '';\n            if (text.length < 12 || !sel.rangeCount || !area.contains(sel.anchorNode)) { bar.hidden = true; return; }\n            var rect = sel.getRangeAt(0).getBoundingClientRect();\n            bar.style.left = (rect.left + rect.width / 2 + window.pageXOffset) + 'px';\n            bar.style.top = (rect.top + window.pageYOffset - 8) + 'px';\n            bar.hidden = false;\n        }, 10);\n    });\n    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { bar.hidden = true; } });\n})();\n</script>",
            "requires": [],
            "notes": "Nothing is loaded from social networks until a reader clicks a share button. Remove a network by deleting its line in the targets list."
        },
        {
            "id": "frontend-sticky-table-headers",
            "version": 1,
            "title": "Sticky Header Row in Long Tables",
            "description": "Keeps the header row of long tables in posts visible while visitors scroll down through the rows, so they always know which column is which.",
            "category": "frontend",
            "tags": [
                "tables",
                "sticky",
                "readability"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":root {\n    --wpide-table-top: 0px;\n}\n\n.wp-block-table thead th,\n.entry-content table thead th {\n    position: sticky;\n    top: var(--wpide-table-top);\n    z-index: 1;\n    background: var(--wp--preset--color--base, #ffffff);\n    box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.15);\n}\n\n.admin-bar .wp-block-table thead th,\n.admin-bar .entry-content table thead th {\n    top: calc(var(--wpide-table-top) + var(--wp-admin--admin-bar--height, 32px));\n}\n\n.wp-block-table:has(thead) {\n    overflow: visible;\n}",
            "requires": [],
            "notes": "Only works when the table is not inside a horizontally scrolling wrapper. Set --wpide-table-top to your sticky header height."
        },
        {
            "id": "frontend-animated-gradient-background",
            "version": 1,
            "title": "Animated Gradient Background Class",
            "description": "Adds a \"wpide-animated-gradient\" class that gives any Group or Cover block a slowly shifting multi-color gradient background, for eye-catching hero and CTA sections.",
            "category": "frontend",
            "tags": [
                "gradient",
                "animation",
                "background"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-animated-gradient {\n    background: linear-gradient(120deg, #6366f1, #ec4899, #f59e0b, #10b981);\n    background-size: 300% 300%;\n    animation: wpide-gradient-shift 18s ease infinite;\n}\n\n@keyframes wpide-gradient-shift {\n    0% {\n        background-position: 0% 50%;\n    }\n\n    50% {\n        background-position: 100% 50%;\n    }\n\n    100% {\n        background-position: 0% 50%;\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wpide-animated-gradient {\n        animation: none;\n    }\n}",
            "requires": [],
            "notes": "Change the four colors in the gradient. The movement stops for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-glass-card-scss",
            "version": 1,
            "title": "Frosted Glass Card Style (SCSS)",
            "description": "Adds a \"wpide-glass\" class for a frosted-glass look (blurred, semi-transparent background and fine border), great for cards placed over photos or gradients.",
            "category": "frontend",
            "tags": [
                "design",
                "cards",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$glass-tint: #ffffff;\n$glass-opacity: .16;\n$glass-blur: 14px;\n$glass-radius: 16px;\n\n.wpide-glass {\n    padding: 1.5rem;\n    border-radius: $glass-radius;\n    border: 1px solid rgba($glass-tint, .35);\n    background: rgba($glass-tint, .85);\n    box-shadow: 0 8px 32px rgba(0, 0, 0, .15);\n\n    @supports ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {\n        background: rgba($glass-tint, $glass-opacity);\n        -webkit-backdrop-filter: blur($glass-blur) saturate(160%);\n        backdrop-filter: blur($glass-blur) saturate(160%);\n    }\n}",
            "requires": [],
            "notes": "Put a Group with the class wpide-glass inside a Cover block for the best effect. Browsers without backdrop blur get a solid fallback."
        },
        {
            "id": "frontend-hover-play-videos",
            "version": 1,
            "title": "Play Videos on Hover",
            "description": "Plays muted Video blocks with the class \"wpide-hover-play\" while the mouse is over them and pauses when it leaves, like product previews in online stores.",
            "category": "frontend",
            "tags": [
                "video",
                "hover",
                "media"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "document.querySelectorAll('.wpide-hover-play video').forEach(function (video) {\n    video.muted = true;\n    video.playsInline = true;\n    video.preload = 'metadata';\n    video.removeAttribute('autoplay');\n    var play = function () { var p = video.play(); if (p && p.catch) { p.catch(function () {}); } };\n    video.addEventListener('mouseenter', play);\n    video.addEventListener('focus', play);\n    video.addEventListener('mouseleave', function () { video.pause(); });\n    video.addEventListener('blur', function () { video.pause(); });\n    video.addEventListener('touchstart', function () { if (video.paused) { play(); } else { video.pause(); } }, { passive: true });\n});",
            "requires": [],
            "notes": "Add wpide-hover-play to the Video block's Additional CSS class(es). On touch screens a tap plays and pauses instead."
        },
        {
            "id": "frontend-pause-offscreen-videos",
            "version": 1,
            "title": "Pause Autoplaying Videos When Off Screen",
            "description": "Pauses background and autoplaying videos when they scroll out of view or the browser tab is hidden, and resumes them when they come back, saving battery and data.",
            "category": "frontend",
            "tags": [
                "video",
                "performance",
                "media"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    var videos = Array.prototype.filter.call(document.querySelectorAll('video[autoplay]'), function (v) { return v.muted; });\n    if (!videos.length || !('IntersectionObserver' in window)) { return; }\n    var visible = new Set();\n    function resume(v) { var p = v.play(); if (p && p.catch) { p.catch(function () {}); } }\n    var io = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (entry.isIntersecting) { visible.add(entry.target); if (!document.hidden) { resume(entry.target); } }\n            else { visible.delete(entry.target); entry.target.pause(); }\n        });\n    }, { threshold: 0.1 });\n    videos.forEach(function (v) { io.observe(v); });\n    document.addEventListener('visibilitychange', function () {\n        videos.forEach(function (v) { if (document.hidden) { v.pause(); } else if (visible.has(v)) { resume(v); } });\n    });\n})();",
            "requires": [],
            "notes": "Only affects videos that autoplay muted, such as Cover block background videos."
        },
        {
            "id": "frontend-offline-notice",
            "version": 1,
            "title": "\"You Are Offline\" Notice",
            "description": "Shows a small notice at the bottom of the screen when the visitor loses their internet connection and hides it when the connection returns, so failed clicks make sense.",
            "category": "frontend",
            "tags": [
                "offline",
                "notice",
                "mobile"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div class=\"wpide-offline\" role=\"status\" aria-live=\"polite\" hidden>You are offline. Some features may not work until your connection is back.</div>\n<style>\n.wpide-offline{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);z-index:100001;max-width:calc(100vw - 32px);padding:10px 16px;border-radius:8px;background:#1d2327;color:#fff;font-size:14px;box-shadow:0 6px 20px rgba(0,0,0,.25);}\n.wpide-offline[hidden]{display:none;}\n</style>\n<script>\n(function () {\n    var note = document.querySelector('.wpide-offline');\n    if (!note) { return; }\n    function update() { note.hidden = navigator.onLine !== false; }\n    window.addEventListener('online', update);\n    window.addEventListener('offline', update);\n    update();\n})();\n</script>",
            "requires": [],
            "notes": "Edit the message text in the HTML."
        },
        {
            "id": "frontend-smooth-details-animation",
            "version": 1,
            "title": "Smooth Open and Close Animation for Details Blocks",
            "description": "Animates Details (accordion) blocks so the answer slides open and closed smoothly instead of jumping, in browsers that support animating to auto height.",
            "category": "frontend",
            "tags": [
                "details",
                "accordion",
                "animation",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: no-preference) {\n    :root {\n        interpolate-size: allow-keywords;\n    }\n\n    details::details-content {\n        height: 0;\n        overflow: clip;\n        transition: height 0.3s ease, content-visibility 0.3s allow-discrete;\n    }\n\n    details[open]::details-content {\n        height: auto;\n    }\n}",
            "requires": [],
            "notes": "Supported in recent Chrome and Edge; other browsers open instantly as before. Off for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-pretty-text-wrapping",
            "version": 1,
            "title": "Nicer Line Breaks in Paragraphs and Headings",
            "description": "Avoids single orphan words on the last line of paragraphs and balances heading lines, using modern CSS text wrapping, for more polished typography.",
            "category": "frontend",
            "tags": [
                "typography",
                "readability",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "p,\nli,\nfigcaption,\nblockquote {\n    text-wrap: pretty;\n}\n\nh1,\nh2,\nh3,\nh4,\n.wp-block-post-title {\n    text-wrap: balance;\n}",
            "requires": [],
            "notes": "Browsers without support simply wrap text as usual."
        },
        {
            "id": "frontend-uniform-archive-thumbnails",
            "version": 1,
            "title": "Same-Size Thumbnails on Blog and Archive Pages",
            "description": "Crops featured images on blog, category and search pages to the same 16:9 shape, so post grids line up evenly even when images have different sizes.",
            "category": "frontend",
            "tags": [
                "archives",
                "thumbnails",
                "images",
                "layout"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".blog .post-thumbnail img,\n.archive .post-thumbnail img,\n.search .post-thumbnail img,\n.wp-block-post-template .wp-block-post-featured-image img {\n    width: 100%;\n    height: auto;\n    aspect-ratio: 16 / 9;\n    object-fit: cover;\n}",
            "requires": [],
            "notes": "Change aspect-ratio to 4 / 3 or 1 for other shapes. Single posts show the full image."
        },
        {
            "id": "frontend-menu-button-class",
            "version": 1,
            "title": "Turn a Menu Item Into a Button",
            "description": "Styles any menu item with the CSS class \"menu-button\" as a solid call-to-action button, in classic theme menus and the Navigation block.",
            "category": "frontend",
            "tags": [
                "menus",
                "buttons",
                "cta",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".menu-item.menu-button > a,\n.wp-block-navigation-item.menu-button > .wp-block-navigation-item__content {\n    display: inline-block;\n    padding: 0.55em 1.2em !important;\n    border-radius: 999px;\n    background: #111827;\n    color: #ffffff !important;\n    font-weight: 600;\n    line-height: 1.2;\n    text-decoration: none;\n    transition: background-color 0.15s ease;\n}\n\n.menu-item.menu-button > a:hover,\n.menu-item.menu-button > a:focus-visible,\n.wp-block-navigation-item.menu-button > .wp-block-navigation-item__content:hover,\n.wp-block-navigation-item.menu-button > .wp-block-navigation-item__content:focus-visible {\n    background: #374151;\n}",
            "requires": [],
            "notes": "Add menu-button to the item's CSS Classes (Appearance > Menus, enable it in Screen Options) or to a Navigation link block's Additional CSS class. Change the colors here."
        },
        {
            "id": "frontend-subtle-parallax-images",
            "version": 1,
            "title": "Subtle Parallax Effect for Images",
            "description": "Gives images inside blocks with the class \"wpide-parallax\" a gentle parallax movement while scrolling, adding depth to hero and feature sections.",
            "category": "frontend",
            "tags": [
                "parallax",
                "images",
                "animation",
                "scroll"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-parallax{overflow:hidden;}\n.wpide-parallax img{will-change:transform;transform:scale(1.15);}\n@media (prefers-reduced-motion:reduce){.wpide-parallax img{transform:none;}}\n</style>\n<script>\n(function () {\n    if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }\n    var items = Array.prototype.map.call(document.querySelectorAll('.wpide-parallax img'), function (img) {\n        var box = img.closest('.wpide-parallax');\n        return { img: img, box: box, speed: parseFloat(box.getAttribute('data-speed')) || 0.15 };\n    });\n    if (!items.length) { return; }\n    var ticking = false;\n    function update() {\n        var vh = window.innerHeight;\n        items.forEach(function (it) {\n            var r = it.box.getBoundingClientRect();\n            if (r.bottom < 0 || r.top > vh) { return; }\n            var offset = (r.top + r.height / 2 - vh / 2) * -it.speed;\n            it.img.style.transform = 'translate3d(0,' + offset.toFixed(1) + 'px,0) scale(1.15)';\n        });\n        ticking = false;\n    }\n    window.addEventListener('scroll', function () { if (!ticking) { ticking = true; requestAnimationFrame(update); } }, { passive: true });\n    window.addEventListener('resize', update);\n    update();\n})();\n</script>",
            "requires": [],
            "notes": "Add wpide-parallax to an Image or Group block. Set the strength with data-speed (not available in the editor), default 0.15. No movement for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-scroll-hint-arrow",
            "version": 1,
            "title": "Bouncing \"Scroll Down\" Arrow on Hero Sections",
            "description": "Adds a gently bouncing arrow to the bottom of any Cover block with the class \"wpide-scroll-hint\", inviting visitors to scroll past a full-screen hero.",
            "category": "frontend",
            "tags": [
                "hero",
                "cover",
                "animation",
                "scroll"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wp-block-cover.wpide-scroll-hint {\n    position: relative;\n}\n\n.wp-block-cover.wpide-scroll-hint::after {\n    content: \"\";\n    position: absolute;\n    left: 50%;\n    bottom: 28px;\n    width: 18px;\n    height: 18px;\n    margin-left: -9px;\n    border-right: 3px solid currentColor;\n    border-bottom: 3px solid currentColor;\n    transform: rotate(45deg);\n    opacity: 0.8;\n    z-index: 2;\n    animation: wpide-scroll-bounce 1.8s ease-in-out infinite;\n}\n\n@keyframes wpide-scroll-bounce {\n    0%,\n    100% {\n        transform: translateY(0) rotate(45deg);\n    }\n\n    50% {\n        transform: translateY(8px) rotate(45deg);\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .wp-block-cover.wpide-scroll-hint::after {\n        animation: none;\n    }\n}",
            "requires": [],
            "notes": "Add wpide-scroll-hint to the Cover block's Additional CSS class(es). The arrow is decorative and hidden from screen readers."
        },
        {
            "id": "frontend-collapsible-widgets-mobile",
            "version": 1,
            "title": "Collapsible Sidebar Widgets on Phones",
            "description": "Folds sidebar and footer widgets into tap-to-open sections on small screens, so visitors reach the content and footer without scrolling past long widget lists.",
            "category": "frontend",
            "tags": [
                "widgets",
                "mobile",
                "sidebar",
                "accordion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-widget-toggle{display:flex;justify-content:space-between;align-items:center;width:100%;padding:.6em 0;border:0;border-bottom:1px solid rgba(0,0,0,.12);background:none;color:inherit;font:inherit;font-weight:700;text-align:left;cursor:pointer;}\n.wpide-widget-toggle::after{content:\"+\";font-size:1.3em;line-height:1;}\n.wpide-widget-toggle[aria-expanded=\"true\"]::after{content:\"\\2212\";}\n.wpide-widget-body[hidden]{display:none;}\n</style>\n<script>\n(function () {\n    var breakpoint = 768;\n    if (window.innerWidth >= breakpoint) { return; }\n    document.querySelectorAll('.widget-area .widget, .sidebar .widget, .widget-area .wp-block-group').forEach(function (widget, i) {\n        var title = widget.querySelector(':scope > .widget-title, :scope > .widgettitle, :scope > h2, :scope > .wp-block-heading');\n        if (!title || widget.querySelector('.wpide-widget-toggle')) { return; }\n        var body = document.createElement('div');\n        body.className = 'wpide-widget-body';\n        body.id = 'wpide-widget-body-' + i;\n        body.hidden = true;\n        while (title.nextSibling) { body.appendChild(title.nextSibling); }\n        var btn = document.createElement('button');\n        btn.type = 'button';\n        btn.className = 'wpide-widget-toggle';\n        btn.setAttribute('aria-expanded', 'false');\n        btn.setAttribute('aria-controls', body.id);\n        btn.textContent = title.textContent;\n        btn.addEventListener('click', function () {\n            var open = btn.getAttribute('aria-expanded') !== 'true';\n            btn.setAttribute('aria-expanded', open ? 'true' : 'false');\n            body.hidden = !open;\n        });\n        title.textContent = '';\n        title.appendChild(btn);\n        widget.appendChild(body);\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Works with widgets that have a title (classic widget titles or a Heading block as the first item). Change the breakpoint at the top of the script."
        },
        {
            "id": "frontend-rotating-testimonials",
            "version": 1,
            "title": "Rotating Testimonials",
            "description": "Shows the items inside any block with the class \"wpide-rotator\" (for example Quote blocks in a Group) one at a time, rotating every few seconds with dot buttons to pick one.",
            "category": "frontend",
            "tags": [
                "testimonials",
                "slider",
                "quotes"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-rotator>.wpide-rotator__item[hidden]{display:none;}\n.wpide-rotator__dots{display:flex;justify-content:center;gap:8px;margin-top:1em;}\n.wpide-rotator__dots button{width:12px;height:12px;padding:0;border:2px solid currentColor;border-radius:50%;background:transparent;cursor:pointer;opacity:.6;}\n.wpide-rotator__dots button[aria-current=\"true\"]{background:currentColor;opacity:1;}\n@media (prefers-reduced-motion:no-preference){.wpide-rotator__item{animation:wpide-rotator-in .5s ease;}}\n@keyframes wpide-rotator-in{from{opacity:0;}to{opacity:1;}}\n</style>\n<script>\ndocument.querySelectorAll('.wpide-rotator').forEach(function (box) {\n    var items = Array.prototype.slice.call(box.children);\n    if (items.length < 2) { return; }\n    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    var dots = document.createElement('div');\n    dots.className = 'wpide-rotator__dots';\n    var current = 0, timer = null;\n    function show(i) {\n        current = (i + items.length) % items.length;\n        items.forEach(function (item, n) { item.hidden = n !== current; });\n        Array.prototype.forEach.call(dots.children, function (d, n) { d.setAttribute('aria-current', n === current ? 'true' : 'false'); });\n    }\n    items.forEach(function (item, n) {\n        item.classList.add('wpide-rotator__item');\n        var d = document.createElement('button');\n        d.type = 'button';\n        d.setAttribute('aria-label', 'Show item ' + (n + 1));\n        d.addEventListener('click', function () { show(n); });\n        dots.appendChild(d);\n    });\n    box.appendChild(dots);\n    show(0);\n    function start() { if (!reduce) { stop(); timer = setInterval(function () { show(current + 1); }, 6000); } }\n    function stop() { if (timer) { clearInterval(timer); timer = null; } }\n    box.addEventListener('mouseenter', stop);\n    box.addEventListener('mouseleave', start);\n    box.addEventListener('focusin', stop);\n    box.addEventListener('focusout', start);\n    start();\n});\n</script>",
            "requires": [],
            "notes": "Add wpide-rotator to a Group containing your quotes. Rotation pauses on hover and keyboard focus, and is off for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-fixed-background-ios-fix",
            "version": 1,
            "title": "Fix \"Fixed Background\" Cover Blocks on iPhone and iPad",
            "description": "Switches Cover blocks set to a fixed (parallax) background to a normal scrolling background on touch devices, where fixed backgrounds look blurry, zoomed or don't show at all.",
            "category": "frontend",
            "tags": [
                "cover",
                "mobile",
                "ios",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (hover: none), (pointer: coarse) {\n    .wp-block-cover.has-parallax,\n    .wp-block-cover__image-background.has-parallax,\n    .wp-block-cover .has-parallax {\n        background-attachment: scroll !important;\n    }\n}",
            "requires": [],
            "notes": "Desktop browsers keep the fixed background effect."
        },
        {
            "id": "frontend-ken-burns-cover",
            "version": 1,
            "title": "Slow Zoom (Ken Burns) Effect for Cover Images",
            "description": "Adds a slow, cinematic zoom to the background image of any Cover block with the class \"wpide-ken-burns\", for more lively hero sections without video.",
            "category": "frontend",
            "tags": [
                "cover",
                "animation",
                "hero",
                "images"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wp-block-cover.wpide-ken-burns {\n    overflow: hidden;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n    .wp-block-cover.wpide-ken-burns .wp-block-cover__image-background {\n        animation: wpide-ken-burns 24s ease-in-out infinite alternate;\n        transform-origin: 60% 40%;\n    }\n}\n\n@keyframes wpide-ken-burns {\n    from {\n        transform: scale(1);\n    }\n\n    to {\n        transform: scale(1.12);\n    }\n}",
            "requires": [],
            "notes": "Add wpide-ken-burns to the Cover block's Additional CSS class(es). Off for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-timeline-scss",
            "version": 1,
            "title": "Vertical Timeline Style for Groups (SCSS)",
            "description": "Turns the blocks inside a Group with the class \"wpide-timeline\" into a vertical timeline with a line and a dot for each step, for histories, roadmaps and processes.",
            "category": "frontend",
            "tags": [
                "timeline",
                "layout",
                "design",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$timeline-color: #2271b1;\n$timeline-line: 2px;\n$timeline-dot: 14px;\n$timeline-indent: 2rem;\n\n.wpide-timeline {\n    position: relative;\n    padding-left: $timeline-indent !important;\n\n    &::before {\n        content: \"\";\n        position: absolute;\n        top: .4em;\n        bottom: .4em;\n        left: calc(#{$timeline-dot} / 2 - #{$timeline-line} / 2);\n        width: $timeline-line;\n        background: rgba($timeline-color, .3);\n    }\n\n    > * {\n        position: relative;\n        margin-bottom: 1.75rem;\n\n        &::before {\n            content: \"\";\n            position: absolute;\n            top: .35em;\n            left: -$timeline-indent;\n            width: $timeline-dot;\n            height: $timeline-dot;\n            border-radius: 50%;\n            background: $timeline-color;\n            box-shadow: 0 0 0 4px rgba($timeline-color, .15);\n        }\n    }\n}",
            "requires": [],
            "notes": "Put one Group (or heading + paragraph pair wrapped in a Group) per step inside the timeline Group."
        },
        {
            "id": "frontend-corner-ribbons-scss",
            "version": 1,
            "title": "Corner Ribbons: \"New\", \"Sale\" and \"Popular\" (SCSS)",
            "description": "Adds classes that put a diagonal corner ribbon on any block (wpide-ribbon-new, wpide-ribbon-sale, wpide-ribbon-popular), handy for pricing columns and product cards.",
            "category": "frontend",
            "tags": [
                "ribbon",
                "badge",
                "pricing",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$ribbons: (\n    new: (\"New\", #16a34a),\n    sale: (\"Sale\", #dc2626),\n    popular: (\"Most popular\", #7c3aed),\n);\n\n[class*=\"wpide-ribbon-\"] {\n    position: relative;\n    overflow: hidden;\n}\n\n@each $name, $ribbon in $ribbons {\n    .wpide-ribbon-#{$name}::after {\n        content: nth($ribbon, 1);\n        position: absolute;\n        top: 22px;\n        right: -46px;\n        width: 170px;\n        padding: 5px 0;\n        transform: rotate(45deg);\n        background: nth($ribbon, 2);\n        color: #fff;\n        font-size: 12px;\n        font-weight: 700;\n        letter-spacing: .04em;\n        text-align: center;\n        text-transform: uppercase;\n        box-shadow: 0 2px 6px rgba(0, 0, 0, .2);\n        pointer-events: none;\n    }\n}",
            "requires": [],
            "notes": "Add the class to a Group or Column block. Add your own ribbons to the $ribbons map."
        },
        {
            "id": "frontend-wave-divider",
            "version": 1,
            "title": "Wave Edge at the Bottom of Sections",
            "description": "Gives any Group or Cover block with the class \"wpide-wave-bottom\" a soft wavy bottom edge instead of a straight line, for friendlier section transitions.",
            "category": "frontend",
            "tags": [
                "dividers",
                "sections",
                "design",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-wave-bottom {\n    --wpide-wave-height: 48px;\n    padding-bottom: calc(var(--wpide-wave-height) + 1rem) !important;\n    -webkit-mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 60' preserveAspectRatio='none'%3E%3Cpath d='M0 0h1200v30c-150 30-300 30-450 0S450 0 300 30 75 60 0 30z'/%3E%3C/svg%3E\") bottom / 100% var(--wpide-wave-height) no-repeat, linear-gradient(#000, #000) top / 100% calc(100% - var(--wpide-wave-height) + 1px) no-repeat;\n    mask: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 60' preserveAspectRatio='none'%3E%3Cpath d='M0 0h1200v30c-150 30-300 30-450 0S450 0 300 30 75 60 0 30z'/%3E%3C/svg%3E\") bottom / 100% var(--wpide-wave-height) no-repeat, linear-gradient(#000, #000) top / 100% calc(100% - var(--wpide-wave-height) + 1px) no-repeat;\n}",
            "requires": [],
            "notes": "The wave is cut out of the block itself, so the next section's background shows through. Increase --wpide-wave-height for a taller wave."
        },
        {
            "id": "frontend-animated-mark-highlight",
            "version": 1,
            "title": "Marker-Pen Animation for Highlighted Text",
            "description": "Animates highlighted text (the <mark> tag from the toolbar's Highlight option) with a marker stroke that sweeps across as the words scroll into view.",
            "category": "frontend",
            "tags": [
                "highlight",
                "animation",
                "typography"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.entry-content mark,.wp-block-post-content mark{--wpide-mark:#fde68a;background:linear-gradient(var(--wpide-mark),var(--wpide-mark)) no-repeat 0 85%/100% 45%;color:inherit;padding:0 .1em;}\n@media (prefers-reduced-motion:no-preference){.wpide-mark-pending{background-size:0 45%!important;}.wpide-mark-animate{transition:background-size .8s ease;}}\n</style>\n<script>\n(function () {\n    var marks = document.querySelectorAll('.entry-content mark, .wp-block-post-content mark');\n    if (!marks.length || !('IntersectionObserver' in window)) { return; }\n    if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { return; }\n    var io = new IntersectionObserver(function (entries) {\n        entries.forEach(function (entry) {\n            if (entry.isIntersecting) { io.unobserve(entry.target); entry.target.classList.remove('wpide-mark-pending'); }\n        });\n    }, { threshold: 1 });\n    marks.forEach(function (m) {\n        if (m.style.backgroundColor) { return; }\n        m.classList.add('wpide-mark-pending', 'wpide-mark-animate');\n        io.observe(m);\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Change the marker color in --wpide-mark. Visitors who prefer reduced motion see the highlight without animation."
        },
        {
            "id": "frontend-numbered-headings",
            "version": 1,
            "title": "Automatic Numbering for Headings (1, 1.1, 1.2...)",
            "description": "Numbers the H2 and H3 headings inside any block with the class \"wpide-numbered-headings\" (1., 1.1, 1.2, 2.), ideal for guides, documentation and terms pages.",
            "category": "frontend",
            "tags": [
                "headings",
                "numbering",
                "documentation",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-numbered-headings {\n    counter-reset: wpide-h2;\n}\n\n.wpide-numbered-headings h2 {\n    counter-increment: wpide-h2;\n    counter-reset: wpide-h3;\n}\n\n.wpide-numbered-headings h2::before {\n    content: counter(wpide-h2) \". \";\n    opacity: 0.6;\n}\n\n.wpide-numbered-headings h3 {\n    counter-increment: wpide-h3;\n}\n\n.wpide-numbered-headings h3::before {\n    content: counter(wpide-h2) \".\" counter(wpide-h3) \" \";\n    opacity: 0.6;\n}",
            "requires": [],
            "notes": "Add wpide-numbered-headings to the Post Content block (block themes) or to a Group that wraps your text."
        },
        {
            "id": "frontend-print-url-and-date",
            "version": 1,
            "title": "Print the Page Address and Date on Printouts",
            "description": "Adds a line with the page's web address and the print date at the bottom of printed pages, so paper copies can always be traced back to the original.",
            "category": "frontend",
            "tags": [
                "print",
                "printing",
                "citations"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-print-source{display:none;}\n@media print{.wpide-print-source{display:block;margin-top:2em;padding-top:.6em;border-top:1px solid #999;font-size:10pt;color:#333;}}\n</style>\n<script>\nwindow.addEventListener('beforeprint', function () {\n    var note = document.querySelector('.wpide-print-source');\n    if (!note) {\n        note = document.createElement('p');\n        note.className = 'wpide-print-source';\n        document.body.appendChild(note);\n    }\n    note.textContent = 'Source: ' + window.location.href.split('#')[0] + ' \\u2014 printed ' + new Date().toLocaleDateString();\n});\n</script>",
            "requires": [],
            "notes": "Only visible on paper or PDF exports; nothing changes on screen."
        },
        {
            "id": "frontend-resume-reading-position",
            "version": 1,
            "title": "\"Continue Where You Left Off\" on Long Articles",
            "description": "Remembers how far a reader got in a long post (in their own browser only) and offers a \"Continue reading\" button when they come back, without cookies or accounts.",
            "category": "frontend",
            "tags": [
                "reading",
                "articles",
                "localstorage"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div class=\"wpide-resume\" hidden role=\"status\"><span>Continue where you left off?</span> <button type=\"button\" class=\"wpide-resume-go\">Continue reading</button> <button type=\"button\" class=\"wpide-resume-close\" aria-label=\"Dismiss\">&times;</button></div>\n<style>\n.wpide-resume{position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:9990;display:flex;align-items:center;gap:10px;max-width:calc(100vw - 32px);padding:10px 14px;border-radius:10px;background:#1d2327;color:#fff;font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.25);}\n.wpide-resume[hidden]{display:none;}\n.wpide-resume button{border:0;border-radius:6px;background:#fff;color:#1d2327;font:inherit;font-weight:600;padding:6px 10px;cursor:pointer;}\n.wpide-resume .wpide-resume-close{background:transparent;color:#fff;font-size:20px;line-height:1;padding:2px 6px;}\n</style>\n<script>\n(function () {\n    if (!document.body.classList.contains('single-post') || document.documentElement.scrollHeight < window.innerHeight * 3) { return; }\n    var key = 'wpide-read-' + window.location.pathname, store;\n    try { store = window.localStorage; store.getItem(key); } catch (e) { return; }\n    var box = document.querySelector('.wpide-resume');\n    var saved = parseInt(store.getItem(key) || '0', 10);\n    if (box && saved > window.innerHeight * 1.5) {\n        box.hidden = false;\n        box.querySelector('.wpide-resume-go').addEventListener('click', function () { window.scrollTo(0, saved); box.hidden = true; });\n        box.querySelector('.wpide-resume-close').addEventListener('click', function () { box.hidden = true; });\n        setTimeout(function () { box.hidden = true; }, 12000);\n    }\n    var timer = null;\n    window.addEventListener('scroll', function () {\n        clearTimeout(timer);\n        timer = setTimeout(function () {\n            var y = window.pageYOffset;\n            var nearEnd = y + window.innerHeight > document.documentElement.scrollHeight - 400;\n            try { if (nearEnd) { store.removeItem(key); } else { store.setItem(key, String(Math.round(y))); } } catch (e) {}\n        }, 400);\n    }, { passive: true });\n})();\n</script>",
            "requires": [],
            "notes": "Uses the browser's localStorage, which is not sent to your server. Only single posts longer than about three screens are tracked."
        },
        {
            "id": "frontend-youtube-lightbox",
            "version": 1,
            "title": "Play YouTube Links in a Lightbox",
            "description": "Opens links to YouTube videos (with the class \"wpide-video-lightbox\" or around a thumbnail image) in a large on-page video player, and only contacts YouTube after the click.",
            "category": "frontend",
            "tags": [
                "youtube",
                "video",
                "lightbox",
                "privacy"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-yt-box{position:fixed;inset:0;z-index:100000;display:flex;align-items:center;justify-content:center;padding:20px;background:rgba(0,0,0,.88);}\n.wpide-yt-box[hidden]{display:none;}\n.wpide-yt-box iframe{width:min(1100px,100%);aspect-ratio:16/9;height:auto;border:0;border-radius:8px;background:#000;}\n.wpide-yt-close{position:absolute;top:14px;right:14px;width:46px;height:46px;border:0;border-radius:50%;background:rgba(255,255,255,.15);color:#fff;font-size:28px;line-height:1;cursor:pointer;}\n</style>\n<script>\n(function () {\n    var box = null, last = null;\n    function videoId(url) {\n        var m = /(?:youtube\\.com\\/(?:watch\\?(?:.*&)?v=|shorts\\/|embed\\/)|youtu\\.be\\/)([\\w-]{11})/.exec(url || '');\n        return m ? m[1] : null;\n    }\n    function close() { if (box) { box.hidden = true; box.querySelector('iframe').src = 'about:blank'; if (last) { last.focus(); } } }\n    document.addEventListener('click', function (e) {\n        var link = e.target.closest ? e.target.closest('a[href*=\"youtu\"]') : null;\n        if (!link || !(link.matches('.wpide-video-lightbox, .wpide-video-lightbox a') || link.querySelector('img'))) { return; }\n        var id = videoId(link.href);\n        if (!id) { return; }\n        e.preventDefault();\n        last = link;\n        if (!box) {\n            box = document.createElement('div');\n            box.className = 'wpide-yt-box';\n            box.setAttribute('role', 'dialog');\n            box.setAttribute('aria-modal', 'true');\n            box.setAttribute('aria-label', 'Video player');\n            box.innerHTML = '<iframe title=\"YouTube video\" allow=\"autoplay; encrypted-media; picture-in-picture; fullscreen\" allowfullscreen></iframe><button type=\"button\" class=\"wpide-yt-close\" aria-label=\"Close video\">&times;</button>';\n            box.addEventListener('click', function (ev) { if (ev.target === box || ev.target.closest('.wpide-yt-close')) { close(); } });\n            document.body.appendChild(box);\n        }\n        box.querySelector('iframe').src = 'https://www.youtube-nocookie.com/embed/' + id + '?autoplay=1&rel=0';\n        box.hidden = false;\n        box.querySelector('.wpide-yt-close').focus();\n    });\n    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { close(); } });\n})();\n</script>",
            "requires": [],
            "notes": "Add wpide-video-lightbox to a Button block or link a thumbnail image to a YouTube URL. Videos play from youtube-nocookie.com."
        },
        {
            "id": "frontend-clickable-cards",
            "version": 1,
            "title": "Make Whole Cards Clickable",
            "description": "Makes an entire Group, Column or Query Loop card with the class \"wpide-card-link\" clickable, using the link in its heading, so visitors don't have to hit the small title link.",
            "category": "frontend",
            "tags": [
                "cards",
                "links",
                "query-loop",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-card-link {\n    position: relative;\n}\n\n.wpide-card-link :is(h2, h3, h4, .wp-block-post-title) a::after {\n    content: \"\";\n    position: absolute;\n    inset: 0;\n    z-index: 1;\n}\n\n.wpide-card-link a:not(:is(h2, h3, h4, .wp-block-post-title) a),\n.wpide-card-link button {\n    position: relative;\n    z-index: 2;\n}\n\n.wpide-card-link:hover {\n    cursor: pointer;\n}\n\n.wpide-card-link:focus-within {\n    outline: 2px solid currentColor;\n    outline-offset: 4px;\n}",
            "requires": [],
            "notes": "The link in the card's heading (or Post Title block) becomes the click target; other links and buttons inside stay clickable on their own."
        },
        {
            "id": "frontend-reverse-columns-mobile",
            "version": 1,
            "title": "Reverse Column Order on Phones",
            "description": "Flips the stacking order of a Columns block with the class \"wpide-reverse-mobile\" on phones, so an image placed on the right appears above its text instead of below.",
            "category": "frontend",
            "tags": [
                "columns",
                "mobile",
                "layout",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-width: 781px) {\n    .wp-block-columns.wpide-reverse-mobile {\n        flex-direction: column-reverse;\n    }\n}",
            "requires": [],
            "notes": "Add wpide-reverse-mobile to the Columns block's Additional CSS class(es). Desktop order stays the same."
        },
        {
            "id": "elementor-query-random-order",
            "version": 1,
            "title": "Elementor Posts Query: Random Order",
            "description": "Adds a custom query ID \"wpide_random\" that shows posts in random order in Elementor Pro's Posts and Loop Grid widgets, for testimonials, team members or featured products.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "random"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_random', function ( $query ) {\n    $query->set( 'orderby', 'rand' );\n    $query->set( 'ignore_sticky_posts', true );\n    $query->set( 'no_found_rows', true );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Type wpide_random in the Query ID field. Page caching will freeze the order until the cache is cleared."
        },
        {
            "id": "elementor-form-phone-validation",
            "version": 1,
            "title": "Elementor Forms: Check Phone Numbers",
            "description": "Rejects Elementor Pro form submissions whose phone (Tel) field contains letters or too few digits, so you get numbers you can actually call back.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "forms",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Allowed number of digits in a phone number.\n$wpide_el_phone_min = 7;\n$wpide_el_phone_max = 15;\n\nadd_action( 'elementor_pro/forms/validation/tel', function ( $field, $record, $ajax_handler ) use ( $wpide_el_phone_min, $wpide_el_phone_max ) {\n    $value = isset( $field['value'] ) ? trim( (string) $field['value'] ) : '';\n\n    if ( '' === $value ) {\n        return;\n    }\n\n    $digits = strlen( preg_replace( '/\\D/', '', $value ) );\n\n    if ( preg_match( '/[^0-9+()\\.\\-\\s]/', $value ) || $digits < $wpide_el_phone_min || $digits > $wpide_el_phone_max ) {\n        $ajax_handler->add_error( $field['id'], 'Please enter a valid phone number.' );\n    }\n}, 10, 3 );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Change the minimum and maximum digit counts for your country."
        },
        {
            "id": "elementor-body-class-on-builder-pages",
            "version": 1,
            "title": "Body Class for Pages Built With Elementor",
            "description": "Adds a \"wpide-elementor-page\" class to the body of pages built with Elementor, so your theme CSS can treat designed pages differently from regular posts.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'body_class', function ( $classes ) {\n    if ( is_singular() && 'builder' === get_post_meta( get_queried_object_id(), '_elementor_edit_mode', true ) ) {\n        $classes[] = 'wpide-elementor-page';\n    }\n\n    return $classes;\n} );",
            "requires": [
                "elementor"
            ],
            "notes": "Use it in CSS, for example .wpide-elementor-page .site-content { padding: 0; }."
        },
        {
            "id": "beaver-builder-remove-font-awesome",
            "version": 1,
            "title": "Stop Beaver Builder Loading Font Awesome for Visitors",
            "description": "Removes the Font Awesome icon stylesheets that Beaver Builder loads on the front end, for sites whose layouts use no Font Awesome icons.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "icons",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Stylesheet handles to remove.\n$wpide_bb_icon_handles = array( 'font-awesome', 'font-awesome-5' );\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_bb_icon_handles ) {\n    if ( is_user_logged_in() || isset( $_GET['fl_builder'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended\n        return;\n    }\n\n    foreach ( $wpide_bb_icon_handles as $handle ) {\n        wp_dequeue_style( $handle );\n    }\n}, 999 );",
            "requires": [
                "beaver-builder"
            ],
            "notes": "Check your pages after enabling it: any Font Awesome icons in modules will disappear. Logged-in users keep the icons so the builder works."
        },
        {
            "id": "beaver-builder-templates-admins-only",
            "version": 1,
            "title": "Hide Beaver Builder Templates From Non-Administrators",
            "description": "Removes the Beaver Builder templates and saved rows menu from wp-admin for everyone but administrators, so shared layouts are not edited by mistake.",
            "category": "page-builders",
            "tags": [
                "beaver-builder",
                "templates",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        remove_menu_page( 'edit.php?post_type=fl-builder-template' );\n    }\n}, 999 );",
            "requires": [
                "beaver-builder"
            ],
            "notes": ""
        },
        {
            "id": "divi-fullscreen-mobile-menu",
            "version": 1,
            "title": "Full-Screen Mobile Menu in Divi",
            "description": "Turns the small dropdown mobile menu of Divi's default header into a full-screen menu with large, easy-to-tap links.",
            "category": "page-builders",
            "tags": [
                "divi",
                "mobile-menu",
                "navigation"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-width: 980px) {\n    #main-header .et_mobile_menu {\n        position: fixed;\n        top: 80px;\n        left: 0;\n        right: 0;\n        bottom: 0;\n        max-height: none;\n        overflow-y: auto;\n        padding: 24px 6%;\n        border-top: 0;\n        background: #111827 !important;\n    }\n\n    #main-header .et_mobile_menu li a {\n        padding: 14px 0;\n        border-bottom: 1px solid rgba(255, 255, 255, 0.12);\n        color: #ffffff !important;\n        font-size: 20px;\n    }\n}",
            "requires": [
                "divi"
            ],
            "notes": "Change the background and text colors to match your brand."
        },
        {
            "id": "divi-library-admins-only",
            "version": 1,
            "title": "Hide the Divi Library From Non-Administrators",
            "description": "Removes the Divi Library menu (saved layouts, sections and modules) for everyone except administrators, so global elements are not changed by accident.",
            "category": "page-builders",
            "tags": [
                "divi",
                "library",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    remove_submenu_page( 'et_divi_options', 'edit.php?post_type=et_pb_layout' );\n    remove_menu_page( 'edit.php?post_type=et_pb_layout' );\n}, 999 );",
            "requires": [
                "divi"
            ],
            "notes": ""
        },
        {
            "id": "bricks-templates-admins-only",
            "version": 1,
            "title": "Hide Bricks Templates From Non-Administrators",
            "description": "Removes the Bricks Templates menu for everyone except administrators, protecting headers, footers and popups built as Bricks templates.",
            "category": "page-builders",
            "tags": [
                "bricks",
                "templates",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        remove_submenu_page( 'bricks', 'edit.php?post_type=bricks_template' );\n        remove_menu_page( 'edit.php?post_type=bricks_template' );\n    }\n}, 999 );",
            "requires": [
                "bricks"
            ],
            "notes": ""
        },
        {
            "id": "navigation-block-hamburger-breakpoint",
            "version": 1,
            "title": "Show the Navigation Block's Mobile Menu on Tablets Too",
            "description": "Switches the Navigation block to its hamburger (mobile) menu up to 1024px wide instead of 600px, so long menus no longer squeeze or wrap on tablets.",
            "category": "gutenberg",
            "tags": [
                "navigation",
                "mobile-menu",
                "responsive"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (min-width: 600px) and (max-width: 1023px) {\n    .wp-block-navigation__responsive-container-open:not(.always-shown) {\n        display: flex !important;\n    }\n\n    .wp-block-navigation__responsive-container:not(.hidden-by-default):not(.is-menu-open) {\n        display: none !important;\n    }\n}",
            "requires": [],
            "notes": "Change 1023px to another width to move the breakpoint. Only affects Navigation blocks set to show the overlay menu on mobile."
        },
        {
            "id": "query-loop-random-order",
            "version": 1,
            "title": "Query Loop Block: Random Order",
            "description": "Shows posts in random order in any Query Loop block with the class \"wpide-random-order\", for rotating testimonials, team members or featured posts.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "random",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_query_random = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_random ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_random['active'] = false !== strpos( $classes, ' wpide-random-order ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_random ) {\n    if ( $wpide_query_random['active'] ) {\n        $query['orderby']             = 'rand';\n        $query['ignore_sticky_posts'] = true;\n    }\n\n    return $query;\n} );",
            "requires": [],
            "notes": "Add wpide-random-order to the Query Loop's Additional CSS class(es). With page caching the order only changes when the cache is refreshed."
        },
        {
            "id": "query-loop-recent-days",
            "version": 1,
            "title": "Query Loop Block: Only Posts From the Last 30 Days",
            "description": "Limits any Query Loop block with the class \"wpide-recent\" to posts published in the last 30 days, for \"This month\" or \"Fresh this week\" sections.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "dates",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How many days back to show.\n$wpide_query_recent_days = 30;\n$wpide_query_recent      = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_recent ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_recent['active'] = false !== strpos( $classes, ' wpide-recent ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_recent, $wpide_query_recent_days ) {\n    if ( ! $wpide_query_recent['active'] ) {\n        return $query;\n    }\n\n    $date_query   = isset( $query['date_query'] ) ? (array) $query['date_query'] : array();\n    $date_query[] = array( 'after' => absint( $wpide_query_recent_days ) . ' days ago', 'inclusive' => true );\n\n    $query['date_query'] = $date_query;\n\n    return $query;\n} );",
            "requires": [],
            "notes": "Change the number of days at the top. Use the class wpide-recent on the Query Loop block."
        },
        {
            "id": "query-loop-upcoming-by-date-field",
            "version": 1,
            "title": "Query Loop Block: Upcoming Items by a Date Field",
            "description": "Shows only items whose date field (such as an event date) is today or later in any Query Loop block with the class \"wpide-upcoming\", sorted soonest first.",
            "category": "gutenberg",
            "tags": [
                "query-loop",
                "events",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Custom field holding the date (Ymd).\n$wpide_query_date_field = 'event_date';\n$wpide_query_upcoming   = array( 'active' => false );\n\nadd_filter( 'pre_render_block', function ( $pre, $parsed ) use ( &$wpide_query_upcoming ) {\n    if ( 'core/query' === $parsed['blockName'] ) {\n        $classes = isset( $parsed['attrs']['className'] ) ? ' ' . $parsed['attrs']['className'] . ' ' : '';\n        $wpide_query_upcoming['active'] = false !== strpos( $classes, ' wpide-upcoming ' );\n    }\n\n    return $pre;\n}, 10, 2 );\n\nadd_filter( 'query_loop_block_query_vars', function ( $query ) use ( &$wpide_query_upcoming, $wpide_query_date_field ) {\n    if ( ! $wpide_query_upcoming['active'] ) {\n        return $query;\n    }\n\n    $meta_query   = isset( $query['meta_query'] ) ? (array) $query['meta_query'] : array();\n    $meta_query[] = array(\n        'key'     => $wpide_query_date_field,\n        'value'   => wp_date( 'Ymd' ),\n        'compare' => '>=',\n        'type'    => 'NUMERIC',\n    );\n\n    $query['meta_query'] = $meta_query;\n    $query['meta_key']   = $wpide_query_date_field;\n    $query['orderby']    = 'meta_value_num';\n    $query['order']      = 'ASC';\n\n    return $query;\n} );",
            "requires": [],
            "notes": "The field must store Ymd dates (20261231), the ACF date picker default. Set the field name at the top."
        },
        {
            "id": "body-classes-for-used-blocks",
            "version": 1,
            "title": "Body Classes for the Blocks Used on a Page",
            "description": "Adds body classes such as \"has-block-cover\" or \"has-block-gallery\" for the main blocks a post or page contains, so CSS can adapt the header or layout to the content.",
            "category": "gutenberg",
            "tags": [
                "body-class",
                "css",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Blocks to look for.\n$wpide_body_class_blocks = array( 'core/cover', 'core/gallery', 'core/table', 'core/video', 'core/embed', 'core/query' );\n\nadd_filter( 'body_class', function ( $classes ) use ( $wpide_body_class_blocks ) {\n    if ( ! is_singular() ) {\n        return $classes;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post || ! has_blocks( $post ) ) {\n        return $classes;\n    }\n\n    foreach ( $wpide_body_class_blocks as $block ) {\n        if ( has_block( $block, $post ) ) {\n            $name      = 0 === strpos( $block, 'core/' ) ? substr( $block, 5 ) : str_replace( '/', '-', $block );\n            $classes[] = 'has-block-' . sanitize_html_class( $name );\n        }\n    }\n\n    return $classes;\n} );",
            "requires": [],
            "notes": "Only the blocks in the list are checked, which keeps it fast. Add any block name you want to style against."
        },
        {
            "id": "remove-block-theme-footer-credit",
            "version": 1,
            "title": "Remove the \"Designed with WordPress\" Footer Credit",
            "description": "Removes the \"Designed with WordPress\" or \"Powered by WordPress\" credit line from the footer of default block themes like Twenty Twenty-Four and Twenty Twenty-Five, without editing the template.",
            "category": "gutenberg",
            "tags": [
                "footer",
                "block-themes",
                "credit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Credit phrases to remove (case-insensitive).\n$wpide_credit_phrases = array( 'Designed with', 'Powered by', 'Proudly powered by' );\n\nadd_filter( 'render_block_core/paragraph', function ( $content ) use ( $wpide_credit_phrases ) {\n    if ( false === stripos( $content, 'wordpress.org' ) ) {\n        return $content;\n    }\n\n    $text = wp_strip_all_tags( $content );\n\n    foreach ( $wpide_credit_phrases as $phrase ) {\n        if ( false !== stripos( $text, $phrase ) && false !== stripos( $text, 'WordPress' ) && strlen( $text ) < 120 ) {\n            return '';\n        }\n    }\n\n    return $content;\n} );",
            "requires": [],
            "notes": "Matches paragraphs containing one of the phrases and a link to wordpress.org. Add your theme's wording to the list if it differs."
        },
        {
            "id": "pattern-pros-and-cons",
            "version": 1,
            "title": "Block Pattern: Pros and Cons",
            "description": "Adds a two-column \"Pros and cons\" pattern with checkmark and cross lists, for reviews, comparisons and buying guides.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "reviews",
                "comparison"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $list = function ( $items, $mark ) {\n        $html = '';\n        foreach ( $items as $item ) {\n            $html .= '<!-- wp:list-item --><li>' . $mark . ' ' . esc_html( $item ) . '</li><!-- /wp:list-item -->';\n        }\n        return '<!-- wp:list --><ul class=\"wp-block-list\">' . $html . '</ul><!-- /wp:list -->';\n    };\n\n    $content = '<!-- wp:columns --><div class=\"wp-block-columns\">'\n        . '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">Pros</h3><!-- /wp:heading -->' . $list( array( 'Easy to set up', 'Great value for money', 'Excellent support' ), '&#x2714;' ) . '</div><!-- /wp:column -->'\n        . '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">Cons</h3><!-- /wp:heading -->' . $list( array( 'No mobile app yet', 'Limited integrations' ), '&#x2716;' ) . '</div><!-- /wp:column -->'\n        . '</div><!-- /wp:columns -->';\n\n    register_block_pattern( 'wpide/pros-cons', array(\n        'title'       => 'Pros and cons',\n        'description' => 'Two lists comparing advantages and drawbacks.',\n        'categories'  => array( 'wpide-sections' ),\n        'keywords'    => array( 'pros', 'cons', 'review', 'comparison' ),\n        'content'     => $content,\n    ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "pattern-team-members",
            "version": 1,
            "title": "Block Pattern: Team Members",
            "description": "Adds a team section pattern with three members, each with a name, role and short bio in columns, ready for you to add photos.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "team",
                "about"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $people = array(\n        array( 'Sam Carter', 'Founder & CEO', 'Started the company after ten years in the industry.' ),\n        array( 'Lena Ortiz', 'Head of Design', 'Turns complex ideas into simple, friendly interfaces.' ),\n        array( 'Ravi Shah', 'Lead Developer', 'Keeps everything fast, secure and running smoothly.' ),\n    );\n\n    $columns = '';\n    foreach ( $people as $person ) {\n        $columns .= '<!-- wp:column --><div class=\"wp-block-column\"><!-- wp:heading {\"level\":3} --><h3 class=\"wp-block-heading\">' . esc_html( $person[0] ) . '</h3><!-- /wp:heading --><!-- wp:paragraph --><p><strong>' . esc_html( $person[1] ) . '</strong></p><!-- /wp:paragraph --><!-- wp:paragraph --><p>' . esc_html( $person[2] ) . '</p><!-- /wp:paragraph --></div><!-- /wp:column -->';\n    }\n\n    register_block_pattern( 'wpide/team-members', array(\n        'title'         => 'Team: three members',\n        'description'   => 'Names, roles and short bios in columns.',\n        'categories'    => array( 'wpide-sections', 'team' ),\n        'keywords'      => array( 'team', 'people', 'staff' ),\n        'viewportWidth' => 1200,\n        'content'       => '<!-- wp:group {\"layout\":{\"type\":\"constrained\"}} --><div class=\"wp-block-group\"><!-- wp:heading --><h2 class=\"wp-block-heading\">Meet the team</h2><!-- /wp:heading --><!-- wp:columns --><div class=\"wp-block-columns\">' . $columns . '</div><!-- /wp:columns --></div><!-- /wp:group -->',\n    ) );\n} );",
            "requires": [],
            "notes": "Insert an Image block above each name to add photos; the \"Rounded\" image style looks good here."
        },
        {
            "id": "block-style-button-arrow",
            "version": 1,
            "title": "Button Block Style: Arrow",
            "description": "Adds an \"Arrow\" style to the Button block that shows a small arrow after the text which nudges forward on hover, a classic \"Learn more\" look.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "buttons",
                "design"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/button', array(\n        'name'         => 'wpide-arrow',\n        'label'        => 'Arrow',\n        'inline_style' => '.wp-block-button.is-style-wpide-arrow .wp-block-button__link::after{content:\"\\2192\";display:inline-block;margin-left:.5em;transition:transform .2s ease;}.wp-block-button.is-style-wpide-arrow .wp-block-button__link:hover::after,.wp-block-button.is-style-wpide-arrow .wp-block-button__link:focus-visible::after{transform:translateX(4px);}@media (prefers-reduced-motion:reduce){.wp-block-button.is-style-wpide-arrow .wp-block-button__link::after{transition:none;}}',\n    ) );\n} );",
            "requires": [],
            "notes": "The hover movement is turned off for visitors who prefer reduced motion."
        },
        {
            "id": "frontend-heading-accordion",
            "version": 1,
            "title": "Turn Headings Into an Accordion",
            "description": "Turns every H3 inside a block with the class \"wpide-accordion\" into a clickable question that shows or hides the content below it, for FAQs written as plain headings and paragraphs.",
            "category": "frontend",
            "tags": [
                "accordion",
                "faq",
                "headings"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-accordion h3 button{all:inherit;display:flex;justify-content:space-between;align-items:center;width:100%;cursor:pointer;}\n.wpide-accordion h3 button::after{content:\"+\";font-weight:400;margin-left:1em;}\n.wpide-accordion h3 button[aria-expanded=\"true\"]::after{content:\"\\2212\";}\n.wpide-accordion h3 button:focus-visible{outline:2px solid currentColor;outline-offset:3px;}\n.wpide-accordion .wpide-accordion__panel[hidden]{display:none;}\n</style>\n<script>\ndocument.querySelectorAll('.wpide-accordion').forEach(function (box, b) {\n    box.querySelectorAll(':scope > h3').forEach(function (heading, i) {\n        var panel = document.createElement('div');\n        panel.className = 'wpide-accordion__panel';\n        panel.id = 'wpide-acc-' + b + '-' + i;\n        panel.hidden = true;\n        while (heading.nextElementSibling && heading.nextElementSibling.tagName !== 'H3') {\n            panel.appendChild(heading.nextElementSibling);\n        }\n        heading.insertAdjacentElement('afterend', panel);\n        var button = document.createElement('button');\n        button.type = 'button';\n        button.setAttribute('aria-expanded', 'false');\n        button.setAttribute('aria-controls', panel.id);\n        while (heading.firstChild) { button.appendChild(heading.firstChild); }\n        heading.appendChild(button);\n        button.addEventListener('click', function () {\n            var open = button.getAttribute('aria-expanded') !== 'true';\n            button.setAttribute('aria-expanded', open ? 'true' : 'false');\n            panel.hidden = !open;\n        });\n    });\n});\n</script>",
            "requires": [],
            "notes": "Add wpide-accordion to a Group block containing H3 headings followed by their answers. Without JavaScript everything stays visible."
        },
        {
            "id": "frontend-gradient-text",
            "version": 1,
            "title": "Gradient Text Class for Headings",
            "description": "Adds a \"wpide-gradient-text\" class that fills a heading's letters with a color gradient, for standout hero titles and highlighted words.",
            "category": "frontend",
            "tags": [
                "typography",
                "gradient",
                "headings",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-gradient-text {\n    background: linear-gradient(90deg, #6366f1, #ec4899);\n    -webkit-background-clip: text;\n    background-clip: text;\n    -webkit-text-fill-color: transparent;\n    color: #6366f1;\n}\n\n@media (forced-colors: active) {\n    .wpide-gradient-text {\n        -webkit-text-fill-color: currentColor;\n        background: none;\n    }\n}",
            "requires": [],
            "notes": "Add the class to a Heading block, or wrap a few words in a span with the class using the HTML editor. Change the two colors to your brand."
        },
        {
            "id": "frontend-scroll-snap-sections",
            "version": 1,
            "title": "Full-Screen Snap Scrolling Between Sections",
            "description": "Makes the page snap from one full-screen section to the next while scrolling, for presentation-style landing pages built from Cover or Group blocks with the class \"wpide-snap\".",
            "category": "frontend",
            "tags": [
                "scroll-snap",
                "landing-pages",
                "sections",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: no-preference) and (min-height: 500px) {\n    html:has(.wpide-snap) {\n        scroll-snap-type: y proximity;\n    }\n\n    .wpide-snap {\n        scroll-snap-align: start;\n        min-height: 100svh;\n    }\n}",
            "requires": [],
            "notes": "Add wpide-snap to each full-height section. Snapping only turns on for pages that contain such sections, and not for visitors who prefer reduced motion."
        },
        {
            "id": "elementor-query-sticky-posts",
            "version": 1,
            "title": "Elementor Posts Query: Only Sticky (Featured) Posts",
            "description": "Adds a custom query ID \"wpide_sticky\" that shows only the posts you marked as sticky, so a \"Featured articles\" section in Elementor Pro stays in sync with the post editor's Sticky option.",
            "category": "page-builders",
            "tags": [
                "elementor",
                "query",
                "sticky",
                "featured"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'elementor/query/wpide_sticky', function ( $query ) {\n    $sticky = array_filter( array_map( 'absint', (array) get_option( 'sticky_posts', array() ) ) );\n\n    $query->set( 'post__in', $sticky ? $sticky : array( 0 ) );\n    $query->set( 'ignore_sticky_posts', true );\n    $query->set( 'orderby', 'date' );\n} );",
            "requires": [
                "elementor-pro"
            ],
            "notes": "Type wpide_sticky in the Query ID field. When no post is sticky, the widget shows nothing."
        },
        {
            "id": "pattern-comparison-table",
            "version": 1,
            "title": "Block Pattern: Plan Comparison Table",
            "description": "Adds a comparison table pattern with a header row and check marks for comparing plans, products or options side by side.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "table",
                "comparison",
                "pricing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_pattern' ) ) {\n        return;\n    }\n\n    if ( ! WP_Block_Pattern_Categories_Registry::get_instance()->is_registered( 'wpide-sections' ) ) {\n        register_block_pattern_category( 'wpide-sections', array( 'label' => 'Site Sections' ) );\n    }\n\n    $rows = array(\n        array( 'Websites', '1', '5', 'Unlimited' ),\n        array( 'Daily backups', '&#x2714;', '&#x2714;', '&#x2714;' ),\n        array( 'Priority support', '&mdash;', '&#x2714;', '&#x2714;' ),\n        array( 'White-label reports', '&mdash;', '&mdash;', '&#x2714;' ),\n    );\n\n    $body = '';\n    foreach ( $rows as $row ) {\n        $body .= '<tr><td>' . implode( '</td><td>', $row ) . '</td></tr>';\n    }\n\n    $content = '<!-- wp:table {\"hasFixedLayout\":true} --><figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>Feature</th><th>Starter</th><th>Growth</th><th>Agency</th></tr></thead><tbody>' . $body . '</tbody></table></figure><!-- /wp:table -->';\n\n    register_block_pattern( 'wpide/comparison-table', array(\n        'title'       => 'Comparison table: three plans',\n        'description' => 'Feature rows compared across three plans.',\n        'categories'  => array( 'wpide-sections' ),\n        'keywords'    => array( 'comparison', 'table', 'plans', 'features' ),\n        'content'     => $content,\n    ) );\n} );",
            "requires": [],
            "notes": "Edit cells directly in the table after inserting. Pair it with the \"Lined\" or \"Hover rows\" table styles."
        },
        {
            "id": "block-style-cover-bottom-gradient",
            "version": 1,
            "title": "Cover Block Style: Bottom Gradient for Readable Text",
            "description": "Adds a \"Bottom gradient\" style to the Cover block that darkens only the lower part of the image, so captions and headings at the bottom stay readable while the photo stays bright.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "cover",
                "images",
                "readability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/cover', array(\n        'name'         => 'wpide-bottom-gradient',\n        'label'        => 'Bottom gradient',\n        'inline_style' => '.wp-block-cover.is-style-wpide-bottom-gradient::before{content:\"\";position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(to top,rgba(0,0,0,.75) 0%,rgba(0,0,0,.35) 35%,transparent 65%);}.wp-block-cover.is-style-wpide-bottom-gradient .wp-block-cover__inner-container{position:relative;z-index:2;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Set the Cover's overlay opacity to 0 and place the text at the bottom for the best result."
        },
        {
            "id": "block-style-post-template-ranking",
            "version": 1,
            "title": "Post Template Block Style: Numbered Ranking",
            "description": "Adds a \"Numbered ranking\" style to the Post Template block inside Query Loops, which puts a large 1, 2, 3... next to each post, for \"Top 10\" and \"Most popular\" lists.",
            "category": "gutenberg",
            "tags": [
                "block-styles",
                "query-loop",
                "ranking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_block_style( 'core/post-template', array(\n        'name'         => 'wpide-ranking',\n        'label'        => 'Numbered ranking',\n        'inline_style' => '.wp-block-post-template.is-style-wpide-ranking{counter-reset:wpide-rank;}.wp-block-post-template.is-style-wpide-ranking>li{counter-increment:wpide-rank;position:relative;padding-left:3.5rem;}.wp-block-post-template.is-style-wpide-ranking>li::before{content:counter(wpide-rank);position:absolute;left:0;top:0;width:2.6rem;font-size:2.2rem;font-weight:800;line-height:1;text-align:center;opacity:.35;}',\n    ) );\n} );",
            "requires": [],
            "notes": "Select the Post Template block (inside the Query Loop) and choose the style. Works best with the List layout."
        },
        {
            "id": "frontend-highlight-jump-target",
            "version": 1,
            "title": "Briefly Highlight the Section You Jumped To",
            "description": "Flashes a soft highlight behind a heading or section when visitors arrive through a #link (from a table of contents or another page), so they instantly see where they landed.",
            "category": "frontend",
            "tags": [
                "anchors",
                "navigation",
                "usability",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: no-preference) {\n    :target {\n        animation: wpide-target-flash 2s ease-out 1;\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    :target {\n        outline: 2px solid currentColor;\n        outline-offset: 4px;\n    }\n}\n\n@keyframes wpide-target-flash {\n    0%,\n    30% {\n        background-color: rgba(253, 230, 138, 0.8);\n        box-shadow: 0 0 0 6px rgba(253, 230, 138, 0.8);\n    }\n\n    100% {\n        background-color: transparent;\n        box-shadow: 0 0 0 6px transparent;\n    }\n}",
            "requires": [],
            "notes": "Change the highlight color in the keyframes. Visitors who prefer reduced motion get a static outline instead."
        },
        {
            "id": "frontend-prevent-double-submit",
            "version": 1,
            "title": "Prevent Double Form Submissions",
            "description": "Disables a form's submit button and shows \"Sending…\" right after it is clicked, so impatient visitors cannot send comments, enquiries or orders twice.",
            "category": "frontend",
            "tags": [
                "forms",
                "usability",
                "spam"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "document.addEventListener('submit', function (e) {\n    var form = e.target;\n    if (!form || form.nodeName !== 'FORM' || form.classList.contains('wpide-allow-resubmit') || e.defaultPrevented) { return; }\n    if (form.checkValidity && !form.checkValidity()) { return; }\n    var buttons = form.querySelectorAll('button[type=\"submit\"], input[type=\"submit\"], button:not([type])');\n    // Wait a tick so the clicked button's name and value are still sent with the form.\n    setTimeout(function () {\n        buttons.forEach(function (btn) {\n            btn.disabled = true;\n            btn.setAttribute('aria-disabled', 'true');\n            btn.setAttribute('data-wpide-label', btn.nodeName === 'INPUT' ? btn.value : btn.textContent);\n            if (btn.nodeName === 'INPUT') { btn.value = 'Sending…'; } else { btn.textContent = 'Sending…'; }\n        });\n    }, 0);\n    setTimeout(function () {\n        buttons.forEach(function (btn) {\n            btn.disabled = false;\n            btn.removeAttribute('aria-disabled');\n            var label = btn.getAttribute('data-wpide-label');\n            if (btn.nodeName === 'INPUT') { btn.value = label; } else { btn.textContent = label; }\n        });\n    }, 8000);\n});",
            "requires": [],
            "notes": "Forms that submit with AJAX re-enable the button after 8 seconds. Add the class wpide-allow-resubmit to a form to skip it."
        },
        {
            "id": "woo-free-shipping-for-role",
            "version": 1,
            "title": "Free Shipping for a Customer Role",
            "description": "Makes your Free Shipping method available to customers with a chosen role, such as wholesale buyers or VIPs, whatever their cart total.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_ship_roles = array( 'wholesale_customer', 'vip' ); // Roles that always get free shipping.\n\nadd_filter( 'woocommerce_shipping_free_shipping_is_available', function ( $available ) use ( $wpide_free_ship_roles ) {\n    if ( $available || ! is_user_logged_in() ) {\n        return $available;\n    }\n\n    return (bool) array_intersect( $wpide_free_ship_roles, (array) wp_get_current_user()->roles );\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add a Free Shipping method to each shipping zone first; this snippet only switches it on for the listed roles."
        },
        {
            "id": "woo-free-shipping-by-item-count",
            "version": 1,
            "title": "Free Shipping From a Number of Items",
            "description": "Unlocks the Free Shipping method once the cart holds a set number of items, for example six bottles or three books, instead of a money threshold.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping",
                "quantity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_ship_min_items = 6; // Items needed in the cart.\n\nadd_filter( 'woocommerce_shipping_free_shipping_is_available', function ( $available, $package ) use ( $wpide_free_ship_min_items ) {\n    if ( $available ) {\n        return $available;\n    }\n\n    $count = 0;\n\n    foreach ( (array) ( $package['contents'] ?? array() ) as $item ) {\n        $count += (int) $item['quantity'];\n    }\n\n    return $count >= $wpide_free_ship_min_items;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Each shipping zone needs a Free Shipping method, and that method's own minimum amount or coupon rule still applies if it is stricter."
        },
        {
            "id": "woo-free-shipping-for-category",
            "version": 1,
            "title": "Free Shipping When the Cart Has a Category",
            "description": "Offers free shipping whenever the cart contains a product from a chosen category, handy for promoting a new collection or bulky-but-profitable lines.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_ship_cats = array( 'new-collection' ); // Category slugs.\n$wpide_free_ship_mode = 'any';                    // 'any' item or 'all' items must match.\n\nadd_filter( 'woocommerce_shipping_free_shipping_is_available', function ( $available, $package ) use ( $wpide_free_ship_cats, $wpide_free_ship_mode ) {\n    if ( $available || empty( $package['contents'] ) ) {\n        return $available;\n    }\n\n    $matches = 0;\n\n    foreach ( $package['contents'] as $item ) {\n        $id = $item['product_id'];\n\n        if ( has_term( $wpide_free_ship_cats, 'product_cat', $id ) ) {\n            $matches++;\n        }\n    }\n\n    return 'all' === $wpide_free_ship_mode ? count( $package['contents'] ) === $matches : $matches > 0;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses category slugs; set $wpide_free_ship_mode to 'all' to require every item in the cart to be in those categories."
        },
        {
            "id": "woo-free-shipping-date-range",
            "version": 1,
            "title": "Free Shipping During a Promotion Period",
            "description": "Turns on free shipping for everyone between two dates, so a free-shipping weekend or holiday promotion starts and stops by itself.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "free-shipping",
                "promotion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_ship_from = '2026-11-27 00:00'; // Promotion start (site timezone).\n$wpide_free_ship_to   = '2026-11-30 23:59'; // Promotion end.\n\nadd_filter( 'woocommerce_shipping_free_shipping_is_available', function ( $available ) use ( $wpide_free_ship_from, $wpide_free_ship_to ) {\n    if ( $available ) {\n        return $available;\n    }\n\n    $now = current_time( 'timestamp' );\n\n    return $now >= strtotime( $wpide_free_ship_from ) && $now <= strtotime( $wpide_free_ship_to );\n}, 20 );\n\nadd_filter( 'woocommerce_cart_shipping_packages', function ( $packages ) {\n    foreach ( $packages as $i => $package ) {\n        $packages[ $i ]['wpide_day'] = current_time( 'Y-m-d' ); // Refresh cached rates daily.\n    }\n\n    return $packages;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Dates use the site's timezone, and the shipping zone needs a Free Shipping method for the offer to appear."
        },
        {
            "id": "woo-local-delivery-postcodes",
            "version": 1,
            "title": "Offer a Shipping Method Only to Listed Postcodes",
            "description": "Shows a method such as local delivery or same-day courier only when the shipping postcode matches your list or starts with one of your prefixes.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "postcodes",
                "local-delivery"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_local_rate_id   = 'flat_rate:3';                    // Rate ID to restrict.\n$wpide_local_postcodes = array( '10115', '10117', '101*' ); // Exact postcodes or prefixes ending in *.\n\nadd_filter( 'woocommerce_package_rates', function ( $rates, $package ) use ( $wpide_local_rate_id, $wpide_local_postcodes ) {\n    if ( ! isset( $rates[ $wpide_local_rate_id ] ) ) {\n        return $rates;\n    }\n\n    $postcode = strtoupper( str_replace( ' ', '', (string) ( $package['destination']['postcode'] ?? '' ) ) );\n    $allowed  = false;\n\n    foreach ( $wpide_local_postcodes as $rule ) {\n        $rule = strtoupper( str_replace( ' ', '', $rule ) );\n\n        if ( '*' === substr( $rule, -1 ) ? 0 === strpos( $postcode, rtrim( $rule, '*' ) ) : $postcode === $rule ) {\n            $allowed = true;\n            break;\n        }\n    }\n\n    if ( '' === $postcode || ! $allowed ) {\n        unset( $rates[ $wpide_local_rate_id ] );\n    }\n\n    return $rates;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the rate ID shown in WooCommerce > Settings > Shipping (for example flat_rate:3); postcodes ending in * match by prefix."
        },
        {
            "id": "woo-remote-area-surcharge",
            "version": 1,
            "title": "Remote Area Shipping Surcharge",
            "description": "Adds a surcharge to the order when the shipping postcode is in a remote area you list, such as islands or highland regions that carriers charge extra for.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "fees",
                "postcodes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_remote = array(\n    'prefixes' => array( 'HS', 'ZE', 'KW15', 'PA41' ), // Postcode prefixes.\n    'amount'   => 9.95,                                // Surcharge.\n    'label'    => 'Remote area surcharge',\n    'taxable'  => true,\n);\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_remote ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! $cart->needs_shipping() ) {\n        return;\n    }\n\n    $postcode = strtoupper( str_replace( ' ', '', (string) WC()->customer->get_shipping_postcode() ) );\n\n    if ( '' === $postcode ) {\n        return;\n    }\n\n    foreach ( $wpide_remote['prefixes'] as $prefix ) {\n        if ( 0 === strpos( $postcode, strtoupper( $prefix ) ) ) {\n            $cart->add_fee( $wpide_remote['label'], $wpide_remote['amount'], $wpide_remote['taxable'] );\n            return;\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Postcodes match by prefix, so 'HS' covers HS1 to HS9; set 'taxable' to false if the fee should not be taxed."
        },
        {
            "id": "woo-weight-tier-shipping",
            "version": 1,
            "title": "Weight-Based Flat Rate Shipping Cost",
            "description": "Recalculates your Flat Rate shipping price from the parcel weight using tiers you set, for example 4.95 up to 2 kg and 7.95 up to 10 kg.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "weight",
                "flat-rate"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_weight_tiers = array( // Max weight => cost.\n    2  => 4.95,\n    5  => 6.50,\n    10 => 7.95,\n);\n$wpide_weight_extra = 0.50; // Cost per weight unit above the last tier.\n\nadd_filter( 'woocommerce_package_rates', function ( $rates, $package ) use ( $wpide_weight_tiers, $wpide_weight_extra ) {\n    $weight = 0.0;\n\n    foreach ( (array) $package['contents'] as $item ) {\n        $weight += (float) $item['data']->get_weight() * (int) $item['quantity'];\n    }\n\n    ksort( $wpide_weight_tiers );\n    $cost = null;\n\n    foreach ( $wpide_weight_tiers as $max => $price ) {\n        if ( $weight <= $max ) {\n            $cost = $price;\n            break;\n        }\n    }\n\n    if ( null === $cost ) {\n        $last = array_key_last( $wpide_weight_tiers );\n        $cost = $wpide_weight_tiers[ $last ] + ceil( $weight - $last ) * $wpide_weight_extra;\n    }\n\n    foreach ( $rates as $rate ) {\n        if ( 'flat_rate' !== $rate->get_method_id() ) {\n            continue;\n        }\n\n        $old = (float) $rate->get_cost();\n        $rate->set_cost( $cost );\n\n        if ( $old > 0 && $rate->get_taxes() ) {\n            $taxes = array();\n\n            foreach ( $rate->get_taxes() as $key => $tax ) {\n                $taxes[ $key ] = $tax * $cost / $old;\n            }\n\n            $rate->set_taxes( $taxes );\n        }\n    }\n\n    return $rates;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Weights use your store's weight unit, and above the last tier the extra cost is added for every unit over it."
        },
        {
            "id": "woo-hide-method-over-weight",
            "version": 1,
            "title": "Hide a Shipping Method Above a Weight",
            "description": "Removes a light-parcel option, such as letter post or a small flat rate, when the order is heavier than that service allows.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "weight"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_weight_limit  = 2;           // Maximum weight in your store's unit.\n$wpide_weight_method = 'flat_rate:5'; // Method ID or rate ID to hide.\n\nadd_filter( 'woocommerce_package_rates', function ( $rates, $package ) use ( $wpide_weight_limit, $wpide_weight_method ) {\n    $weight = 0.0;\n\n    foreach ( (array) $package['contents'] as $item ) {\n        $weight += (float) $item['data']->get_weight() * (int) $item['quantity'];\n    }\n\n    if ( $weight <= $wpide_weight_limit ) {\n        return $rates;\n    }\n\n    foreach ( $rates as $id => $rate ) {\n        if ( $id === $wpide_weight_method || $rate->get_method_id() === $wpide_weight_method ) {\n            unset( $rates[ $id ] );\n        }\n    }\n\n    return $rates;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use a method ID (flat_rate, free_shipping) to hide every instance or a full rate ID such as flat_rate:5 for one."
        },
        {
            "id": "woo-shipping-class-handling-fee",
            "version": 1,
            "title": "Handling Fee per Item in a Shipping Class",
            "description": "Charges an extra handling fee for each unit from a shipping class such as \"fragile\" or \"oversized\", listed as its own line in the totals.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "fees",
                "shipping-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_class_fee = array(\n    'class'  => 'fragile',               // Shipping class slug.\n    'amount' => 2.50,                    // Fee per unit.\n    'label'  => 'Fragile item handling', // Line label.\n);\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_class_fee ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    $units = 0;\n\n    foreach ( $cart->get_cart() as $item ) {\n        if ( $item['data']->get_shipping_class() === $wpide_class_fee['class'] ) {\n            $units += (int) $item['quantity'];\n        }\n    }\n\n    if ( $units > 0 ) {\n        $cart->add_fee( sprintf( '%s (x%d)', $wpide_class_fee['label'], $units ), $units * $wpide_class_fee['amount'], true );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the shipping class slug from WooCommerce > Settings > Shipping > Classes."
        },
        {
            "id": "woo-shipping-method-for-roles",
            "version": 1,
            "title": "Shipping Methods Reserved for Certain Roles",
            "description": "Hides chosen shipping methods, such as pallet delivery or trade collection, from everyone except customers with the roles you list.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "roles",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_reserved_methods = array( 'flat_rate:7' );       // Methods or rate IDs to reserve.\n$wpide_reserved_roles   = array( 'wholesale_customer' ); // Roles that may use them.\n\nadd_filter( 'woocommerce_package_rates', function ( $rates ) use ( $wpide_reserved_methods, $wpide_reserved_roles ) {\n    $allowed = is_user_logged_in() && array_intersect( $wpide_reserved_roles, (array) wp_get_current_user()->roles );\n\n    if ( $allowed ) {\n        return $rates;\n    }\n\n    foreach ( $rates as $id => $rate ) {\n        if ( in_array( $id, $wpide_reserved_methods, true ) || in_array( $rate->get_method_id(), $wpide_reserved_methods, true ) ) {\n            unset( $rates[ $id ] );\n        }\n    }\n\n    return $rates;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "List method IDs (local_pickup) or rate IDs (flat_rate:7); guests never see these methods."
        },
        {
            "id": "woo-sort-shipping-by-cost",
            "version": 1,
            "title": "Sort Shipping Options From Cheapest to Most Expensive",
            "description": "Lists the available shipping methods in price order on the cart and checkout, so the cheapest option is always shown first.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_package_rates', function ( $rates ) {\n    if ( count( $rates ) < 2 ) {\n        return $rates;\n    }\n\n    uasort( $rates, function ( $a, $b ) {\n        return (float) $a->get_cost() <=> (float) $b->get_cost();\n    } );\n\n    return $rates;\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Free methods come first; pair it with \"Select the Cheapest Shipping Method by Default\" to preselect the cheapest one too."
        },
        {
            "id": "woo-default-cheapest-shipping",
            "version": 1,
            "title": "Select the Cheapest Shipping Method by Default",
            "description": "Preselects the lowest-priced shipping option for new carts instead of whichever method happens to be first in the zone.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_shipping_chosen_method', function ( $default, $rates, $chosen ) {\n    if ( $chosen && isset( $rates[ $chosen ] ) ) {\n        return $chosen;\n    }\n\n    $cheapest = $default;\n    $lowest   = null;\n\n    foreach ( $rates as $id => $rate ) {\n        $cost = (float) $rate->get_cost();\n\n        if ( null === $lowest || $cost < $lowest ) {\n            $lowest   = $cost;\n            $cheapest = $id;\n        }\n    }\n\n    return $cheapest;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only sets the default; a method the customer picks themselves is kept."
        },
        {
            "id": "woo-shipping-method-descriptions",
            "version": 1,
            "title": "Descriptions Under Shipping Methods",
            "description": "Prints a short explanation under each shipping option at checkout, such as delivery times or \"signature required\", so customers know what they are choosing.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "checkout",
                "descriptions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rate_notes = array( // Rate ID or method ID => description.\n    'free_shipping' => 'Delivered in 3 to 5 working days.',\n    'flat_rate'     => 'Tracked delivery in 1 to 2 working days.',\n    'local_pickup'  => 'Collect from our shop, usually ready within 2 hours.',\n);\n\nadd_action( 'woocommerce_after_shipping_rate', function ( $rate ) use ( $wpide_rate_notes ) {\n    $text = $wpide_rate_notes[ $rate->get_id() ] ?? ( $wpide_rate_notes[ $rate->get_method_id() ] ?? '' );\n\n    if ( '' !== $text ) {\n        printf( '<br><small class=\"wpide-rate-note\">%s</small>', esc_html( $text ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Keys can be rate IDs (flat_rate:1) or method IDs (local_pickup), and the notes show on the classic cart and checkout."
        },
        {
            "id": "woo-free-shipping-label-suffix",
            "version": 1,
            "title": "Show \"Free\" Next to Zero-Cost Shipping Methods",
            "description": "Adds a clear \"Free\" label to shipping options that cost nothing, such as local pickup, which WooCommerce otherwise shows with no price at all.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_free_text = 'Free'; // Label added to zero-cost methods.\n\nadd_filter( 'woocommerce_cart_shipping_method_full_label', function ( $label, $method ) use ( $wpide_free_text ) {\n    if ( (float) $method->get_cost() > 0 || false !== strpos( $label, $wpide_free_text ) ) {\n        return $label;\n    }\n\n    return $label . ': <strong>' . esc_html( $wpide_free_text ) . '</strong>';\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $wpide_free_text to translate or restyle the label."
        },
        {
            "id": "woo-handling-fee-shipped-orders",
            "version": 1,
            "title": "Flat Handling Fee on Shipped Orders",
            "description": "Adds a fixed packing and handling charge to every order that needs shipping, while downloads and services stay fee-free.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "fees",
                "shipping",
                "handling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_handling = array(\n    'amount'  => 1.95,                 // Fee amount.\n    'label'   => 'Packing & handling', // Line label.\n    'taxable' => true,\n);\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_handling ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! $cart->needs_shipping() ) {\n        return;\n    }\n\n    $cart->add_fee( $wpide_handling['label'], $wpide_handling['amount'], $wpide_handling['taxable'] );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_handling['taxable'] to match how your accountant treats handling charges."
        },
        {
            "id": "woo-local-pickup-discount",
            "version": 1,
            "title": "Discount for Choosing Local Pickup",
            "description": "Rewards customers who collect their order in person with a percentage off the products, shown as a discount line when local pickup is selected.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "local-pickup",
                "discount"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_pickup_discount = 5; // Percent off the products subtotal.\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_pickup_discount ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->session ) {\n        return;\n    }\n\n    $chosen = (array) WC()->session->get( 'chosen_shipping_methods', array() );\n\n    foreach ( $chosen as $method ) {\n        if ( 0 === strpos( (string) $method, 'local_pickup' ) || 0 === strpos( (string) $method, 'pickup_location' ) ) {\n            $amount = $cart->get_subtotal() * $wpide_pickup_discount / 100;\n            $cart->add_fee( sprintf( 'Pickup discount (%s%%)', $wpide_pickup_discount ), -$amount );\n            return;\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with both the classic Local Pickup method and the block checkout's pickup_location method."
        },
        {
            "id": "woo-category-shipping-countries",
            "version": 1,
            "title": "Ship a Category Only to Certain Countries",
            "description": "Stops checkout when the cart contains products from a restricted category, such as batteries or plants, and the shipping country is not on your allowed list.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "countries",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_restricted_cats      = array( 'plants' );          // Category slugs.\n$wpide_restricted_countries = array( 'GB', 'IE' );        // Countries these may ship to.\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_restricted_cats, $wpide_restricted_countries ) {\n    $country = ! empty( $data['ship_to_different_address'] ) ? ( $data['shipping_country'] ?? '' ) : ( $data['billing_country'] ?? '' );\n\n    if ( '' === $country || in_array( $country, $wpide_restricted_countries, true ) || ! WC()->cart ) {\n        return;\n    }\n\n    $names = array();\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( has_term( $wpide_restricted_cats, 'product_cat', $item['product_id'] ) ) {\n            $names[] = $item['data']->get_name();\n        }\n    }\n\n    if ( $names ) {\n        $errors->add( 'shipping', sprintf( 'Sorry, we cannot ship %s to %s. Please remove it to continue.', esc_html( implode( ', ', array_unique( $names ) ) ), esc_html( WC()->countries->countries[ $country ] ?? $country ) ) );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses two-letter country codes, and the error message names the products that cannot be shipped."
        },
        {
            "id": "woo-block-po-box-addresses",
            "version": 1,
            "title": "Block PO Box Shipping Addresses",
            "description": "Rejects checkout when the shipping address is a PO Box or post office locker, for couriers that cannot deliver there.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "validation",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_po_patterns = '/\\b(p\\.?\\s*o\\.?\\s*box|post\\s*office\\s*box|pobox|packstation|postfach)\\b/i'; // Patterns to block.\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_po_patterns ) {\n    if ( empty( WC()->cart ) || ! WC()->cart->needs_shipping() ) {\n        return;\n    }\n\n    $prefix = ! empty( $data['ship_to_different_address'] ) ? 'shipping_' : 'billing_';\n    $lines  = ( $data[ $prefix . 'address_1' ] ?? '' ) . ' ' . ( $data[ $prefix . 'address_2' ] ?? '' );\n\n    if ( preg_match( $wpide_po_patterns, $lines ) ) {\n        $errors->add( 'shipping', 'Sorry, we cannot deliver to PO Boxes or parcel lockers. Please enter a street address.' );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Checks both address lines of the address the order ships to; extend $wpide_po_patterns for other languages."
        },
        {
            "id": "woo-delivery-postcode-whitelist",
            "version": 1,
            "title": "Accept Orders Only for Your Delivery Area",
            "description": "Stops checkout with a friendly message when the delivery postcode is outside the area you serve, ideal for local food, flower or furniture delivery.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "postcodes",
                "local-delivery"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_area_postcodes = array( 'SW1*', 'SW3*', 'W8*' ); // Postcodes or prefixes ending in *.\n$wpide_area_message   = 'Sorry, we only deliver within central London at the moment.';\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_area_postcodes, $wpide_area_message ) {\n    if ( empty( WC()->cart ) || ! WC()->cart->needs_shipping() ) {\n        return;\n    }\n\n    $prefix   = ! empty( $data['ship_to_different_address'] ) ? 'shipping_' : 'billing_';\n    $postcode = strtoupper( str_replace( ' ', '', (string) ( $data[ $prefix . 'postcode' ] ?? '' ) ) );\n\n    foreach ( $wpide_area_postcodes as $rule ) {\n        $rule = strtoupper( str_replace( ' ', '', $rule ) );\n\n        if ( '*' === substr( $rule, -1 ) ? 0 === strpos( $postcode, rtrim( $rule, '*' ) ) : $postcode === $rule ) {\n            return;\n        }\n    }\n\n    $errors->add( 'shipping', esc_html( $wpide_area_message ) );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Postcodes ending in * match by prefix, and orders that do not need shipping are never checked."
        },
        {
            "id": "woo-checkout-delivery-date",
            "version": 1,
            "title": "Preferred Delivery Date Field at Checkout",
            "description": "Adds a date picker to the classic checkout so customers can choose a delivery date after your lead time, then shows the date on the order screen and in emails.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "delivery",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_delivery = array(\n    'label'         => 'Preferred delivery date',\n    'lead_days'     => 2,    // Earliest date = today + this many days.\n    'max_days'      => 60,   // Latest date customers can pick.\n    'required'      => false,\n    'skip_weekends' => true,\n);\n\nadd_filter( 'woocommerce_checkout_fields', function ( $fields ) use ( $wpide_delivery ) {\n    $fields['order']['wpide_delivery_date'] = array(\n        'type'              => 'date',\n        'label'             => $wpide_delivery['label'],\n        'required'          => $wpide_delivery['required'],\n        'priority'          => 5,\n        'custom_attributes' => array(\n            'min' => wp_date( 'Y-m-d', strtotime( '+' . $wpide_delivery['lead_days'] . ' days' ) ),\n            'max' => wp_date( 'Y-m-d', strtotime( '+' . $wpide_delivery['max_days'] . ' days' ) ),\n        ),\n    );\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_delivery ) {\n    $value = (string) ( $data['wpide_delivery_date'] ?? '' );\n\n    if ( '' === $value ) {\n        return;\n    }\n\n    $date = DateTime::createFromFormat( '!Y-m-d', $value, wp_timezone() );\n\n    if ( ! $date || $date->format( 'Y-m-d' ) !== $value ) {\n        $errors->add( 'validation', 'Please choose a valid delivery date.' );\n        return;\n    }\n\n    $earliest = new DateTime( 'today +' . $wpide_delivery['lead_days'] . ' days', wp_timezone() );\n\n    if ( $date < $earliest ) {\n        $errors->add( 'validation', sprintf( 'The earliest delivery date is %s.', esc_html( wp_date( get_option( 'date_format' ), $earliest->getTimestamp() ) ) ) );\n    } elseif ( $wpide_delivery['skip_weekends'] && (int) $date->format( 'N' ) >= 6 ) {\n        $errors->add( 'validation', 'We do not deliver at weekends. Please choose a weekday.' );\n    }\n}, 10, 2 );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {\n    if ( ! empty( $data['wpide_delivery_date'] ) ) {\n        $order->update_meta_data( '_wpide_delivery_date', sanitize_text_field( $data['wpide_delivery_date'] ) );\n    }\n}, 10, 2 );\n\n$wpide_delivery_label = $wpide_delivery['label'];\n\nadd_action( 'woocommerce_admin_order_data_after_shipping_address', function ( $order ) use ( $wpide_delivery_label ) {\n    $date = $order->get_meta( '_wpide_delivery_date' );\n\n    if ( $date ) {\n        printf( '<p><strong>%s:</strong> %s</p>', esc_html( $wpide_delivery_label ), esc_html( wp_date( get_option( 'date_format' ), strtotime( $date ) ) ) );\n    }\n} );\n\nadd_filter( 'woocommerce_email_order_meta_fields', function ( $fields, $sent_to_admin, $order ) use ( $wpide_delivery_label ) {\n    $date = $order->get_meta( '_wpide_delivery_date' );\n\n    if ( $date ) {\n        $fields['wpide_delivery_date'] = array( 'label' => $wpide_delivery_label, 'value' => wp_date( get_option( 'date_format' ), strtotime( $date ) ) );\n    }\n\n    return $fields;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the browser's date picker; set 'lead_days' for the earliest date and 'skip_weekends' to refuse Saturdays and Sundays."
        },
        {
            "id": "woo-checkout-delivery-time-slot",
            "version": 1,
            "title": "Delivery Time Slot Choice at Checkout",
            "description": "Lets customers pick a delivery time window, such as morning or evening, on the classic checkout and saves it on the order for your drivers.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "delivery",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_slots = array( // Key => label.\n    'morning'   => 'Morning (8am to 12pm)',\n    'afternoon' => 'Afternoon (12pm to 5pm)',\n    'evening'   => 'Evening (5pm to 9pm)',\n);\n\nadd_filter( 'woocommerce_checkout_fields', function ( $fields ) use ( $wpide_slots ) {\n    $fields['order']['wpide_time_slot'] = array(\n        'type'     => 'select',\n        'label'    => 'Delivery time slot',\n        'required' => true,\n        'priority' => 6,\n        'options'  => array( '' => 'Choose a time slot' ) + $wpide_slots,\n    );\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order, $data ) use ( $wpide_slots ) {\n    $slot = (string) ( $data['wpide_time_slot'] ?? '' );\n\n    if ( isset( $wpide_slots[ $slot ] ) ) {\n        $order->update_meta_data( '_wpide_time_slot', $wpide_slots[ $slot ] );\n    }\n}, 10, 2 );\n\nadd_action( 'woocommerce_admin_order_data_after_shipping_address', function ( $order ) {\n    $slot = $order->get_meta( '_wpide_time_slot' );\n\n    if ( $slot ) {\n        printf( '<p><strong>Delivery slot:</strong> %s</p>', esc_html( $slot ) );\n    }\n} );\n\nadd_filter( 'woocommerce_email_order_meta_fields', function ( $fields, $sent_to_admin, $order ) {\n    $slot = $order->get_meta( '_wpide_time_slot' );\n\n    if ( $slot ) {\n        $fields['wpide_time_slot'] = array( 'label' => 'Delivery slot', 'value' => $slot );\n    }\n\n    return $fields;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit $wpide_slots to match your delivery rounds; the chosen slot appears on the order screen and in order emails."
        },
        {
            "id": "woo-block-checkout-delivery-day",
            "version": 1,
            "title": "Delivery Day Selector on the Block Checkout",
            "description": "Adds a required \"Delivery day\" dropdown to the Checkout block listing the next working days, stored on the order with WooCommerce's additional fields API.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "block-checkout",
                "delivery",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_delivery_days = array(\n    'lead_days' => 1,  // First day offered = today + this many days.\n    'count'     => 6,  // How many days to offer.\n    'weekends'  => false,\n);\n\nadd_action( 'woocommerce_init', function () use ( $wpide_delivery_days ) {\n    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {\n        return;\n    }\n\n    $options = array();\n    $day     = new DateTimeImmutable( 'today', wp_timezone() );\n    $day     = $day->modify( '+' . (int) $wpide_delivery_days['lead_days'] . ' days' );\n\n    while ( count( $options ) < $wpide_delivery_days['count'] ) {\n        if ( $wpide_delivery_days['weekends'] || (int) $day->format( 'N' ) < 6 ) {\n            $options[] = array( 'value' => $day->format( 'Y-m-d' ), 'label' => wp_date( 'l j F', $day->getTimestamp() ) );\n        }\n\n        $day = $day->modify( '+1 day' );\n    }\n\n    woocommerce_register_additional_checkout_field( array(\n        'id'       => 'wpide/delivery-day',\n        'label'    => 'Delivery day',\n        'location' => 'order',\n        'type'     => 'select',\n        'required' => true,\n        'options'  => $options,\n    ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs WooCommerce 8.9 or newer with the Checkout block, and the chosen day appears on the order screen and in emails automatically."
        },
        {
            "id": "woo-tax-exempt-role",
            "version": 1,
            "title": "Tax-Exempt Customer Roles",
            "description": "Removes tax from the cart and checkout for customers with roles you choose, such as charities, resellers or schools with tax exemption.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "tax",
                "roles",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_tax_exempt_roles = array( 'tax_exempt', 'wholesale_customer' ); // Roles without tax.\n\nadd_action( 'woocommerce_before_calculate_totals', function () use ( $wpide_tax_exempt_roles ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->customer ) {\n        return;\n    }\n\n    $exempt = is_user_logged_in() && array_intersect( $wpide_tax_exempt_roles, (array) wp_get_current_user()->roles );\n\n    if ( $exempt ) {\n        WC()->customer->set_is_vat_exempt( true );\n    }\n}, 5 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Orders record the exemption in WooCommerce's is_vat_exempt field, so keep proof of exemption on file for each customer."
        },
        {
            "id": "woo-tax-exempt-checkbox",
            "version": 1,
            "title": "Tax Exemption Checkbox With Certificate Number",
            "description": "Adds an \"I am tax exempt\" checkbox and certificate number to the classic checkout; when ticked, tax is removed and the number is saved on the order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "tax",
                "checkout",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_checkout_fields', function ( $fields ) {\n    $fields['billing']['billing_tax_exempt'] = array(\n        'type'     => 'checkbox',\n        'label'    => 'I am tax exempt',\n        'class'    => array( 'form-row-wide', 'update_totals_on_change' ),\n        'priority' => 125,\n    );\n    $fields['billing']['billing_tax_exempt_id'] = array(\n        'type'     => 'text',\n        'label'    => 'Tax exemption certificate number',\n        'class'    => array( 'form-row-wide' ),\n        'priority' => 126,\n    );\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_checkout_update_order_review', function ( $post_data ) {\n    parse_str( (string) $post_data, $posted );\n    WC()->customer->set_is_vat_exempt( ! empty( $posted['billing_tax_exempt'] ) );\n} );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {\n    if ( ! empty( $data['billing_tax_exempt'] ) && '' === trim( (string) ( $data['billing_tax_exempt_id'] ?? '' ) ) ) {\n        $errors->add( 'validation', 'Please enter your tax exemption certificate number.' );\n    }\n}, 10, 2 );\n\nadd_action( 'woocommerce_checkout_process', function () {\n    if ( WC()->customer ) {\n        WC()->customer->set_is_vat_exempt( ! empty( $_POST['billing_tax_exempt'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The snippet only checks that a certificate number was entered, so verify numbers before you ship."
        },
        {
            "id": "woo-vat-number-field",
            "version": 1,
            "title": "EU VAT Number Field With Format Check",
            "description": "Adds an optional VAT number field to the classic checkout, checks it matches the official format for the billing country, and saves it to the order and emails.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "vat",
                "checkout",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_vat_formats = array( // Country => pattern for the part after the country prefix.\n    'AT' => 'U\\d{8}', 'BE' => '[01]\\d{9}', 'BG' => '\\d{9,10}', 'CY' => '\\d{8}[A-Z]', 'CZ' => '\\d{8,10}',\n    'DE' => '\\d{9}', 'DK' => '\\d{8}', 'EE' => '\\d{9}', 'EL' => '\\d{9}', 'ES' => '[A-Z0-9]\\d{7}[A-Z0-9]',\n    'FI' => '\\d{8}', 'FR' => '[A-HJ-NP-Z0-9]{2}\\d{9}', 'HR' => '\\d{11}', 'HU' => '\\d{8}', 'IE' => '\\d{7}[A-W][A-I]?|\\d[A-Z+*]\\d{5}[A-W]',\n    'IT' => '\\d{11}', 'LT' => '\\d{9}|\\d{12}', 'LU' => '\\d{8}', 'LV' => '\\d{11}', 'MT' => '\\d{8}', 'NL' => '\\d{9}B\\d{2}',\n    'PL' => '\\d{10}', 'PT' => '\\d{9}', 'RO' => '\\d{2,10}', 'SE' => '\\d{12}', 'SI' => '\\d{8}', 'SK' => '\\d{10}', 'XI' => '\\d{9}|\\d{12}',\n);\n\nadd_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    $fields['billing_vat_number'] = array(\n        'type'     => 'text',\n        'label'    => 'VAT number',\n        'required' => false,\n        'class'    => array( 'form-row-wide' ),\n        'priority' => 35,\n    );\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_vat_formats ) {\n    $vat = strtoupper( preg_replace( '/[\\s.\\-]/', '', (string) ( $data['billing_vat_number'] ?? '' ) ) );\n\n    if ( '' === $vat ) {\n        return;\n    }\n\n    $country = (string) ( $data['billing_country'] ?? '' );\n    $prefix  = 'GR' === $country ? 'EL' : $country;\n\n    if ( ! isset( $wpide_vat_formats[ $prefix ] ) ) {\n        return;\n    }\n\n    if ( 0 === strpos( $vat, $prefix ) ) {\n        $vat = substr( $vat, strlen( $prefix ) );\n    }\n\n    if ( ! preg_match( '/^(' . $wpide_vat_formats[ $prefix ] . ')$/', $vat ) ) {\n        $errors->add( 'validation', sprintf( 'The VAT number does not look like a valid %s VAT number. Please check it or leave the field empty.', esc_html( $prefix ) ) );\n    }\n}, 10, 2 );\n\nadd_filter( 'woocommerce_order_formatted_billing_address', function ( $address, $order ) {\n    $vat = $order->get_meta( '_billing_vat_number' );\n\n    if ( $vat ) {\n        $address['company'] = trim( ( $address['company'] ?? '' ) . \"\\nVAT: \" . $vat );\n    }\n\n    return $address;\n}, 10, 2 );\n\nadd_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {\n    $vat = $order->get_meta( '_billing_vat_number' );\n\n    if ( $vat ) {\n        printf( '<p><strong>VAT number:</strong> %s</p>', esc_html( $vat ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Checks the number's format only (it does not contact VIES) and stores it as _billing_vat_number under the billing address."
        },
        {
            "id": "woo-vat-reverse-charge",
            "version": 1,
            "title": "Reverse Charge VAT for EU Business Buyers",
            "description": "Removes VAT for business customers in another EU country who enter a VAT number, and adds a \"reverse charge\" note to the order, as cross-border B2B sales require.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "vat",
                "tax",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_eu_countries = array( 'AT', 'BE', 'BG', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', 'PL', 'PT', 'RO', 'SE', 'SI', 'SK' );\n\n$wpide_reverse_charge = function ( $country, $vat ) use ( $wpide_eu_countries ) {\n    $base = WC()->countries->get_base_country();\n\n    return '' !== trim( (string) $vat ) && $country !== $base && in_array( $country, $wpide_eu_countries, true );\n};\n\nadd_action( 'woocommerce_checkout_update_order_review', function ( $post_data ) use ( $wpide_reverse_charge ) {\n    parse_str( (string) $post_data, $posted );\n    WC()->customer->set_is_vat_exempt( $wpide_reverse_charge( (string) ( $posted['billing_country'] ?? '' ), $posted['billing_vat_number'] ?? '' ) );\n} );\n\nadd_action( 'woocommerce_checkout_process', function () use ( $wpide_reverse_charge ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the checkout nonce.\n    $exempt = $wpide_reverse_charge( wc_clean( wp_unslash( $_POST['billing_country'] ?? '' ) ), wc_clean( wp_unslash( $_POST['billing_vat_number'] ?? '' ) ) );\n    WC()->customer->set_is_vat_exempt( $exempt );\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order ) {\n    if ( WC()->customer && WC()->customer->get_is_vat_exempt() && $order->get_meta( '_billing_vat_number' ) ) {\n        $order->update_meta_data( '_wpide_reverse_charge', 'yes' );\n    }\n}, 20 );\n\nadd_action( 'woocommerce_email_after_order_table', function ( $order ) {\n    if ( 'yes' === $order->get_meta( '_wpide_reverse_charge' ) ) {\n        echo '<p>' . esc_html( 'VAT reverse charge: the customer accounts for VAT (Article 196, Council Directive 2006/112/EC).' ) . '</p>';\n    }\n} );\n\nadd_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {\n    if ( 'yes' === $order->get_meta( '_wpide_reverse_charge' ) ) {\n        echo '<p><strong>' . esc_html( 'VAT reverse charge applied' ) . '</strong></p>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs the \"EU VAT Number Field With Format Check\" snippet for the billing_vat_number field; confirm the rules for your business with your accountant."
        },
        {
            "id": "woo-excl-tax-prices-for-b2b",
            "version": 1,
            "title": "Show Prices Without Tax to Business Customers",
            "description": "Displays shop and cart prices excluding tax for trade roles, while regular shoppers keep seeing tax-inclusive prices.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "tax",
                "b2b",
                "prices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_b2b_display_roles = array( 'wholesale_customer', 'business' ); // Roles that see prices excluding tax.\n\n$wpide_b2b_display = function ( $value ) use ( $wpide_b2b_display_roles ) {\n    if ( ! is_user_logged_in() ) {\n        return $value;\n    }\n\n    return array_intersect( $wpide_b2b_display_roles, (array) wp_get_current_user()->roles ) ? 'excl' : $value;\n};\n\nadd_filter( 'option_woocommerce_tax_display_shop', $wpide_b2b_display );\nadd_filter( 'option_woocommerce_tax_display_cart', $wpide_b2b_display );\n\nadd_filter( 'woocommerce_get_variation_prices_hash', function ( $hash ) use ( $wpide_b2b_display ) {\n    $hash['wpide_tax_display'] = $wpide_b2b_display( 'default' );\n\n    return $hash;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only changes how prices are shown; the tax charged at checkout is unchanged."
        },
        {
            "id": "woo-price-incl-and-excl-tax",
            "version": 1,
            "title": "Show Prices Both With and Without Tax",
            "description": "Adds the tax-exclusive price under every tax-inclusive price (or the reverse), so both consumers and business buyers see the number they need.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "tax",
                "prices",
                "display"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_dual_labels = array(\n    'incl' => 'incl. VAT',\n    'excl' => 'excl. VAT',\n);\n\nadd_filter( 'woocommerce_get_price_suffix', function ( $suffix, $product, $price = '', $qty = 1 ) use ( $wpide_dual_labels ) {\n    if ( ! wc_tax_enabled() || ! $product->is_taxable() || '' === $product->get_price() ) {\n        return $suffix;\n    }\n\n    $incl = wc_get_price_including_tax( $product, array( 'qty' => $qty ) );\n    $excl = wc_get_price_excluding_tax( $product, array( 'qty' => $qty ) );\n\n    if ( 'incl' === get_option( 'woocommerce_tax_display_shop' ) ) {\n        return sprintf( ' <small class=\"woocommerce-price-suffix\">%s<br>%s %s</small>', esc_html( $wpide_dual_labels['incl'] ), wc_price( $excl ), esc_html( $wpide_dual_labels['excl'] ) );\n    }\n\n    return sprintf( ' <small class=\"woocommerce-price-suffix\">%s<br>%s %s</small>', esc_html( $wpide_dual_labels['excl'] ), wc_price( $incl ), esc_html( $wpide_dual_labels['incl'] ) );\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs taxes enabled in WooCommerce; edit the two labels to match your local wording, such as VAT, GST or MwSt."
        },
        {
            "id": "woo-category-zero-rate-tax",
            "version": 1,
            "title": "Zero-Rate Tax Class for a Category",
            "description": "Applies a different tax class, such as Zero rate or Reduced rate, to every product in chosen categories, so you do not have to edit each product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "tax",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_tax_class_cats = array( 'books', 'childrens-clothing' ); // Category slugs.\n$wpide_tax_class_slug = 'zero-rate';                           // Tax class slug.\n\n$wpide_tax_class = function ( $class, $product ) use ( $wpide_tax_class_cats, $wpide_tax_class_slug ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return has_term( $wpide_tax_class_cats, 'product_cat', $id ) ? $wpide_tax_class_slug : $class;\n};\n\nadd_filter( 'woocommerce_product_get_tax_class', $wpide_tax_class, 10, 2 );\nadd_filter( 'woocommerce_product_variation_get_tax_class', $wpide_tax_class, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the tax class slug from WooCommerce > Settings > Tax (zero-rate, reduced-rate); variations follow their parent's categories."
        },
        {
            "id": "woo-shipping-calculator-fields",
            "version": 1,
            "title": "Simplify the Cart Shipping Calculator",
            "description": "Removes the city and postcode or state boxes from the cart's shipping calculator when your rates only depend on the country.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_calc_keep = array(\n    'state'    => false, // Keep the state/county field.\n    'city'     => false, // Keep the city field.\n    'postcode' => false, // Keep the postcode field.\n);\n\nadd_filter( 'woocommerce_shipping_calculator_enable_state', function () use ( $wpide_calc_keep ) {\n    return $wpide_calc_keep['state'];\n} );\nadd_filter( 'woocommerce_shipping_calculator_enable_city', function () use ( $wpide_calc_keep ) {\n    return $wpide_calc_keep['city'];\n} );\nadd_filter( 'woocommerce_shipping_calculator_enable_postcode', function () use ( $wpide_calc_keep ) {\n    return $wpide_calc_keep['postcode'];\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set any of the three values to true to keep that field."
        },
        {
            "id": "woo-free-shipping-product-note",
            "version": 1,
            "title": "\"Free Shipping Over X\" Note on Product Pages",
            "description": "Shows a small free-shipping reminder under the price on product pages, with the threshold taken from your settings, to nudge bigger orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "product-page",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ship_note = array(\n    'threshold' => 50,                                        // Free shipping threshold.\n    'text'      => 'Free shipping on orders over %s',          // %s = threshold.\n);\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_ship_note ) {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->needs_shipping() ) {\n        return;\n    }\n\n    printf( '<p class=\"wpide-ship-note\">%s</p>', wp_kses_post( sprintf( esc_html( $wpide_ship_note['text'] ), wc_price( $wpide_ship_note['threshold'] ) ) ) );\n}, 11 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Hidden on virtual and downloadable products; change the wording in $wpide_ship_note."
        },
        {
            "id": "woo-express-shipping-cutoff",
            "version": 1,
            "title": "Hide Express Shipping After the Daily Cut-Off",
            "description": "Removes your next-day or express shipping option after a set time and at weekends, so customers are not promised a delivery you cannot dispatch.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "express",
                "schedule"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_express = array(\n    'rate'      => 'flat_rate:4', // Method ID or rate ID of the express option.\n    'cutoff'    => '14:00',       // Last order time (24h).\n    'weekends'  => false,         // Offer express at weekends.\n);\n\nadd_filter( 'woocommerce_package_rates', function ( $rates ) use ( $wpide_express ) {\n    $now     = current_time( 'H:i' );\n    $weekend = (int) current_time( 'N' ) >= 6;\n\n    if ( $now < $wpide_express['cutoff'] && ( $wpide_express['weekends'] || ! $weekend ) ) {\n        return $rates;\n    }\n\n    foreach ( $rates as $id => $rate ) {\n        if ( $id === $wpide_express['rate'] || $rate->get_method_id() === $wpide_express['rate'] ) {\n            unset( $rates[ $id ] );\n        }\n    }\n\n    return $rates;\n}, 20 );\n\nadd_filter( 'woocommerce_cart_shipping_packages', function ( $packages ) {\n    foreach ( $packages as $i => $package ) {\n        $packages[ $i ]['wpide_hour'] = current_time( 'Y-m-d H' );\n    }\n\n    return $packages;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the site's timezone, and rates refresh every hour so the option disappears on time for open carts."
        },
        {
            "id": "woo-shipping-insurance-option",
            "version": 1,
            "title": "Optional Shipping Insurance at Checkout",
            "description": "Offers a \"Protect my parcel\" checkbox on the classic checkout that adds an insurance fee based on the order value, and updates the totals instantly.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fees",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_insurance = array(\n    'label'   => 'Shipping insurance',\n    'percent' => 2,    // Percent of the products subtotal.\n    'minimum' => 1.50, // Lowest fee charged.\n);\n\n$wpide_insurance_chosen = function () {\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies its checkout requests.\n    if ( isset( $_POST['post_data'] ) ) {\n        parse_str( wp_unslash( $_POST['post_data'] ), $posted );\n        WC()->session->set( 'wpide_insurance', ! empty( $posted['wpide_insurance'] ) );\n    } elseif ( isset( $_POST['woocommerce-process-checkout-nonce'] ) ) {\n        WC()->session->set( 'wpide_insurance', ! empty( $_POST['wpide_insurance'] ) );\n    }\n    // phpcs:enable\n\n    return (bool) WC()->session->get( 'wpide_insurance', false );\n};\n\nadd_action( 'woocommerce_review_order_before_order_total', function () use ( $wpide_insurance, $wpide_insurance_chosen ) {\n    if ( ! WC()->cart->needs_shipping() ) {\n        return;\n    }\n\n    printf(\n        '<tr class=\"wpide-insurance\"><th>%s</th><td><label><input type=\"checkbox\" name=\"wpide_insurance\" value=\"1\" class=\"update_totals_on_change\" onchange=\"jQuery(document.body).trigger(\\'update_checkout\\')\" %s> %s</label></td></tr>',\n        esc_html( $wpide_insurance['label'] ),\n        checked( $wpide_insurance_chosen(), true, false ),\n        esc_html( 'Protect my parcel against loss and damage' )\n    );\n} );\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_insurance, $wpide_insurance_chosen ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->session || ! $cart->needs_shipping() || ! $wpide_insurance_chosen() ) {\n        return;\n    }\n\n    $fee = max( $wpide_insurance['minimum'], $cart->get_subtotal() * $wpide_insurance['percent'] / 100 );\n    $cart->add_fee( $wpide_insurance['label'], round( $fee, wc_get_price_decimals() ), true );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The fee is a percentage of the subtotal with a minimum, and customers can untick it any time before paying."
        },
        {
            "id": "woo-gift-wrap-option",
            "version": 1,
            "title": "Gift Wrapping Option at Checkout",
            "description": "Adds a \"Gift wrap this order\" checkbox to the classic checkout for a small fee and flags the order so your packers can spot it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fees",
                "gifts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gift_wrap = array(\n    'label' => 'Gift wrapping',\n    'price' => 3.50,\n);\n\n$wpide_gift_wrap_chosen = function () {\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies its checkout requests.\n    if ( isset( $_POST['post_data'] ) ) {\n        parse_str( wp_unslash( $_POST['post_data'] ), $posted );\n        WC()->session->set( 'wpide_gift_wrap', ! empty( $posted['wpide_gift_wrap'] ) );\n    } elseif ( isset( $_POST['woocommerce-process-checkout-nonce'] ) ) {\n        WC()->session->set( 'wpide_gift_wrap', ! empty( $_POST['wpide_gift_wrap'] ) );\n    }\n    // phpcs:enable\n\n    return (bool) WC()->session->get( 'wpide_gift_wrap', false );\n};\n\nadd_action( 'woocommerce_review_order_before_order_total', function () use ( $wpide_gift_wrap, $wpide_gift_wrap_chosen ) {\n    printf(\n        '<tr class=\"wpide-gift-wrap\"><th>%s</th><td><label><input type=\"checkbox\" name=\"wpide_gift_wrap\" value=\"1\" onchange=\"jQuery(document.body).trigger(\\'update_checkout\\')\" %s> %s</label></td></tr>',\n        esc_html( $wpide_gift_wrap['label'] ),\n        checked( $wpide_gift_wrap_chosen(), true, false ),\n        wp_kses_post( sprintf( 'Gift wrap this order (+%s)', wc_price( $wpide_gift_wrap['price'] ) ) )\n    );\n} );\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_gift_wrap, $wpide_gift_wrap_chosen ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->session || ! $wpide_gift_wrap_chosen() ) {\n        return;\n    }\n\n    $cart->add_fee( $wpide_gift_wrap['label'], $wpide_gift_wrap['price'], true );\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order ) use ( $wpide_gift_wrap_chosen ) {\n    if ( WC()->session && $wpide_gift_wrap_chosen() ) {\n        $order->update_meta_data( '_wpide_gift_wrap', 'yes' );\n    }\n} );\n\nadd_action( 'woocommerce_admin_order_data_after_order_details', function ( $order ) {\n    if ( 'yes' === $order->get_meta( '_wpide_gift_wrap' ) ) {\n        echo '<p class=\"form-field form-field-wide\" style=\"color:#b32d2e;font-weight:600\">' . esc_html( 'Gift wrap this order' ) . '</p>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set the price and label at the top; gift-wrapped orders are flagged on the order screen."
        },
        {
            "id": "woo-wholesale-price-field",
            "version": 1,
            "title": "Wholesale Price Field per Product",
            "description": "Adds a \"Wholesale price\" box to simple products and variations, and charges that price to customers with your wholesale role instead of the retail price.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "wholesale",
                "pricing",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wholesale_role = 'wholesale_customer'; // Role that pays wholesale prices.\n\n$wpide_is_wholesale = function () use ( $wpide_wholesale_role ) {\n    return is_user_logged_in() && in_array( $wpide_wholesale_role, (array) wp_get_current_user()->roles, true );\n};\n\nadd_action( 'woocommerce_product_options_pricing', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'        => '_wpide_wholesale_price',\n        'label'     => 'Wholesale price (' . get_woocommerce_currency_symbol() . ')',\n        'data_type' => 'price',\n        'value'     => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_wholesale_price' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_wholesale_price'] ) ) {\n        $product->update_meta_data( '_wpide_wholesale_price', wc_format_decimal( wp_unslash( $_POST['_wpide_wholesale_price'] ) ) );\n    }\n} );\n\nadd_action( 'woocommerce_variation_options_pricing', function ( $loop, $data, $variation ) {\n    woocommerce_wp_text_input( array(\n        'id'            => '_wpide_wholesale_price[' . $loop . ']',\n        'label'         => 'Wholesale price (' . get_woocommerce_currency_symbol() . ')',\n        'data_type'     => 'price',\n        'wrapper_class' => 'form-row form-row-full',\n        'value'         => get_post_meta( $variation->ID, '_wpide_wholesale_price', true ),\n    ) );\n}, 10, 3 );\n\nadd_action( 'woocommerce_save_product_variation', function ( $variation_id, $i ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the variations save nonce.\n    if ( isset( $_POST['_wpide_wholesale_price'][ $i ] ) && current_user_can( 'edit_product', $variation_id ) ) {\n        update_post_meta( $variation_id, '_wpide_wholesale_price', wc_format_decimal( wp_unslash( $_POST['_wpide_wholesale_price'][ $i ] ) ) );\n    }\n}, 10, 2 );\n\n$wpide_wholesale_filter = function ( $price, $product ) use ( $wpide_is_wholesale ) {\n    if ( ! $wpide_is_wholesale() ) {\n        return $price;\n    }\n\n    $wholesale = $product->get_meta( '_wpide_wholesale_price', true, 'edit' );\n\n    return '' !== $wholesale ? $wholesale : $price;\n};\n\nforeach ( array( 'woocommerce_product_get_price', 'woocommerce_product_variation_get_price', 'woocommerce_variation_prices_price' ) as $wpide_hook ) {\n    add_filter( $wpide_hook, $wpide_wholesale_filter, 99, 2 );\n}\n\nadd_filter( 'woocommerce_product_is_on_sale', function ( $on_sale, $product ) use ( $wpide_is_wholesale ) {\n    return $wpide_is_wholesale() && '' !== $product->get_meta( '_wpide_wholesale_price', true, 'edit' ) ? false : $on_sale;\n}, 10, 2 );\n\nadd_filter( 'woocommerce_get_variation_prices_hash', function ( $hash ) use ( $wpide_is_wholesale ) {\n    $hash['wpide_wholesale'] = $wpide_is_wholesale() ? 'yes' : 'no';\n\n    return $hash;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The wholesale role must already exist, and products with an empty wholesale price charge wholesale buyers the normal price."
        },
        {
            "id": "woo-quantity-price-tiers",
            "version": 1,
            "title": "Quantity Price Breaks With a Price Table",
            "description": "Lowers the unit price when a customer buys more of the same product, for example 5% off from 10 units, and shows a price-break table on the product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "pricing",
                "quantity",
                "tiers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_qty_tiers = array(\n    'tiers'    => array( 10 => 5, 25 => 10, 50 => 15 ), // Minimum quantity => percent off.\n    'products' => array(),                              // Product IDs, empty for all.\n);\n\n$wpide_tier_percent = function ( $qty ) use ( $wpide_qty_tiers ) {\n    $percent = 0;\n\n    foreach ( $wpide_qty_tiers['tiers'] as $min => $off ) {\n        if ( $qty >= $min ) {\n            $percent = $off;\n        }\n    }\n\n    return $percent;\n};\n\n$wpide_tier_applies = function ( $product_id ) use ( $wpide_qty_tiers ) {\n    return empty( $wpide_qty_tiers['products'] ) || in_array( (int) $product_id, array_map( 'intval', $wpide_qty_tiers['products'] ), true );\n};\n\nadd_action( 'woocommerce_before_calculate_totals', function ( $cart ) use ( $wpide_tier_percent, $wpide_tier_applies ) {\n    static $base_prices = null;\n\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    $base_prices = $base_prices ?: new SplObjectStorage(); // Original prices, so repeated totals runs never stack.\n\n    foreach ( $cart->get_cart() as $item ) {\n        if ( ! $wpide_tier_applies( $item['product_id'] ) ) {\n            continue;\n        }\n\n        if ( ! $base_prices->contains( $item['data'] ) ) {\n            $base_prices[ $item['data'] ] = (float) $item['data']->get_price( 'edit' );\n        }\n\n        $base    = $base_prices[ $item['data'] ];\n        $percent = $wpide_tier_percent( (int) $item['quantity'] );\n        $item['data']->set_price( $percent ? round( $base * ( 100 - $percent ) / 100, wc_get_price_decimals() ) : $base );\n    }\n}, 20 );\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_qty_tiers, $wpide_tier_applies ) {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->is_purchasable() || '' === $product->get_price() || ! $wpide_tier_applies( $product->get_id() ) ) {\n        return;\n    }\n\n    $price = (float) wc_get_price_to_display( $product );\n\n    echo '<table class=\"wpide-price-tiers shop_table\"><thead><tr><th>' . esc_html( 'Quantity' ) . '</th><th>' . esc_html( 'Price each' ) . '</th></tr></thead><tbody>';\n\n    foreach ( $wpide_qty_tiers['tiers'] as $min => $off ) {\n        printf( '<tr><td>%s+</td><td>%s <small>(-%s%%)</small></td></tr>', esc_html( $min ), wp_kses_post( wc_price( $price * ( 100 - $off ) / 100 ) ), esc_html( $off ) );\n    }\n\n    echo '</tbody></table>';\n}, 25 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Tiers apply per cart line; list product IDs in 'products' to limit them, or leave it empty for every product."
        },
        {
            "id": "woo-wholesale-minimum-order",
            "version": 1,
            "title": "Minimum Order Amount for Wholesale Customers",
            "description": "Requires trade customers to reach a higher minimum order value than retail shoppers before they can check out.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "wholesale",
                "minimum",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_trade_minimum = array(\n    'roles'  => array( 'wholesale_customer' ), // Roles with the minimum.\n    'amount' => 250,                           // Minimum subtotal.\n);\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_trade_minimum ) {\n    if ( ! is_user_logged_in() || ! array_intersect( $wpide_trade_minimum['roles'], (array) wp_get_current_user()->roles ) ) {\n        return;\n    }\n\n    $subtotal = (float) WC()->cart->get_subtotal();\n\n    if ( $subtotal < $wpide_trade_minimum['amount'] ) {\n        wc_add_notice(\n            sprintf( 'Trade orders have a minimum of %1$s. Your cart is currently %2$s.', wc_price( $wpide_trade_minimum['amount'] ), wc_price( $subtotal ) ),\n            'error'\n        );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The minimum is compared with the cart subtotal before shipping, and retail customers are not affected."
        },
        {
            "id": "woo-category-for-roles-only",
            "version": 1,
            "title": "Product Category Visible Only to Certain Roles",
            "description": "Hides a category's products from the shop, search and product pages for everyone except the roles you choose, such as trade customers or members.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "roles",
                "visibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_private_cats = array(\n    'categories' => array( 'trade-only' ),         // Category slugs to hide.\n    'roles'      => array( 'wholesale_customer' ), // Roles that can see them ('logged_in' = any logged-in user).\n);\n\n$wpide_can_see_private = function () use ( $wpide_private_cats ) {\n    if ( current_user_can( 'manage_woocommerce' ) ) {\n        return true;\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return false;\n    }\n\n    return in_array( 'logged_in', $wpide_private_cats['roles'], true ) || array_intersect( $wpide_private_cats['roles'], (array) wp_get_current_user()->roles );\n};\n\n$wpide_private_tax_query = array(\n    'taxonomy' => 'product_cat',\n    'field'    => 'slug',\n    'terms'    => $wpide_private_cats['categories'],\n    'operator' => 'NOT IN',\n);\n\nadd_action( 'woocommerce_product_query', function ( $query ) use ( $wpide_can_see_private, $wpide_private_tax_query ) {\n    if ( $wpide_can_see_private() ) {\n        return;\n    }\n\n    $tax_query   = (array) $query->get( 'tax_query' );\n    $tax_query[] = $wpide_private_tax_query;\n    $query->set( 'tax_query', $tax_query );\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_can_see_private, $wpide_private_tax_query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || $wpide_can_see_private() ) {\n        return;\n    }\n\n    $tax_query   = (array) $query->get( 'tax_query' );\n    $tax_query[] = $wpide_private_tax_query;\n    $query->set( 'tax_query', $tax_query );\n} );\n\nadd_filter( 'woocommerce_shortcode_products_query', function ( $args ) use ( $wpide_can_see_private, $wpide_private_tax_query ) {\n    if ( ! $wpide_can_see_private() ) {\n        $args['tax_query']   = isset( $args['tax_query'] ) ? (array) $args['tax_query'] : array();\n        $args['tax_query'][] = $wpide_private_tax_query;\n    }\n\n    return $args;\n} );\n\nadd_filter( 'woocommerce_related_products', function ( $ids ) use ( $wpide_can_see_private, $wpide_private_cats ) {\n    if ( $wpide_can_see_private() ) {\n        return $ids;\n    }\n\n    return array_values( array_filter( $ids, function ( $id ) use ( $wpide_private_cats ) {\n        return ! has_term( $wpide_private_cats['categories'], 'product_cat', $id );\n    } ) );\n} );\n\nadd_action( 'template_redirect', function () use ( $wpide_can_see_private, $wpide_private_cats ) {\n    $blocked = ( is_product() && has_term( $wpide_private_cats['categories'], 'product_cat', get_queried_object_id() ) )\n        || ( is_product_category( $wpide_private_cats['categories'] ) );\n\n    if ( $blocked && ! $wpide_can_see_private() ) {\n        global $wp_query;\n        $wp_query->set_404();\n        status_header( 404 );\n        nocache_headers();\n    }\n} );\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) use ( $wpide_can_see_private, $wpide_private_cats ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return $purchasable && ( $wpide_can_see_private() || ! has_term( $wpide_private_cats['categories'], 'product_cat', $id ) );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add 'logged_in' to the roles to show the category to any signed-in customer; direct links to hidden products show a 404 page."
        },
        {
            "id": "woo-category-in-store-only",
            "version": 1,
            "title": "\"Available In Store Only\" Products by Category",
            "description": "Lets customers browse products from chosen categories online but removes the Add to Cart button and shows an \"available in store only\" message instead.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "categories",
                "purchasing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_store_only = array(\n    'categories' => array( 'furniture' ),                                      // Category slugs.\n    'message'    => 'Available in our showroom only. Call us to reserve one.', // Shown instead of Add to Cart.\n);\n\n$wpide_store_only_match = function ( $product ) use ( $wpide_store_only ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return has_term( $wpide_store_only['categories'], 'product_cat', $id );\n};\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) use ( $wpide_store_only_match ) {\n    return $purchasable && ! $wpide_store_only_match( $product );\n}, 10, 2 );\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_store_only, $wpide_store_only_match ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && $wpide_store_only_match( $product ) ) {\n        printf( '<p class=\"wpide-store-only\">%s</p>', esc_html( $wpide_store_only['message'] ) );\n    }\n}, 31 );\n\nadd_filter( 'woocommerce_product_add_to_cart_text', function ( $text, $product ) use ( $wpide_store_only_match ) {\n    return $wpide_store_only_match( $product ) ? 'View details' : $text;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products stay visible with their price; add your opening hours or address to the message."
        },
        {
            "id": "woo-category-price-on-request",
            "version": 1,
            "title": "\"Price on Request\" for a Category",
            "description": "Hides prices and the Add to Cart button for products in chosen categories and shows a \"Request a price\" button that links to your contact page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "prices",
                "quotes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_por = array(\n    'categories' => array( 'bespoke' ), // Category slugs.\n    'label'      => 'Price on request',\n    'button'     => 'Request a price',\n    'url'        => '/contact/',        // Contact page URL.\n);\n\n$wpide_por_match = function ( $product ) use ( $wpide_por ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return has_term( $wpide_por['categories'], 'product_cat', $id );\n};\n\nadd_filter( 'woocommerce_get_price_html', function ( $html, $product ) use ( $wpide_por, $wpide_por_match ) {\n    return $wpide_por_match( $product ) ? '<span class=\"wpide-por\">' . esc_html( $wpide_por['label'] ) . '</span>' : $html;\n}, 20, 2 );\n\nadd_filter( 'woocommerce_is_purchasable', function ( $purchasable, $product ) use ( $wpide_por_match ) {\n    return $purchasable && ! $wpide_por_match( $product );\n}, 10, 2 );\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_por, $wpide_por_match ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && $wpide_por_match( $product ) ) {\n        printf(\n            '<p><a class=\"button alt wpide-por-button\" href=\"%s\">%s</a></p>',\n            esc_url( add_query_arg( 'product', rawurlencode( $product->get_name() ), home_url( $wpide_por['url'] ) ) ),\n            esc_html( $wpide_por['button'] )\n        );\n    }\n}, 30 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The product title is added to the contact link as ?product=, which form plugins can use to prefill a field."
        },
        {
            "id": "woo-category-minimum-quantity",
            "version": 1,
            "title": "Minimum Combined Quantity for a Category",
            "description": "Requires customers to buy a minimum number of items from a category in total, for example any 6 wines, mixed as they like.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "categories",
                "minimum"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_min = array(\n    'categories' => array( 'wine' ), // Category slugs.\n    'minimum'    => 6,               // Minimum items from these categories.\n    'name'       => 'wines',         // Used in the message.\n);\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_cat_min ) {\n    $count = 0;\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( has_term( $wpide_cat_min['categories'], 'product_cat', $item['product_id'] ) ) {\n            $count += (int) $item['quantity'];\n        }\n    }\n\n    if ( $count > 0 && $count < $wpide_cat_min['minimum'] ) {\n        wc_add_notice( sprintf( 'Please add at least %1$d %2$s to your order (you have %3$d). Mix and match as you like.', $wpide_cat_min['minimum'], esc_html( $wpide_cat_min['name'] ), $count ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only carts that contain the category are checked, so other products can still be bought on their own."
        },
        {
            "id": "woo-category-sold-in-multiples",
            "version": 1,
            "title": "Sell a Category in Multiples (Cases)",
            "description": "Makes the total quantity from a category a multiple of a case size, for example 6 or 12 bottles, and explains how many more to add.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "categories",
                "cases"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_case = array(\n    'categories' => array( 'wine' ), // Category slugs.\n    'size'       => 6,               // Case size.\n);\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_case ) {\n    $count = 0;\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( has_term( $wpide_case['categories'], 'product_cat', $item['product_id'] ) ) {\n            $count += (int) $item['quantity'];\n        }\n    }\n\n    $remainder = $wpide_case['size'] > 0 ? $count % $wpide_case['size'] : 0;\n\n    if ( $count > 0 && $remainder ) {\n        wc_add_notice( sprintf( 'These products are sold in cases of %1$d. Please add %2$d more to complete a case.', $wpide_case['size'], $wpide_case['size'] - $remainder ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Different products in the category can be mixed within a case."
        },
        {
            "id": "woo-product-max-quantity-field",
            "version": 1,
            "title": "Maximum Quantity Field per Product",
            "description": "Adds a \"Maximum per order\" field to the product Inventory tab and enforces it in the quantity box and the cart, for limited releases or allocated stock.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "inventory",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_inventory_product_data', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'                => '_wpide_max_qty',\n        'label'             => 'Maximum per order',\n        'type'              => 'number',\n        'custom_attributes' => array( 'min' => 1, 'step' => 1 ),\n        'desc_tip'          => true,\n        'description'       => 'Leave empty for no limit.',\n        'value'             => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_max_qty' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_max_qty'] ) ) {\n        $max = absint( wp_unslash( $_POST['_wpide_max_qty'] ) );\n        $max ? $product->update_meta_data( '_wpide_max_qty', $max ) : $product->delete_meta_data( '_wpide_max_qty' );\n    }\n} );\n\n$wpide_max_for = function ( $product ) {\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return absint( get_post_meta( $id, '_wpide_max_qty', true ) );\n};\n\nadd_filter( 'woocommerce_quantity_input_args', function ( $args, $product ) use ( $wpide_max_for ) {\n    $max = $wpide_max_for( $product );\n\n    if ( $max ) {\n        $args['max_value'] = isset( $args['max_value'] ) && $args['max_value'] > 0 ? min( $args['max_value'], $max ) : $max;\n    }\n\n    return $args;\n}, 10, 2 );\n\nadd_filter( 'woocommerce_available_variation', function ( $data, $product, $variation ) use ( $wpide_max_for ) {\n    $max = $wpide_max_for( $variation );\n\n    if ( $max ) {\n        $data['max_qty'] = '' === $data['max_qty'] ? $max : min( (int) $data['max_qty'], $max );\n    }\n\n    return $data;\n}, 10, 3 );\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_max_for ) {\n    $totals = array();\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $totals[ $item['product_id'] ] = ( $totals[ $item['product_id'] ] ?? 0 ) + (int) $item['quantity'];\n    }\n\n    foreach ( $totals as $product_id => $qty ) {\n        $product = wc_get_product( $product_id );\n        $max     = $product ? $wpide_max_for( $product ) : 0;\n\n        if ( $max && $qty > $max ) {\n            wc_add_notice( sprintf( 'You can buy up to %1$d of \"%2$s\" per order.', $max, esc_html( $product->get_name() ) ), 'error' );\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Leave the field empty for no limit; variations follow the limit set on their parent product."
        },
        {
            "id": "woo-buy-x-get-cheapest-free",
            "version": 1,
            "title": "Buy 3, Get the Cheapest One Free",
            "description": "Runs a \"buy X, get one free\" deal automatically: for every set of items in the cart, the cheapest one is discounted to zero, shown as a line in the totals.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "discounts",
                "bogo",
                "promotion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_bogo = array(\n    'group'      => 3,       // Every 3rd item is free (buy 2 get 1 = 3).\n    'categories' => array(), // Category slugs, empty for all products.\n    'label'      => 'Buy 2, get 1 free',\n);\n\n$wpide_bogo_prices = function ( $cart ) use ( $wpide_bogo ) {\n    $prices = array();\n\n    foreach ( $cart->get_cart() as $item ) {\n        if ( $wpide_bogo['categories'] && ! has_term( $wpide_bogo['categories'], 'product_cat', $item['product_id'] ) ) {\n            continue;\n        }\n\n        $unit = (float) wc_get_price_excluding_tax( $item['data'] );\n\n        for ( $i = 0; $i < (int) $item['quantity']; $i++ ) {\n            $prices[] = $unit;\n        }\n    }\n\n    sort( $prices );\n\n    return $prices;\n};\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_bogo, $wpide_bogo_prices ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    $prices = $wpide_bogo_prices( $cart );\n    $free   = (int) floor( count( $prices ) / max( 1, $wpide_bogo['group'] ) );\n\n    if ( $free > 0 ) {\n        $cart->add_fee( $wpide_bogo['label'], -array_sum( array_slice( $prices, 0, $free ) ), true );\n    }\n} );\n\nadd_action( 'woocommerce_before_cart', function () use ( $wpide_bogo, $wpide_bogo_prices ) {\n    $count = count( $wpide_bogo_prices( WC()->cart ) );\n    $left  = $wpide_bogo['group'] - ( $count % $wpide_bogo['group'] );\n\n    if ( $count > 0 && $left > 0 && $left < $wpide_bogo['group'] ) {\n        wc_print_notice( sprintf( 'Add %1$d more qualifying item(s) to get one free (%2$s).', $left, esc_html( $wpide_bogo['label'] ) ), 'notice' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set the group size and optional categories at the top; a cart notice tells shoppers how many more items unlock the next free one."
        },
        {
            "id": "woo-bundle-together-discount",
            "version": 1,
            "title": "Bundle Discount for Products Bought Together",
            "description": "Gives a discount when a set of products is in the cart together, and suggests the missing items with an add-to-cart link when only part of the bundle is there.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "bundles",
                "discounts",
                "upsell"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_bundles = array(\n    array(\n        'name'     => 'Camera starter kit',\n        'products' => array( 101, 102, 103 ), // Product IDs in the bundle.\n        'discount' => 15,                     // Amount off when all are in the cart.\n    ),\n);\n\n$wpide_bundle_status = function ( $bundle ) {\n    $in_cart = array();\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $in_cart[ $item['product_id'] ] = ( $in_cart[ $item['product_id'] ] ?? 0 ) + (int) $item['quantity'];\n    }\n\n    $sets    = PHP_INT_MAX;\n    $missing = array();\n\n    foreach ( $bundle['products'] as $id ) {\n        $qty = $in_cart[ $id ] ?? 0;\n        $sets = min( $sets, $qty );\n\n        if ( ! $qty ) {\n            $missing[] = $id;\n        }\n    }\n\n    return array(\n        'sets'    => PHP_INT_MAX === $sets ? 0 : $sets,\n        'missing' => $missing,\n        'partial' => count( $missing ) < count( $bundle['products'] ),\n    );\n};\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_bundles, $wpide_bundle_status ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    foreach ( $wpide_bundles as $bundle ) {\n        $status = $wpide_bundle_status( $bundle );\n\n        if ( $status['sets'] > 0 ) {\n            $cart->add_fee( $bundle['name'] . ' discount', -$bundle['discount'] * $status['sets'], true );\n        }\n    }\n} );\n\nadd_action( 'woocommerce_before_cart', function () use ( $wpide_bundles, $wpide_bundle_status ) {\n    foreach ( $wpide_bundles as $bundle ) {\n        $status = $wpide_bundle_status( $bundle );\n\n        if ( ! $status['partial'] || ! $status['missing'] ) {\n            continue;\n        }\n\n        $links = array();\n\n        foreach ( $status['missing'] as $id ) {\n            $product = wc_get_product( $id );\n\n            if ( $product && $product->is_purchasable() && $product->is_in_stock() ) {\n                $links[] = sprintf( '<a href=\"%s\">%s</a>', esc_url( $product->add_to_cart_url() ), esc_html( $product->get_name() ) );\n            }\n        }\n\n        if ( $links ) {\n            wc_print_notice( sprintf( 'Complete the %1$s and save %2$s: add %3$s.', esc_html( $bundle['name'] ), wc_price( $bundle['discount'] ), implode( ', ', $links ) ), 'notice' );\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add more entries to $wpide_bundles for more bundles; the discount applies once per complete bundle in the cart."
        },
        {
            "id": "woo-price-per-unit",
            "version": 1,
            "title": "Unit Price Display (Price per kg, litre or item)",
            "description": "Shows a comparison price such as \"4.50 / kg\" under the product price, calculated from a unit quantity you enter on each product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "unit-price",
                "display"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_pricing', function () {\n    global $product_object;\n\n    $get = function ( $key ) use ( $product_object ) {\n        return $product_object instanceof WC_Product ? $product_object->get_meta( $key ) : '';\n    };\n\n    woocommerce_wp_text_input( array( 'id' => '_wpide_unit_amount', 'label' => 'Pack contents', 'data_type' => 'decimal', 'description' => 'For example 0.5 for 500 g.', 'desc_tip' => true, 'value' => $get( '_wpide_unit_amount' ) ) );\n    woocommerce_wp_text_input( array( 'id' => '_wpide_unit_label', 'label' => 'Unit', 'placeholder' => 'kg', 'value' => $get( '_wpide_unit_label' ) ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_unit_amount'], $_POST['_wpide_unit_label'] ) ) {\n        $product->update_meta_data( '_wpide_unit_amount', wc_format_decimal( wp_unslash( $_POST['_wpide_unit_amount'] ) ) );\n        $product->update_meta_data( '_wpide_unit_label', sanitize_text_field( wp_unslash( $_POST['_wpide_unit_label'] ) ) );\n    }\n    // phpcs:enable\n} );\n\nadd_filter( 'woocommerce_get_price_html', function ( $html, $product ) {\n    $amount = (float) $product->get_meta( '_wpide_unit_amount' );\n    $label  = $product->get_meta( '_wpide_unit_label' );\n\n    if ( $amount <= 0 || '' === $label || '' === $product->get_price() || $product->is_type( 'variable' ) ) {\n        return $html;\n    }\n\n    $unit = wc_get_price_to_display( $product ) / $amount;\n\n    return $html . sprintf( '<br><small class=\"wpide-unit-price\">%s / %s</small>', wc_price( $unit ), esc_html( $label ) );\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Enter the pack contents (for example 0.5 for 500 g) and the unit on each product's General tab; products without them are unchanged."
        },
        {
            "id": "woo-trim-price-zeros",
            "version": 1,
            "title": "Hide \".00\" on Whole-Number Prices",
            "description": "Shows round prices as 25 instead of 25.00 while keeping decimals on prices that need them, such as 24.99.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "formatting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_price_trim_zeros', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Affects every price WooCommerce formats, including the cart and emails."
        },
        {
            "id": "woo-was-now-sale-price",
            "version": 1,
            "title": "\"Was / Now\" Sale Price Format",
            "description": "Replaces the crossed-out sale price layout with clear \"Now 20.00, was 25.00\" wording that reads better and works with screen readers.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "sale",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_was_now = array(\n    'now' => 'Now',\n    'was' => 'was',\n);\n\nadd_filter( 'woocommerce_format_sale_price', function ( $html, $regular, $sale ) use ( $wpide_was_now ) {\n    $regular = is_numeric( $regular ) ? wc_price( $regular ) : $regular;\n    $sale    = is_numeric( $sale ) ? wc_price( $sale ) : $sale;\n\n    return sprintf(\n        '<span class=\"wpide-now\">%1$s %2$s</span> <span class=\"wpide-was\">%3$s <del>%4$s</del></span>',\n        esc_html( $wpide_was_now['now'] ),\n        wp_kses_post( $sale ),\n        esc_html( $wpide_was_now['was'] ),\n        wp_kses_post( $regular )\n    );\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change the words in $wpide_was_now to match your tone."
        },
        {
            "id": "woo-hide-variable-price-range",
            "version": 1,
            "title": "Hide the Price Range Until a Variation Is Chosen",
            "description": "Removes the \"10.00 to 50.00\" range at the top of variable product pages, so the exact price appears only once the shopper picks options.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "prices",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {\n    if ( ! is_product() || ! $product->is_type( 'variable' ) || $product->get_id() !== get_queried_object_id() ) {\n        return $html;\n    }\n\n    if ( doing_action( 'woocommerce_single_product_summary' ) && $product->get_variation_price( 'min' ) !== $product->get_variation_price( 'max' ) ) {\n        return '';\n    }\n\n    return $html;\n}, 20, 2 );\n\nadd_filter( 'woocommerce_show_variation_price', '__return_true' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shop listings keep their price range, and the variation price shows even when all variations cost the same."
        },
        {
            "id": "woo-price-label-suffix-field",
            "version": 1,
            "title": "Price Label Field per Product (\"per m²\", \"per month\")",
            "description": "Adds a field to each product for a short label shown after its price, such as \"per m²\", \"per person\" or \"per night\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "labels",
                "display"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_pricing', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'          => '_wpide_price_label',\n        'label'       => 'Price label',\n        'placeholder' => 'per m²',\n        'value'       => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_price_label' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_price_label'] ) ) {\n        $product->update_meta_data( '_wpide_price_label', sanitize_text_field( wp_unslash( $_POST['_wpide_price_label'] ) ) );\n    }\n} );\n\nadd_filter( 'woocommerce_get_price_html', function ( $html, $product ) {\n    if ( '' === $html ) {\n        return $html;\n    }\n\n    $id    = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n    $label = get_post_meta( $id, '_wpide_price_label', true );\n\n    return '' === $label ? $html : $html . ' <span class=\"wpide-price-label\">' . esc_html( $label ) . '</span>';\n}, 30, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The label appears wherever the price is shown, and variations use their parent product's label."
        },
        {
            "id": "woo-featured-product-badge",
            "version": 1,
            "title": "\"Featured\" Badge on Featured Products",
            "description": "Shows a \"Featured\" or \"Staff pick\" badge on the image of products you have starred as featured, in the shop and on product pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badges",
                "featured",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_featured_badge = array(\n    'text'       => 'Staff pick',\n    'background' => '#1e3a8a',\n    'color'      => '#ffffff',\n);\n\n$wpide_featured_badge_html = function () use ( $wpide_featured_badge ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && $product->is_featured() ) {\n        printf( '<span class=\"wpide-featured-badge\">%s</span>', esc_html( $wpide_featured_badge['text'] ) );\n    }\n};\n\nadd_action( 'woocommerce_before_shop_loop_item_title', $wpide_featured_badge_html, 9 );\nadd_action( 'woocommerce_before_single_product_summary', $wpide_featured_badge_html, 9 );\n\nadd_action( 'wp_head', function () use ( $wpide_featured_badge ) {\n    if ( ! is_woocommerce() ) {\n        return;\n    }\n\n    printf(\n        '<style>.woocommerce ul.products li.product,.woocommerce div.product{position:relative}.wpide-featured-badge{position:absolute;top:.5em;right:.5em;z-index:9;padding:.25em .6em;border-radius:3px;font-size:.8em;font-weight:600;line-height:1.4;background:%s;color:%s}</style>',\n        esc_attr( $wpide_featured_badge['background'] ),\n        esc_attr( $wpide_featured_badge['color'] )\n    );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Star products in Products > All Products to feature them, and change the badge text and colours at the top."
        },
        {
            "id": "woo-tag-based-badges",
            "version": 1,
            "title": "Product Badges From Product Tags",
            "description": "Turns chosen product tags into coloured badges on product images, so tagging a product \"vegan\" or \"limited\" instantly shows a matching label.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badges",
                "tags",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_tag_badges = array( // Tag slug => array( label, background colour ).\n    'vegan'   => array( 'Vegan', '#15803d' ),\n    'limited' => array( 'Limited edition', '#9a3412' ),\n    'eco'     => array( 'Eco friendly', '#0f766e' ),\n);\n\n$wpide_tag_badges_html = function () use ( $wpide_tag_badges ) {\n    global $product;\n\n    if ( ! $product instanceof WC_Product ) {\n        return;\n    }\n\n    $slugs = wp_list_pluck( get_the_terms( $product->get_id(), 'product_tag' ) ?: array(), 'slug' );\n    $html  = '';\n\n    foreach ( $wpide_tag_badges as $slug => $badge ) {\n        if ( in_array( $slug, $slugs, true ) ) {\n            $html .= sprintf( '<span class=\"wpide-tag-badge\" style=\"background:%s\">%s</span>', esc_attr( $badge[1] ), esc_html( $badge[0] ) );\n        }\n    }\n\n    if ( $html ) {\n        echo '<div class=\"wpide-tag-badges\">' . $html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- Escaped above.\n    }\n};\n\nadd_action( 'woocommerce_before_shop_loop_item_title', $wpide_tag_badges_html, 9 );\nadd_action( 'woocommerce_before_single_product_summary', $wpide_tag_badges_html, 9 );\n\nadd_action( 'wp_head', function () {\n    if ( is_woocommerce() ) {\n        echo '<style>.woocommerce ul.products li.product,.woocommerce div.product{position:relative}.wpide-tag-badges{position:absolute;top:.5em;left:.5em;z-index:9;display:flex;flex-wrap:wrap;gap:4px}.wpide-tag-badge{padding:.2em .55em;border-radius:3px;color:#fff;font-size:.75em;font-weight:600;line-height:1.4}</style>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Map each tag slug to a label and colour; products with several mapped tags show several badges."
        },
        {
            "id": "woo-backorder-badge",
            "version": 1,
            "title": "\"Pre-Order\" Badge for Backorder Products",
            "description": "Labels products that are out of stock but allow backorders with a \"Pre-order\" badge in the shop and on product pages, so buyers know shipping will be later.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "badges",
                "backorders",
                "stock"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_backorder_badge = 'Pre-order'; // Badge text.\n\n$wpide_backorder_badge_html = function () use ( $wpide_backorder_badge ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && $product->is_on_backorder() ) {\n        printf( '<span class=\"wpide-backorder-badge\">%s</span>', esc_html( $wpide_backorder_badge ) );\n    }\n};\n\nadd_action( 'woocommerce_before_shop_loop_item_title', $wpide_backorder_badge_html, 9 );\nadd_action( 'woocommerce_before_single_product_summary', $wpide_backorder_badge_html, 9 );\n\nadd_action( 'wp_head', function () {\n    if ( is_woocommerce() ) {\n        echo '<style>.woocommerce ul.products li.product,.woocommerce div.product{position:relative}.wpide-backorder-badge{position:absolute;bottom:.5em;left:.5em;z-index:9;padding:.25em .6em;border-radius:3px;background:#6d28d9;color:#fff;font-size:.8em;font-weight:600}</style>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shows on products whose stock status is \"On backorder\"; change the text to \"Backorder\" or \"Ships later\" if you prefer."
        },
        {
            "id": "woo-hide-products-without-price",
            "version": 1,
            "title": "Hide Products Without a Price From the Shop",
            "description": "Keeps unfinished products that have no price yet out of the shop, category pages and product searches until you add one.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "visibility",
                "prices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_require_price = function ( $query ) {\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array(\n        'key'     => '_price',\n        'value'   => '',\n        'compare' => '!=',\n    );\n    $query->set( 'meta_query', $meta_query );\n};\n\nadd_action( 'woocommerce_product_query', $wpide_require_price );\n\nadd_filter( 'woocommerce_shortcode_products_query', function ( $args ) {\n    $args['meta_query']   = isset( $args['meta_query'] ) ? (array) $args['meta_query'] : array();\n    $args['meta_query'][] = array( 'key' => '_price', 'value' => '', 'compare' => '!=' );\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products stay reachable by direct link, and variable products are listed when any variation has a price."
        },
        {
            "id": "woo-sort-by-sku",
            "version": 1,
            "title": "\"Sort by SKU\" Option in the Shop",
            "description": "Adds a \"Sort by product code\" choice to the shop's sorting dropdown, useful for trade catalogues where customers order by code.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sorting",
                "sku",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_catalog_orderby', function ( $options ) {\n    $options['sku'] = 'Sort by product code';\n\n    return $options;\n} );\n\nadd_filter( 'woocommerce_get_catalog_ordering_args', function ( $args, $orderby ) {\n    if ( 'sku' === $orderby ) {\n        $args['orderby']  = 'meta_value title';\n        $args['order']    = 'ASC';\n        $args['meta_key'] = '_sku'; // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Products without a SKU are listed last."
        },
        {
            "id": "woo-sort-by-name",
            "version": 1,
            "title": "\"Sort by Name (A to Z)\" Option in the Shop",
            "description": "Adds alphabetical sorting to the shop's sorting dropdown, in both A to Z and Z to A directions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sorting",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_catalog_orderby', function ( $options ) {\n    $options['title-asc']  = 'Sort by name: A to Z';\n    $options['title-desc'] = 'Sort by name: Z to A';\n\n    return $options;\n} );\n\nadd_filter( 'woocommerce_get_catalog_ordering_args', function ( $args, $orderby ) {\n    if ( 'title-asc' === $orderby || 'title-desc' === $orderby ) {\n        $args['orderby']  = 'title';\n        $args['order']    = 'title-asc' === $orderby ? 'ASC' : 'DESC';\n        $args['meta_key'] = ''; // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Rename the labels in the first filter to suit your shop."
        },
        {
            "id": "woo-sort-by-most-reviewed",
            "version": 1,
            "title": "\"Most Reviewed\" Sorting Option",
            "description": "Adds a \"Sort by most reviewed\" choice to the shop so shoppers can find the products other customers talk about most.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sorting",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_catalog_orderby', function ( $options ) {\n    $options['reviews'] = 'Sort by most reviewed';\n\n    return $options;\n} );\n\nadd_filter( 'woocommerce_get_catalog_ordering_args', function ( $args, $orderby ) {\n    if ( 'reviews' === $orderby ) {\n        $args['orderby']  = array( 'meta_value_num' => 'DESC', 'date' => 'DESC' );\n        $args['order']    = 'DESC';\n        $args['meta_key'] = '_wc_review_count'; // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the review count WooCommerce already stores, so it adds no extra work per page."
        },
        {
            "id": "woo-category-default-sorting",
            "version": 1,
            "title": "Different Default Sorting per Category",
            "description": "Sets the default product order for specific categories, for example newest first for \"New arrivals\" and price for \"Clearance\", while others keep the store default.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sorting",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_category_sorting = array( // Category slug => default sorting.\n    'new-arrivals' => 'date',\n    'clearance'    => 'price',\n);\n\nadd_filter( 'woocommerce_default_catalog_orderby', function ( $orderby ) use ( $wpide_category_sorting ) {\n    if ( ! is_product_category() ) {\n        return $orderby;\n    }\n\n    $term = get_queried_object();\n\n    return ( $term && isset( $wpide_category_sorting[ $term->slug ] ) ) ? $wpide_category_sorting[ $term->slug ] : $orderby;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Values are the sorting keys WooCommerce uses: menu_order, popularity, rating, date, price, price-desc."
        },
        {
            "id": "woo-sort-random-daily",
            "version": 1,
            "title": "\"Shuffle\" Sorting That Changes Daily",
            "description": "Adds a random order option to the shop sorting that stays the same all day, so pagination works and every product gets its turn at the top.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sorting",
                "random"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_shuffle_default = false; // Make shuffle the default order.\n\nadd_filter( 'woocommerce_catalog_orderby', function ( $options ) {\n    $options['shuffle'] = 'Discover (random)';\n\n    return $options;\n} );\n\nadd_filter( 'woocommerce_get_catalog_ordering_args', function ( $args, $orderby ) {\n    if ( 'shuffle' === $orderby ) {\n        $args['orderby']  = 'RAND(' . absint( current_time( 'Ymd' ) ) . ')';\n        $args['order']    = '';\n        $args['meta_key'] = ''; // phpcs:ignore WordPress.DB.SlowDBQuery\n    }\n\n    return $args;\n}, 10, 2 );\n\nif ( $wpide_shuffle_default ) {\n    add_filter( 'woocommerce_default_catalog_orderby', function () {\n        return 'shuffle';\n    } );\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_shuffle_default to true to make it the default order for the shop."
        },
        {
            "id": "woo-product-custom-tab-field",
            "version": 1,
            "title": "Custom Product Tab Edited per Product",
            "description": "Adds an editor box to each product for an extra tab, such as \"Ingredients\" or \"Specifications\", that only appears on products where you filled it in.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-tabs",
                "product-page",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_field_tab = array(\n    'title'    => 'Specifications', // Tab title.\n    'priority' => 25,               // Position among the tabs.\n);\n\nadd_action( 'add_meta_boxes_product', function () use ( $wpide_field_tab ) {\n    add_meta_box( 'wpide-tab-content', $wpide_field_tab['title'] . ' tab', function ( $post ) {\n        wp_nonce_field( 'wpide_tab_content', 'wpide_tab_content_nonce' );\n        wp_editor( get_post_meta( $post->ID, '_wpide_tab_content', true ), 'wpide_tab_content', array( 'textarea_rows' => 8, 'media_buttons' => false ) );\n    }, 'product', 'normal' );\n} );\n\nadd_action( 'save_post_product', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_tab_content_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_tab_content_nonce'] ), 'wpide_tab_content' ) || ! current_user_can( 'edit_post', $post_id ) || wp_is_post_autosave( $post_id ) ) {\n        return;\n    }\n\n    update_post_meta( $post_id, '_wpide_tab_content', wp_kses_post( wp_unslash( $_POST['wpide_tab_content'] ?? '' ) ) );\n} );\n\nadd_filter( 'woocommerce_product_tabs', function ( $tabs ) use ( $wpide_field_tab ) {\n    global $product;\n\n    $content = $product instanceof WC_Product ? get_post_meta( $product->get_id(), '_wpide_tab_content', true ) : '';\n\n    if ( '' !== trim( (string) $content ) ) {\n        $tabs['wpide_field_tab'] = array(\n            'title'    => $wpide_field_tab['title'],\n            'priority' => $wpide_field_tab['priority'],\n            'callback' => function () use ( $content ) {\n                echo wp_kses_post( wpautop( do_shortcode( $content ) ) );\n            },\n        );\n    }\n\n    return $tabs;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change the tab title in $wpide_field_tab; the content allows post HTML and runs shortcodes."
        },
        {
            "id": "woo-product-video-tab",
            "version": 1,
            "title": "Product Video Tab",
            "description": "Adds a \"Video\" field to products and shows the video (YouTube, Vimeo or an uploaded MP4) in its own product tab.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-tabs",
                "video",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_general_product_data', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'          => '_wpide_video_url',\n        'label'       => 'Video URL',\n        'type'        => 'url',\n        'placeholder' => 'https://www.youtube.com/watch?v=...',\n        'value'       => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_video_url' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_video_url'] ) ) {\n        $product->update_meta_data( '_wpide_video_url', esc_url_raw( wp_unslash( $_POST['_wpide_video_url'] ) ) );\n    }\n} );\n\nadd_filter( 'woocommerce_product_tabs', function ( $tabs ) {\n    global $product;\n\n    $url = $product instanceof WC_Product ? $product->get_meta( '_wpide_video_url' ) : '';\n\n    if ( $url ) {\n        $tabs['wpide_video'] = array(\n            'title'    => 'Video',\n            'priority' => 15,\n            'callback' => function () use ( $url ) {\n                if ( preg_match( '/\\.(mp4|webm|ogv)(\\?.*)?$/i', $url ) ) {\n                    echo wp_video_shortcode( array( 'src' => esc_url( $url ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput\n                    return;\n                }\n\n                $embed = $GLOBALS['wp_embed']->shortcode( array(), esc_url( $url ) );\n                echo '<div class=\"wpide-video-tab\">' . $embed . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- oEmbed output.\n            },\n        );\n    }\n\n    return $tabs;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Paste a YouTube or Vimeo link or a media library MP4 URL; WordPress caches the embed after the first view."
        },
        {
            "id": "woo-category-product-tab",
            "version": 1,
            "title": "Shared Product Tab for One Category",
            "description": "Adds the same extra tab, such as \"Care instructions\" for clothing or \"Allergens\" for food, to every product in the categories you choose.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-tabs",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_tab = array(\n    'categories' => array( 'clothing' ),                                                     // Category slugs.\n    'title'      => 'Care instructions',\n    'content'    => '<p>Machine wash at 30°C. Do not tumble dry. Iron on a low heat inside out.</p>',\n    'priority'   => 30,\n);\n\nadd_filter( 'woocommerce_product_tabs', function ( $tabs ) use ( $wpide_cat_tab ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && has_term( $wpide_cat_tab['categories'], 'product_cat', $product->get_id() ) ) {\n        $tabs['wpide_cat_tab'] = array(\n            'title'    => $wpide_cat_tab['title'],\n            'priority' => $wpide_cat_tab['priority'],\n            'callback' => function () use ( $wpide_cat_tab ) {\n                echo wp_kses_post( do_shortcode( $wpide_cat_tab['content'] ) );\n            },\n        );\n    }\n\n    return $tabs;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the content here once and every product in the categories updates; shortcodes are run."
        },
        {
            "id": "woo-product-documents-tab",
            "version": 1,
            "title": "Downloads Tab With Manuals and Data Sheets",
            "description": "Adds a \"Documents\" field to products for links to manuals, data sheets or certificates, listed in a Downloads tab on the product page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-tabs",
                "documents",
                "downloads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_general_product_data', function () {\n    global $product_object;\n\n    woocommerce_wp_textarea_input( array(\n        'id'          => '_wpide_documents',\n        'label'       => 'Documents',\n        'placeholder' => \"User manual | https://example.com/manual.pdf\\nData sheet | https://example.com/sheet.pdf\",\n        'description' => 'One per line: Label | URL',\n        'desc_tip'    => true,\n        'value'       => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_documents' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_documents'] ) ) {\n        $product->update_meta_data( '_wpide_documents', sanitize_textarea_field( wp_unslash( $_POST['_wpide_documents'] ) ) );\n    }\n} );\n\nadd_filter( 'woocommerce_product_tabs', function ( $tabs ) {\n    global $product;\n\n    $raw  = $product instanceof WC_Product ? (string) $product->get_meta( '_wpide_documents' ) : '';\n    $docs = array();\n\n    foreach ( preg_split( '/\\r\\n|\\n/', $raw ) as $line ) {\n        $parts = array_map( 'trim', explode( '|', $line, 2 ) );\n\n        if ( 2 === count( $parts ) && wp_http_validate_url( $parts[1] ) ) {\n            $docs[] = $parts;\n        }\n    }\n\n    if ( $docs ) {\n        $tabs['wpide_documents'] = array(\n            'title'    => 'Downloads',\n            'priority' => 35,\n            'callback' => function () use ( $docs ) {\n                echo '<ul class=\"wpide-documents\">';\n\n                foreach ( $docs as $doc ) {\n                    printf( '<li><a href=\"%s\" target=\"_blank\" rel=\"noopener\">%s</a></li>', esc_url( $doc[1] ), esc_html( $doc[0] ) );\n                }\n\n                echo '</ul>';\n            },\n        );\n    }\n\n    return $tabs;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Enter one document per line as \"Label | URL\", using URLs of PDFs uploaded to the media library."
        },
        {
            "id": "woo-search-results-per-page",
            "version": 1,
            "title": "More Products per Page on Search Results",
            "description": "Shows a different number of products on product search results than on normal shop pages, so shoppers see more matches at once.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_search_per_page = 36; // Products per page on search results.\n\nadd_filter( 'loop_shop_per_page', function ( $per_page ) use ( $wpide_search_per_page ) {\n    return is_search() ? $wpide_search_per_page : $per_page;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only affects product searches; shop and category pages keep their own setting."
        },
        {
            "id": "woo-product-search-placeholder",
            "version": 1,
            "title": "Change the Product Search Placeholder",
            "description": "Replaces the \"Search products…\" hint inside WooCommerce's product search box with your own text, such as \"Search 2,000 parts by name or code\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_search_placeholder = 'Search by name or product code'; // Placeholder text.\n\nadd_filter( 'get_product_search_form', function ( $form ) use ( $wpide_search_placeholder ) {\n    return preg_replace( '/placeholder=\"[^\"]*\"/', 'placeholder=\"' . esc_attr( $wpide_search_placeholder ) . '\"', $form, 1 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Affects the classic product search widget and get_product_search_form(); the Product Search block has its own placeholder setting."
        },
        {
            "id": "woo-product-search-titles-only",
            "version": 1,
            "title": "Match Product Searches on Titles Only",
            "description": "Makes product searches look only at product names instead of full descriptions, giving shorter and more relevant results for catalogues with long descriptions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_search', function ( $search, $query ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || 'product' !== $query->get( 'post_type' ) ) {\n        return $search;\n    }\n\n    $terms = (array) $query->get( 'search_terms' );\n\n    if ( ! $terms ) {\n        return $search;\n    }\n\n    $clauses = array();\n\n    foreach ( $terms as $term ) {\n        $clauses[] = $wpdb->prepare( \"{$wpdb->posts}.post_title LIKE %s\", '%' . $wpdb->esc_like( $term ) . '%' );\n    }\n\n    return ' AND (' . implode( ' AND ', $clauses ) . ') ';\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only product searches on the front end are affected; blog searches keep matching content."
        },
        {
            "id": "woo-search-matches-category",
            "version": 1,
            "title": "Send Searches That Match a Category to It",
            "description": "Takes shoppers straight to a product category page when what they search for is exactly a category name, such as \"dresses\" or \"garden tools\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "categories",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_search() || 'product' !== get_query_var( 'post_type' ) || is_paged() ) {\n        return;\n    }\n\n    $term = get_term_by( 'name', trim( get_search_query( false ) ), 'product_cat' );\n\n    if ( ! $term ) {\n        $term = get_term_by( 'slug', sanitize_title( get_search_query( false ) ), 'product_cat' );\n    }\n\n    if ( $term && ! is_wp_error( $term ) ) {\n        $link = get_term_link( $term );\n\n        if ( ! is_wp_error( $link ) ) {\n            wp_safe_redirect( $link );\n            exit;\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Matching ignores upper and lower case, and partial matches still show the normal search results."
        },
        {
            "id": "woo-product-subtitle",
            "version": 1,
            "title": "Product Subtitle Field",
            "description": "Adds a subtitle field to products and shows it under the product name on product pages and in shop listings, for taglines like \"Organic cotton, made in Portugal\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "fields",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_subtitle_in_loop = true; // Also show in shop listings.\n\nadd_action( 'woocommerce_product_options_general_product_data', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'    => '_wpide_subtitle',\n        'label' => 'Subtitle',\n        'value' => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_subtitle' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_subtitle'] ) ) {\n        $product->update_meta_data( '_wpide_subtitle', sanitize_text_field( wp_unslash( $_POST['_wpide_subtitle'] ) ) );\n    }\n} );\n\n$wpide_subtitle_html = function ( $tag ) {\n    return function () use ( $tag ) {\n        global $product;\n\n        $subtitle = $product instanceof WC_Product ? $product->get_meta( '_wpide_subtitle' ) : '';\n\n        if ( '' !== $subtitle ) {\n            printf( '<%1$s class=\"wpide-subtitle\">%2$s</%1$s>', tag_escape( $tag ), esc_html( $subtitle ) );\n        }\n    };\n};\n\nadd_action( 'woocommerce_single_product_summary', $wpide_subtitle_html( 'p' ), 6 );\n\nif ( $wpide_subtitle_in_loop ) {\n    add_action( 'woocommerce_after_shop_loop_item_title', $wpide_subtitle_html( 'span' ), 4 );\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_subtitle_in_loop to false to show it on product pages only."
        },
        {
            "id": "woo-loop-view-product-button",
            "version": 1,
            "title": "Replace Shop \"Add to Cart\" With \"View Product\"",
            "description": "Swaps the Add to Cart buttons in shop listings for a \"View product\" button, so customers see the full details and options before buying.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "buttons",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_view_button = 'View product'; // Button text.\n\nadd_filter( 'woocommerce_loop_add_to_cart_link', function ( $html, $product ) use ( $wpide_view_button ) {\n    return sprintf(\n        '<a href=\"%s\" class=\"button wpide-view-product\" aria-label=\"%s\">%s</a>',\n        esc_url( $product->get_permalink() ),\n        esc_attr( sprintf( '%s: %s', $wpide_view_button, $product->get_name() ) ),\n        esc_html( $wpide_view_button )\n    );\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Product pages keep their normal Add to Cart button."
        },
        {
            "id": "woo-sku-in-cart-items",
            "version": 1,
            "title": "Show SKUs in the Cart, Checkout and Orders",
            "description": "Prints each product's SKU under its name in the cart, checkout and order details, which trade buyers and phone orders often need.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sku",
                "cart",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_item_data', function ( $data, $item ) {\n    $sku = isset( $item['data'] ) ? $item['data']->get_sku() : '';\n\n    if ( $sku ) {\n        $data[] = array( 'key' => 'SKU', 'value' => $sku );\n    }\n\n    return $data;\n}, 10, 2 );\n\nadd_action( 'woocommerce_order_item_meta_start', function ( $item_id, $item ) {\n    $product = is_callable( array( $item, 'get_product' ) ) ? $item->get_product() : null;\n\n    if ( $product && $product->get_sku() && ! is_admin() ) {\n        printf( '<br><small class=\"wpide-item-sku\">SKU: %s</small>', esc_html( $product->get_sku() ) );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Variations show their own SKU, or the parent's if they have none."
        },
        {
            "id": "woo-disable-out-of-stock-variations",
            "version": 1,
            "title": "Grey Out Out-of-Stock Variations",
            "description": "Makes sold-out variations unselectable in the product options dropdowns, so customers cannot pick a size or colour you no longer have.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "stock"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_variation_is_active', function ( $active, $variation ) {\n    return $active && $variation->is_in_stock();\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works best for products with up to 30 variations, where WooCommerce loads all variation data on the page."
        },
        {
            "id": "woo-variation-stock-labels",
            "version": 1,
            "title": "\"Out of Stock\" Labels in Variation Dropdowns",
            "description": "Adds \"(out of stock)\" next to sold-out options in the variation dropdown of single-attribute products, such as sizes, so shoppers see availability before choosing.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "stock",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_oos_label = 'out of stock'; // Text added to sold-out options.\n\nadd_filter( 'woocommerce_variation_option_name', function ( $name, $term = null, $attribute = '', $product = null ) use ( $wpide_oos_label ) {\n    static $map = array();\n\n    if ( ! $product instanceof WC_Product_Variable || ! $attribute ) {\n        return $name;\n    }\n\n    $id = $product->get_id();\n\n    if ( ! isset( $map[ $id ] ) ) {\n        $map[ $id ] = false;\n        $variation_attributes = $product->get_variation_attributes();\n\n        if ( 1 === count( $variation_attributes ) ) {\n            $map[ $id ] = array();\n\n            foreach ( $product->get_children() as $child_id ) {\n                $child = wc_get_product( $child_id );\n\n                if ( $child ) {\n                    foreach ( $child->get_attributes() as $value ) {\n                        $map[ $id ][ strtolower( (string) $value ) ] = $child->is_in_stock();\n                    }\n                }\n            }\n        }\n    }\n\n    if ( ! is_array( $map[ $id ] ) ) {\n        return $name;\n    }\n\n    $key = strtolower( $term instanceof WP_Term ? $term->slug : (string) $name );\n\n    return ( isset( $map[ $id ][ $key ] ) && ! $map[ $id ][ $key ] ) ? $name . ' (' . $wpide_oos_label . ')' : $name;\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Applies to products whose variations use one attribute; products with several attributes are left unchanged."
        },
        {
            "id": "woo-scheduled-category-sale",
            "version": 1,
            "title": "Scheduled Percentage Sale on a Category",
            "description": "Puts every product in chosen categories on sale by a percentage between two dates, with sale badges and crossed-out prices, without editing products.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "categories",
                "promotion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_sale = array(\n    'categories' => array( 'summer' ),   // Category slugs.\n    'percent'    => 20,                  // Percent off the regular price.\n    'from'       => '2026-07-01 00:00',  // Start (site timezone).\n    'to'         => '2026-07-31 23:59',  // End.\n);\n\n$wpide_cat_sale_active = function ( $product ) use ( $wpide_cat_sale ) {\n    $now = current_time( 'timestamp' );\n\n    if ( $now < strtotime( $wpide_cat_sale['from'] ) || $now > strtotime( $wpide_cat_sale['to'] ) ) {\n        return false;\n    }\n\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    return has_term( $wpide_cat_sale['categories'], 'product_cat', $id );\n};\n\n$wpide_cat_sale_price = function ( $price, $product ) use ( $wpide_cat_sale, $wpide_cat_sale_active ) {\n    $regular = (float) $product->get_regular_price( 'edit' );\n\n    if ( $regular <= 0 || ! $wpide_cat_sale_active( $product ) ) {\n        return $price;\n    }\n\n    $sale = round( $regular * ( 100 - $wpide_cat_sale['percent'] ) / 100, wc_get_price_decimals() );\n\n    return ( '' === $price || $sale < (float) $price ) ? (string) $sale : $price;\n};\n\nforeach ( array( 'woocommerce_product_get_sale_price', 'woocommerce_product_get_price', 'woocommerce_product_variation_get_sale_price', 'woocommerce_product_variation_get_price', 'woocommerce_variation_prices_sale_price', 'woocommerce_variation_prices_price' ) as $wpide_hook ) {\n    add_filter( $wpide_hook, $wpide_cat_sale_price, 50, 2 );\n}\n\nadd_filter( 'woocommerce_get_variation_prices_hash', function ( $hash ) use ( $wpide_cat_sale ) {\n    $now           = current_time( 'timestamp' );\n    $hash['wpide'] = ( $now >= strtotime( $wpide_cat_sale['from'] ) && $now <= strtotime( $wpide_cat_sale['to'] ) ) ? 'sale-' . $wpide_cat_sale['percent'] : 'no-sale';\n\n    return $hash;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Dates use the site's timezone, and products that already have a lower sale price keep it."
        },
        {
            "id": "woo-first-order-discount",
            "version": 1,
            "title": "Automatic Discount on a Customer's First Order",
            "description": "Takes a percentage off the first order of new customers, checked against their account or billing email, to win first-time buyers without coupon codes.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "discounts",
                "new-customers",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_first_order = array(\n    'percent' => 10,                     // Percent off the products subtotal.\n    'label'   => 'First order discount', // Line label.\n);\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_first_order ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->customer || ! WC()->session ) {\n        return;\n    }\n\n    $email = is_user_logged_in() ? wp_get_current_user()->user_email : WC()->customer->get_billing_email();\n\n    if ( ! is_email( $email ) ) {\n        return;\n    }\n\n    $cache = (array) WC()->session->get( 'wpide_first_order', array() );\n    $key   = md5( strtolower( $email ) );\n\n    if ( ! isset( $cache[ $key ] ) ) {\n        $previous = wc_get_orders( array(\n            'billing_email' => $email,\n            'status'        => wc_get_is_paid_statuses(),\n            'limit'         => 1,\n            'return'        => 'ids',\n        ) );\n\n        if ( ! $previous && is_user_logged_in() ) {\n            $previous = wc_get_orders( array( 'customer_id' => get_current_user_id(), 'status' => wc_get_is_paid_statuses(), 'limit' => 1, 'return' => 'ids' ) );\n        }\n\n        $cache[ $key ] = empty( $previous );\n        WC()->session->set( 'wpide_first_order', $cache );\n    }\n\n    if ( $cache[ $key ] ) {\n        $cart->add_fee( $wpide_first_order['label'], -$cart->get_subtotal() * $wpide_first_order['percent'] / 100, true );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Guests get the discount once they enter an email address with no previous paid orders, and the check is cached per email for the session."
        },
        {
            "id": "woo-spend-tier-discount",
            "version": 1,
            "title": "Spend More, Save More Discount Tiers",
            "description": "Gives a bigger percentage discount as the cart subtotal grows, for example 5% over 100 and 10% over 200, and tells shoppers how close they are to the next tier.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "discounts",
                "tiers",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_spend_tiers = array( 100 => 5, 200 => 10, 400 => 15 ); // Subtotal => percent off.\n\n$wpide_spend_tier = function ( $subtotal ) use ( $wpide_spend_tiers ) {\n    $current = array( 0, 0 );\n    $next    = null;\n    ksort( $wpide_spend_tiers );\n\n    foreach ( $wpide_spend_tiers as $min => $off ) {\n        if ( $subtotal >= $min ) {\n            $current = array( $min, $off );\n        } elseif ( null === $next ) {\n            $next = array( $min, $off );\n        }\n    }\n\n    return array( $current, $next );\n};\n\nadd_action( 'woocommerce_cart_calculate_fees', function ( $cart ) use ( $wpide_spend_tier ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return;\n    }\n\n    list( $current ) = $wpide_spend_tier( (float) $cart->get_subtotal() );\n\n    if ( $current[1] > 0 ) {\n        $cart->add_fee( sprintf( 'Spend discount (%d%%)', $current[1] ), -$cart->get_subtotal() * $current[1] / 100, true );\n    }\n} );\n\nadd_action( 'woocommerce_before_cart', function () use ( $wpide_spend_tier ) {\n    $subtotal       = (float) WC()->cart->get_subtotal();\n    list( , $next ) = $wpide_spend_tier( $subtotal );\n\n    if ( $next ) {\n        wc_print_notice( sprintf( 'Spend %1$s more to save %2$d%% on your order.', wc_price( $next[0] - $subtotal ), $next[1] ), 'notice' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only the highest tier reached applies, and the cart notice disappears once the top tier is reached."
        },
        {
            "id": "woo-reorder-checkout-fields",
            "version": 1,
            "title": "Reorder the Billing and Shipping Fields",
            "description": "Changes the order of checkout address fields, for example country first and postcode before city, to match how addresses are written in your market.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_field_order = array( // Field => priority (lower = higher up).\n    'country'    => 5,\n    'first_name' => 10,\n    'last_name'  => 20,\n    'company'    => 30,\n    'postcode'   => 40,\n    'city'       => 45,\n    'address_1'  => 50,\n    'address_2'  => 60,\n    'state'      => 70,\n);\n\nadd_filter( 'woocommerce_default_address_fields', function ( $fields ) use ( $wpide_field_order ) {\n    foreach ( $wpide_field_order as $key => $priority ) {\n        if ( isset( $fields[ $key ] ) ) {\n            $fields[ $key ]['priority'] = $priority;\n        }\n    }\n\n    return $fields;\n}, 20 );\n\nadd_filter( 'woocommerce_get_country_locale', function ( $locales ) use ( $wpide_field_order ) {\n    foreach ( $locales as $country => $fields ) {\n        foreach ( $fields as $key => $field ) {\n            if ( isset( $field['priority'], $wpide_field_order[ $key ] ) ) {\n                $locales[ $country ][ $key ]['priority'] = $wpide_field_order[ $key ];\n            }\n        }\n    }\n\n    return $locales;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Lower numbers come first; field keys have no billing_ or shipping_ prefix and apply to both address forms."
        },
        {
            "id": "woo-require-company-name",
            "version": 1,
            "title": "Make the Company Name Required",
            "description": "Turns the optional Company field into a required one for stores that sell only to businesses.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "b2b",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    if ( isset( $fields['billing_company'] ) ) {\n        $fields['billing_company']['required'] = true;\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Make sure the Company field is not hidden in Appearance > Customize > WooCommerce > Checkout."
        },
        {
            "id": "woo-checkout-labels-placeholders",
            "version": 1,
            "title": "Change Checkout Field Labels and Placeholders",
            "description": "Renames checkout fields and their placeholder hints, for example \"Town / City\" to \"City\" or \"Apartment, suite, unit\" to \"Flat or floor\", without a translation plugin.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_field_texts = array( // Field key => array( label, placeholder ).\n    'billing_city'      => array( 'City', '' ),\n    'shipping_city'     => array( 'City', '' ),\n    'billing_address_2' => array( '', 'Flat, floor or building (optional)' ),\n    'billing_phone'     => array( 'Mobile number', 'For delivery updates' ),\n    'order_comments'    => array( 'Delivery notes', 'Gate code, safe place, etc.' ),\n);\n\nadd_filter( 'woocommerce_checkout_fields', function ( $fields ) use ( $wpide_field_texts ) {\n    foreach ( $fields as $group => $group_fields ) {\n        foreach ( $wpide_field_texts as $key => $texts ) {\n            if ( ! isset( $group_fields[ $key ] ) ) {\n                continue;\n            }\n\n            if ( '' !== $texts[0] ) {\n                $fields[ $group ][ $key ]['label'] = $texts[0];\n            }\n\n            if ( '' !== $texts[1] ) {\n                $fields[ $group ][ $key ]['placeholder'] = $texts[1];\n            }\n        }\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use full field keys such as billing_city or order_comments, and leave a value empty to keep WooCommerce's default."
        },
        {
            "id": "woo-postcode-city-one-row",
            "version": 1,
            "title": "Put Postcode and City on One Row",
            "description": "Places the postcode and city fields side by side on the checkout, which saves space and matches how many countries write addresses.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_default_address_fields', function ( $fields ) {\n    if ( isset( $fields['postcode'], $fields['city'] ) ) {\n        $fields['postcode']['class'] = array( 'form-row-first', 'address-field' );\n        $fields['city']['class']     = array( 'form-row-last', 'address-field' );\n        $fields['city']['priority']  = $fields['postcode']['priority'] + 1;\n    }\n\n    return $fields;\n}, 30 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "On phones the two fields still stack, as with WooCommerce's first and last name fields."
        },
        {
            "id": "woo-pickup-hides-address",
            "version": 1,
            "title": "Skip Address Fields When Local Pickup Is Chosen",
            "description": "Hides and stops requiring the street address fields on the classic checkout when the customer picks Local Pickup, since you will not deliver the order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "local-pickup",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_pickup_hidden = array( 'address_1', 'address_2', 'city', 'postcode', 'state', 'company' ); // Billing fields to skip.\n\n$wpide_pickup_selected = function () {\n    $chosen = WC()->session ? (array) WC()->session->get( 'chosen_shipping_methods', array() ) : array();\n\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the checkout nonce.\n    if ( isset( $_POST['shipping_method'] ) ) {\n        $chosen = array_map( 'wc_clean', (array) wp_unslash( $_POST['shipping_method'] ) );\n    }\n\n    foreach ( $chosen as $method ) {\n        if ( 0 === strpos( (string) $method, 'local_pickup' ) ) {\n            return true;\n        }\n    }\n\n    return false;\n};\n\nadd_filter( 'woocommerce_checkout_fields', function ( $fields ) use ( $wpide_pickup_hidden, $wpide_pickup_selected ) {\n    if ( ! $wpide_pickup_selected() ) {\n        return $fields;\n    }\n\n    foreach ( $wpide_pickup_hidden as $key ) {\n        if ( isset( $fields['billing'][ 'billing_' . $key ] ) ) {\n            $fields['billing'][ 'billing_' . $key ]['required'] = false;\n        }\n    }\n\n    return $fields;\n}, 30 );\n\nadd_action( 'wp_footer', function () use ( $wpide_pickup_hidden ) {\n    if ( ! is_checkout() || is_wc_endpoint_url() ) {\n        return;\n    }\n\n    $selectors = implode( ',', array_map( function ( $key ) {\n        return '#billing_' . $key . '_field';\n    }, $wpide_pickup_hidden ) );\n    ?>\n    <script>\n    jQuery( function ( $ ) {\n        var fields = <?php echo wp_json_encode( $selectors ); ?>;\n        function toggle() {\n            var method = $( 'input[name^=\"shipping_method\"]:checked, select[name^=\"shipping_method\"], input[type=\"hidden\"][name^=\"shipping_method\"]' ).first().val() || '';\n            $( fields ).toggle( method.indexOf( 'local_pickup' ) !== 0 );\n        }\n        $( document.body ).on( 'updated_checkout', toggle );\n        toggle();\n    } );\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Name, email and phone stay required, and the address fields come back if the customer switches to a delivery method."
        },
        {
            "id": "woo-checkout-po-number",
            "version": 1,
            "title": "Purchase Order Number Field at Checkout",
            "description": "Adds an optional \"PO number\" field to the classic checkout for business buyers and shows it on the order screen, in emails and on the customer's order page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "b2b",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_po_required = false; // Require a PO number.\n\nadd_filter( 'woocommerce_checkout_fields', function ( $fields ) use ( $wpide_po_required ) {\n    $fields['order']['wpide_po_number'] = array(\n        'type'      => 'text',\n        'label'     => 'Purchase order number',\n        'required'  => $wpide_po_required,\n        'priority'  => 1,\n        'maxlength' => 40,\n    );\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_checkout_create_order', function ( $order, $data ) {\n    if ( ! empty( $data['wpide_po_number'] ) ) {\n        $order->update_meta_data( '_wpide_po_number', sanitize_text_field( $data['wpide_po_number'] ) );\n    }\n}, 10, 2 );\n\nadd_action( 'woocommerce_admin_order_data_after_billing_address', function ( $order ) {\n    $po = $order->get_meta( '_wpide_po_number' );\n\n    if ( $po ) {\n        printf( '<p><strong>PO number:</strong> %s</p>', esc_html( $po ) );\n    }\n} );\n\nadd_filter( 'woocommerce_email_order_meta_fields', function ( $fields, $sent_to_admin, $order ) {\n    $po = $order->get_meta( '_wpide_po_number' );\n\n    if ( $po ) {\n        $fields['wpide_po_number'] = array( 'label' => 'PO number', 'value' => $po );\n    }\n\n    return $fields;\n}, 10, 3 );\n\nadd_filter( 'woocommerce_get_order_item_totals', function ( $rows, $order ) {\n    $po = $order->get_meta( '_wpide_po_number' );\n\n    if ( $po ) {\n        $rows['wpide_po_number'] = array( 'label' => 'PO number:', 'value' => esc_html( $po ) );\n    }\n\n    return $rows;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_po_required to true to make the field mandatory; the value is saved as the order meta _wpide_po_number."
        },
        {
            "id": "woo-category-terms-checkbox",
            "version": 1,
            "title": "Extra Agreement Checkbox for Certain Products",
            "description": "Adds a required checkbox to the checkout only when the cart has products from a chosen category, for example \"I understand personalised items cannot be returned\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "terms",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_terms = array(\n    'categories' => array( 'personalised' ), // Category slugs.\n    'text'       => 'I understand that personalised items are made to order and cannot be returned.',\n);\n\n$wpide_cat_terms_needed = function () use ( $wpide_cat_terms ) {\n    if ( ! WC()->cart ) {\n        return false;\n    }\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( has_term( $wpide_cat_terms['categories'], 'product_cat', $item['product_id'] ) ) {\n            return true;\n        }\n    }\n\n    return false;\n};\n\nadd_action( 'woocommerce_review_order_before_submit', function () use ( $wpide_cat_terms, $wpide_cat_terms_needed ) {\n    if ( ! $wpide_cat_terms_needed() ) {\n        return;\n    }\n\n    printf(\n        '<p class=\"form-row validate-required wpide-cat-terms\"><label class=\"woocommerce-form__label woocommerce-form__label-for-checkbox checkbox\"><input type=\"checkbox\" class=\"woocommerce-form__input woocommerce-form__input-checkbox input-checkbox\" name=\"wpide_cat_terms\" value=\"1\"> <span>%s</span>&nbsp;<abbr class=\"required\" title=\"required\">*</abbr></label></p>',\n        esc_html( $wpide_cat_terms['text'] )\n    );\n}, 9 );\n\nadd_action( 'woocommerce_checkout_process', function () use ( $wpide_cat_terms_needed ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the checkout nonce.\n    if ( $wpide_cat_terms_needed() && empty( $_POST['wpide_cat_terms'] ) ) {\n        wc_add_notice( 'Please confirm you have read the note about personalised items.', 'error' );\n    }\n} );\n\nadd_action( 'woocommerce_checkout_order_created', function ( $order ) use ( $wpide_cat_terms ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the checkout nonce.\n    if ( ! empty( $_POST['wpide_cat_terms'] ) ) {\n        $order->add_order_note( 'Customer accepted: ' . $wpide_cat_terms['text'] );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The agreement is recorded as an order note so you can show it was accepted."
        },
        {
            "id": "woo-tidy-checkout-input",
            "version": 1,
            "title": "Tidy Names and Postcodes Entered at Checkout",
            "description": "Cleans up what customers type before the order is saved: names get proper capitals, postcodes become upper case and stray spaces are removed.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "data-quality",
                "address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_checkout_posted_data', function ( $data ) {\n    foreach ( array( 'billing', 'shipping' ) as $type ) {\n        foreach ( array( 'first_name', 'last_name', 'city', 'address_1', 'address_2', 'company' ) as $field ) {\n            $key = $type . '_' . $field;\n\n            if ( empty( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) {\n                continue;\n            }\n\n            $value = trim( preg_replace( '/\\s+/u', ' ', $data[ $key ] ) );\n\n            if ( in_array( $field, array( 'first_name', 'last_name', 'city' ), true ) && ( mb_strtolower( $value ) === $value || mb_strtoupper( $value ) === $value ) ) {\n                $value = mb_convert_case( mb_strtolower( $value ), MB_CASE_TITLE, 'UTF-8' );\n            }\n\n            $data[ $key ] = $value;\n        }\n\n        if ( ! empty( $data[ $type . '_postcode' ] ) && is_string( $data[ $type . '_postcode' ] ) ) {\n            $data[ $type . '_postcode' ] = strtoupper( trim( preg_replace( '/\\s+/', ' ', $data[ $type . '_postcode' ] ) ) );\n        }\n    }\n\n    if ( ! empty( $data['billing_email'] ) && is_string( $data['billing_email'] ) ) {\n        $data['billing_email'] = strtolower( trim( $data['billing_email'] ) );\n    }\n\n    return $data;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Names typed in mixed case, such as \"McDonald\", are left as typed; only all-lower or all-upper names are changed."
        },
        {
            "id": "woo-block-disposable-emails",
            "version": 1,
            "title": "Block Disposable Email Addresses at Checkout",
            "description": "Refuses orders placed with throwaway email services, so order confirmations, downloads and receipts reach a real inbox.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "email",
                "fraud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_disposable_domains = array( 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'tempmail.com', 'yopmail.com', 'trashmail.com', 'sharklasers.com', 'getnada.com', 'temp-mail.org', 'dispostable.com' );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_disposable_domains ) {\n    $email = strtolower( (string) ( $data['billing_email'] ?? '' ) );\n\n    if ( false === strpos( $email, '@' ) ) {\n        return;\n    }\n\n    $domain = substr( strrchr( $email, '@' ), 1 );\n\n    foreach ( $wpide_disposable_domains as $blocked ) {\n        if ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) {\n            $errors->add( 'validation', 'Please use a permanent email address so we can send your order details.' );\n            return;\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add domains to the list as you spot them; subdomains of listed domains are blocked too."
        },
        {
            "id": "woo-checkout-block-list",
            "version": 1,
            "title": "Block Checkout for Listed Emails and Phone Numbers",
            "description": "Stops orders from customers on your block list, such as repeat chargeback offenders, matched by email address, email domain or phone number.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fraud",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_block_list = array(\n    'emails'  => array( 'fraud@example.com' ),     // Full email addresses.\n    'domains' => array( 'bad-domain.example' ),    // Email domains.\n    'phones'  => array( '+44 7700 900123' ),       // Phone numbers.\n);\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_block_list ) {\n    $email  = strtolower( trim( (string) ( $data['billing_email'] ?? '' ) ) );\n    $domain = false !== strpos( $email, '@' ) ? substr( strrchr( $email, '@' ), 1 ) : '';\n    $phone  = preg_replace( '/\\D/', '', (string) ( $data['billing_phone'] ?? '' ) );\n    $phones = array_map( function ( $number ) {\n        return preg_replace( '/\\D/', '', $number );\n    }, $wpide_block_list['phones'] );\n\n    $blocked = in_array( $email, array_map( 'strtolower', $wpide_block_list['emails'] ), true )\n        || ( $domain && in_array( $domain, array_map( 'strtolower', $wpide_block_list['domains'] ), true ) )\n        || ( strlen( $phone ) > 5 && in_array( $phone, $phones, true ) );\n\n    if ( $blocked ) {\n        $errors->add( 'validation', 'Sorry, we are unable to process this order. Please contact us for help.' );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Phone numbers are compared by digits only, and the message is deliberately neutral so blocked customers are not told why."
        },
        {
            "id": "woo-address-length-limit",
            "version": 1,
            "title": "Limit Address Line Length for Shipping Labels",
            "description": "Stops checkout when an address line is longer than your carrier's label allows (often 35 characters) and asks the customer to use the second line.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "validation",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_address_max = 35; // Maximum characters per address line.\n\nadd_filter( 'woocommerce_default_address_fields', function ( $fields ) use ( $wpide_address_max ) {\n    foreach ( array( 'address_1', 'address_2', 'company' ) as $key ) {\n        if ( isset( $fields[ $key ] ) ) {\n            $fields[ $key ]['maxlength'] = $wpide_address_max;\n        }\n    }\n\n    return $fields;\n} );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_address_max ) {\n    foreach ( array( 'billing_address_1', 'billing_address_2', 'shipping_address_1', 'shipping_address_2' ) as $key ) {\n        if ( ! empty( $data[ $key ] ) && mb_strlen( (string) $data[ $key ] ) > $wpide_address_max ) {\n            $errors->add( 'validation', sprintf( 'Address lines can be up to %d characters. Please move the rest to the second address line.', $wpide_address_max ) );\n            return;\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Also sets a maxlength on the fields so customers see the limit while typing."
        },
        {
            "id": "woo-latin-address-only",
            "version": 1,
            "title": "Require Latin Characters in Addresses",
            "description": "Asks customers to type their name and address in Latin letters, for international carriers whose labels cannot print other alphabets.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "validation",
                "international"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_latin_fields = array( 'first_name', 'last_name', 'address_1', 'address_2', 'city' ); // Fields to check.\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_latin_fields ) {\n    foreach ( array( 'billing', 'shipping' ) as $type ) {\n        if ( 'shipping' === $type && empty( $data['ship_to_different_address'] ) ) {\n            continue;\n        }\n\n        foreach ( $wpide_latin_fields as $field ) {\n            $value = (string) ( $data[ $type . '_' . $field ] ?? '' );\n\n            if ( '' !== $value && ! preg_match( '/^[\\p{Latin}\\p{N}\\s\\'\\-.,\\/#&()]+$/u', $value ) ) {\n                $errors->add( 'validation', 'Please enter your name and address using Latin letters (A to Z) so the courier can read the label.' );\n                return;\n            }\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Accented letters such as é, ü and ø are allowed; edit $wpide_latin_fields to check more or fewer fields."
        },
        {
            "id": "woo-require-house-number",
            "version": 1,
            "title": "Require a House Number in the Street Address",
            "description": "Catches addresses typed without a house or building number, one of the most common reasons parcels come back undelivered.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "validation",
                "address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_house_number_skip = array( 'IE' ); // Countries to skip.\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_house_number_skip ) {\n    if ( empty( WC()->cart ) || ! WC()->cart->needs_shipping() ) {\n        return;\n    }\n\n    $prefix  = ! empty( $data['ship_to_different_address'] ) ? 'shipping_' : 'billing_';\n    $country = (string) ( $data[ $prefix . 'country' ] ?? '' );\n    $line    = trim( (string) ( $data[ $prefix . 'address_1' ] ?? '' ) . ' ' . (string) ( $data[ $prefix . 'address_2' ] ?? '' ) );\n\n    if ( '' !== $line && ! in_array( $country, $wpide_house_number_skip, true ) && ! preg_match( '/\\d/', $line ) ) {\n        $errors->add( 'validation', 'Please include your house or building number in the street address.' );\n    }\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only orders that need shipping are checked; add countries where addresses often have no numbers to $wpide_house_number_skip."
        },
        {
            "id": "woo-optional-state-field",
            "version": 1,
            "title": "Make the State / County Field Optional",
            "description": "Stops requiring the state, county or province at checkout for every country, for stores whose couriers do not need it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fields",
                "address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_get_country_locale', function ( $locales ) {\n    foreach ( $locales as $country => $fields ) {\n        if ( isset( $fields['state'] ) ) {\n            $locales[ $country ]['state']['required'] = false;\n        }\n    }\n\n    return $locales;\n}, 20 );\n\nadd_filter( 'woocommerce_get_country_locale_default', function ( $fields ) {\n    if ( isset( $fields['state'] ) ) {\n        $fields['state']['required'] = false;\n    }\n\n    return $fields;\n}, 20 );\n\nadd_filter( 'woocommerce_default_address_fields', function ( $fields ) {\n    if ( isset( $fields['state'] ) ) {\n        $fields['state']['required'] = false;\n    }\n\n    return $fields;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The field stays visible; customers can still fill it in."
        },
        {
            "id": "woo-block-checkout-po-number",
            "version": 1,
            "title": "Purchase Order Number on the Block Checkout",
            "description": "Adds an optional \"Purchase order number\" field to the Checkout block for business buyers, saved on the order with WooCommerce's additional fields API.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "block-checkout",
                "b2b",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_init', function () {\n    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {\n        return;\n    }\n\n    woocommerce_register_additional_checkout_field( array(\n        'id'         => 'wpide/po-number',\n        'label'      => 'Purchase order number (optional)',\n        'location'   => 'order',\n        'type'       => 'text',\n        'required'   => false,\n        'attributes' => array(\n            'maxLength'    => 40,\n            'autocomplete' => 'off',\n        ),\n    ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs WooCommerce 8.9 or newer, and the value appears on the order screen, in emails and on the confirmation page automatically."
        },
        {
            "id": "woo-block-checkout-vat-number",
            "version": 1,
            "title": "VAT Number Field on the Block Checkout",
            "description": "Adds an optional VAT number field to the contact section of the Checkout block and rejects numbers that do not look like a VAT number.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "block-checkout",
                "vat",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_init', function () {\n    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {\n        return;\n    }\n\n    woocommerce_register_additional_checkout_field( array(\n        'id'       => 'wpide/vat-number',\n        'label'    => 'VAT number (optional)',\n        'location' => 'contact',\n        'type'     => 'text',\n        'required' => false,\n    ) );\n} );\n\nadd_action( 'woocommerce_validate_additional_field', function ( $errors, $field_key, $field_value ) {\n    if ( 'wpide/vat-number' !== $field_key || '' === trim( (string) $field_value ) ) {\n        return;\n    }\n\n    $vat = strtoupper( preg_replace( '/[\\s.\\-]/', '', (string) $field_value ) );\n\n    if ( ! preg_match( '/^(AT|BE|BG|CY|CZ|DE|DK|EE|EL|ES|FI|FR|HR|HU|IE|IT|LT|LU|LV|MT|NL|PL|PT|RO|SE|SI|SK|XI|GB|CH|NO)[A-Z0-9+*]{8,12}$/', $vat ) ) {\n        $errors->add( 'invalid_vat_number', 'Please enter a VAT number with its country prefix, for example DE123456789.' );\n    }\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs WooCommerce 8.9 or newer; the check covers the format (country prefix plus 8 to 12 characters) and does not contact VIES."
        },
        {
            "id": "woo-block-checkout-newsletter",
            "version": 1,
            "title": "Newsletter Opt-In on the Block Checkout",
            "description": "Adds an unticked \"Send me news and offers\" checkbox to the Checkout block and saves the answer on the order, so you only email customers who agreed.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "block-checkout",
                "newsletter",
                "consent"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_init', function () {\n    if ( ! function_exists( 'woocommerce_register_additional_checkout_field' ) ) {\n        return;\n    }\n\n    woocommerce_register_additional_checkout_field( array(\n        'id'       => 'wpide/newsletter',\n        'label'    => 'Send me news and offers by email (you can unsubscribe at any time)',\n        'location' => 'order',\n        'type'     => 'checkbox',\n    ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Needs WooCommerce 8.9 or newer; read the answer with $order->get_meta( '_wc_other/wpide/newsletter' ) when syncing to your mailing tool."
        },
        {
            "id": "woo-cancel-unpaid-bank-orders",
            "version": 1,
            "title": "Cancel Unpaid Bank Transfer Orders After a Few Days",
            "description": "Automatically cancels on-hold orders paid by bank transfer or cheque that are still unpaid after a set number of days, returning the stock to your shop.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "automation",
                "bacs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_unpaid = array(\n    'days'     => 7,                         // Days before cancelling.\n    'gateways' => array( 'bacs', 'cheque' ), // Payment method IDs.\n);\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_cancel_unpaid_orders' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_cancel_unpaid_orders' );\n    }\n} );\n\nadd_action( 'wpide_cancel_unpaid_orders', function () use ( $wpide_unpaid ) {\n    if ( ! function_exists( 'wc_get_orders' ) ) {\n        return;\n    }\n\n    $orders = wc_get_orders( array(\n        'status'         => 'on-hold',\n        'payment_method' => $wpide_unpaid['gateways'],\n        'date_created'   => '<' . ( time() - $wpide_unpaid['days'] * DAY_IN_SECONDS ),\n        'limit'          => 50,\n    ) );\n\n    foreach ( $orders as $order ) {\n        $order->update_status( 'cancelled', sprintf( 'Cancelled automatically: unpaid after %d days.', $wpide_unpaid['days'] ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs daily through WP-Cron, 50 orders per run, and customers get WooCommerce's cancelled order email if it is enabled."
        },
        {
            "id": "woo-auto-complete-after-days",
            "version": 1,
            "title": "Complete Processing Orders After a Set Number of Days",
            "description": "Marks processing orders as completed automatically a number of days after purchase, for stores where you do not update orders when they are delivered.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "automation",
                "status"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_complete_after_days = 10; // Days after the order date.\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_auto_complete_orders' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_auto_complete_orders' );\n    }\n} );\n\nadd_action( 'wpide_auto_complete_orders', function () use ( $wpide_complete_after_days ) {\n    if ( ! function_exists( 'wc_get_orders' ) ) {\n        return;\n    }\n\n    $orders = wc_get_orders( array(\n        'status'       => 'processing',\n        'date_created' => '<' . ( time() - $wpide_complete_after_days * DAY_IN_SECONDS ),\n        'limit'        => 50,\n    ) );\n\n    foreach ( $orders as $order ) {\n        $order->update_status( 'completed', sprintf( 'Completed automatically %d days after purchase.', $wpide_complete_after_days ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs daily through WP-Cron, 50 orders per run, and customers get the \"Order completed\" email if it is enabled."
        },
        {
            "id": "woo-hold-high-value-orders",
            "version": 1,
            "title": "Put High-Value Orders On Hold for Review",
            "description": "Sends paid orders above an amount to \"On hold\" instead of \"Processing\", so you can check them for fraud before shipping.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "fraud",
                "status"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_review_amount = 1000; // Orders at or above this total are held.\n\nadd_filter( 'woocommerce_payment_complete_order_status', function ( $status, $order_id, $order = null ) use ( $wpide_review_amount ) {\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );\n\n    if ( 'processing' === $status && $order && (float) $order->get_total() >= $wpide_review_amount ) {\n        $order->add_order_note( sprintf( 'Held for manual review: order total is %s or more.', wc_price( $wpide_review_amount ) ) );\n\n        return 'on-hold';\n    }\n\n    return $status;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The payment is still taken and only the status changes, so move checked orders to Processing yourself."
        },
        {
            "id": "woo-flag-country-mismatch",
            "version": 1,
            "title": "Flag Orders Whose Billing and Shipping Countries Differ",
            "description": "Adds a private order note, and optionally holds the order, when the billing and shipping countries do not match, a common sign of card fraud.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "fraud",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mismatch_hold = false; // Also set the order to On hold.\n\n$wpide_check_mismatch = function ( $order ) use ( $wpide_mismatch_hold ) {\n    if ( ! $order instanceof WC_Order || ! $order->has_shipping_address() ) {\n        return;\n    }\n\n    $billing  = $order->get_billing_country();\n    $shipping = $order->get_shipping_country();\n\n    if ( $billing && $shipping && $billing !== $shipping ) {\n        $order->add_order_note( sprintf( 'Check: billing country (%1$s) and shipping country (%2$s) differ.', $billing, $shipping ) );\n\n        if ( $wpide_mismatch_hold && $order->has_status( array( 'processing', 'pending' ) ) ) {\n            $order->update_status( 'on-hold', 'Held because the countries differ.' );\n        }\n    }\n};\n\nadd_action( 'woocommerce_checkout_order_created', $wpide_check_mismatch );\nadd_action( 'woocommerce_store_api_checkout_order_processed', $wpide_check_mismatch );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with the classic and block checkouts; set $wpide_mismatch_hold to true to also put these orders on hold."
        },
        {
            "id": "woo-log-status-changes-by-user",
            "version": 1,
            "title": "Record Which Staff Member Changed an Order Status",
            "description": "Adds the name of the logged-in user to the order notes every time an order status changes, so you can see who marked an order shipped or refunded.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "notes",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {\n    $user = wp_get_current_user();\n\n    if ( ! $user->exists() || ! current_user_can( 'edit_shop_orders' ) || ! $order instanceof WC_Order ) {\n        return;\n    }\n\n    $order->add_order_note( sprintf( 'Status changed from %1$s to %2$s by %3$s.', wc_get_order_status_name( $from ), wc_get_order_status_name( $to ), $user->display_name ), false, true );\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Status changes made by payment gateways, cron or the customer are not labelled, since no staff user is logged in."
        },
        {
            "id": "woo-ready-for-pickup-status",
            "version": 1,
            "title": "\"Ready for Pickup\" Order Status With Customer Email",
            "description": "Adds a \"Ready for pickup\" order status and emails the customer automatically when you set it, so click-and-collect customers know when to come in.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "status",
                "local-pickup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_pickup_email = array(\n    'subject' => 'Your order #{order_number} is ready for pickup',\n    'heading' => 'Ready for pickup',\n    'body'    => 'Hi {first_name}, good news: your order is packed and waiting for you at our shop. Please bring your order number when you collect it.',\n);\n\nadd_filter( 'woocommerce_register_shop_order_post_statuses', function ( $statuses ) {\n    $statuses['wc-ready-pickup'] = array(\n        'label'                     => 'Ready for pickup',\n        'public'                    => false,\n        'exclude_from_search'       => false,\n        'show_in_admin_all_list'    => true,\n        'show_in_admin_status_list' => true,\n        /* translators: %s: number of orders */\n        'label_count'               => _n_noop( 'Ready for pickup <span class=\"count\">(%s)</span>', 'Ready for pickup <span class=\"count\">(%s)</span>' ),\n    );\n\n    return $statuses;\n} );\n\nadd_filter( 'wc_order_statuses', function ( $statuses ) {\n    $statuses['wc-ready-pickup'] = 'Ready for pickup';\n\n    return $statuses;\n} );\n\n$wpide_pickup_bulk = function ( $actions ) {\n    $actions['mark_ready-pickup'] = 'Change status to ready for pickup';\n\n    return $actions;\n};\n\nadd_filter( 'bulk_actions-woocommerce_page_wc-orders', $wpide_pickup_bulk, 20 );\nadd_filter( 'bulk_actions-edit-shop_order', $wpide_pickup_bulk, 20 );\n\nadd_filter( 'woocommerce_order_is_paid_statuses', function ( $statuses ) {\n    $statuses[] = 'ready-pickup';\n\n    return $statuses;\n} );\n\nadd_action( 'woocommerce_order_status_ready-pickup', function ( $order_id, $order = null ) use ( $wpide_pickup_email ) {\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );\n\n    if ( ! $order || ! $order->get_billing_email() ) {\n        return;\n    }\n\n    $replace = array(\n        '{order_number}' => $order->get_order_number(),\n        '{first_name}'   => $order->get_billing_first_name(),\n    );\n    $mailer  = WC()->mailer();\n    $message = $mailer->wrap_message( $wpide_pickup_email['heading'], wpautop( esc_html( strtr( $wpide_pickup_email['body'], $replace ) ) ) );\n\n    $mailer->send( $order->get_billing_email(), strtr( $wpide_pickup_email['subject'], $replace ), $message );\n    $order->add_order_note( 'Ready for pickup email sent to the customer.' );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the email wording in $wpide_pickup_email; it is sent with WooCommerce's email template and colours."
        },
        {
            "id": "woo-order-status-colours",
            "version": 1,
            "title": "Colour-Coded Order Statuses in the Orders List",
            "description": "Gives each order status its own clear colour in the admin orders list, so processing, on-hold and failed orders stand out at a glance.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "styling"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": ".order-status.status-processing { background: #c6e1c6; color: #2c4700; }\n.order-status.status-on-hold { background: #f8dda7; color: #573b00; }\n.order-status.status-pending { background: #e5e5e5; color: #454545; }\n.order-status.status-completed { background: #c8d7e1; color: #003d66; }\n.order-status.status-cancelled { background: #eee; color: #777; text-decoration: line-through; }\n.order-status.status-refunded { background: #e5d4f2; color: #4b1f6f; }\n.order-status.status-failed { background: #eba3a3; color: #761919; font-weight: 600; }\n.order-status.status-checkout-draft { background: #f0f0f1; color: #50575e; font-style: italic; }\n.order-status.status-awaiting-shipment,\n.order-status.status-ready-pickup { background: #b3e5f5; color: #0b4f63; }",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works on the HPOS and legacy order screens; add rules for custom statuses by slug, such as .status-awaiting-shipment."
        },
        {
            "id": "woo-orders-payment-method-column",
            "version": 1,
            "title": "Payment Method Column in the Orders List",
            "description": "Adds a column to the admin orders screen showing how each order was paid, such as card, PayPal or bank transfer, without opening the order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_pm_column = function ( $columns ) {\n    $new = array();\n\n    foreach ( $columns as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'order_status' === $key ) {\n            $new['wpide_payment'] = 'Payment';\n        }\n    }\n\n    return isset( $new['wpide_payment'] ) ? $new : $new + array( 'wpide_payment' => 'Payment' );\n};\n\n$wpide_pm_render = function ( $column, $order ) {\n    if ( 'wpide_payment' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n\n    echo $order ? esc_html( $order->get_payment_method_title() ? $order->get_payment_method_title() : '–' ) : '';\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_pm_column );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_pm_column );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_pm_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_pm_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with both the HPOS orders screen and the legacy posts screen."
        },
        {
            "id": "woo-orders-shipping-method-column",
            "version": 1,
            "title": "Shipping Method Column in the Orders List",
            "description": "Shows the chosen shipping method of each order in the admin orders list, so you can spot express and pickup orders before packing.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "shipping"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_ship_column = function ( $columns ) {\n    $columns['wpide_shipping'] = 'Shipping';\n\n    return $columns;\n};\n\n$wpide_ship_render = function ( $column, $order ) {\n    if ( 'wpide_shipping' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n\n    if ( $order ) {\n        $method = $order->get_shipping_method();\n        echo esc_html( $method ? $method : '–' );\n    }\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_ship_column );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_ship_column );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_ship_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_ship_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Orders with several shipping lines list them all, separated by commas."
        },
        {
            "id": "woo-orders-contact-column",
            "version": 1,
            "title": "Customer Email and Phone Column in the Orders List",
            "description": "Adds the customer's email address and phone number to the admin orders list as click-to-email and click-to-call links.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_contact_column = function ( $columns ) {\n    $columns['wpide_contact'] = 'Contact';\n\n    return $columns;\n};\n\n$wpide_contact_render = function ( $column, $order ) {\n    if ( 'wpide_contact' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n\n    if ( ! $order ) {\n        return;\n    }\n\n    $email = $order->get_billing_email();\n    $phone = $order->get_billing_phone();\n\n    if ( $email ) {\n        printf( '<a href=\"mailto:%1$s\">%2$s</a><br>', esc_attr( $email ), esc_html( $email ) );\n    }\n\n    if ( $phone ) {\n        printf( '<a href=\"tel:%1$s\">%2$s</a>', esc_attr( preg_replace( '/[^\\d+]/', '', $phone ) ), esc_html( $phone ) );\n    }\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_contact_column );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_contact_column );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_contact_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_contact_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only staff who can view the orders screen see these details."
        },
        {
            "id": "woo-orders-filter-payment-method",
            "version": 1,
            "title": "Filter Orders by Payment Method",
            "description": "Adds a \"Payment method\" dropdown above the admin orders list, so you can list only bank transfer, PayPal or cash on delivery orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_pm_filter_dropdown = function () {\n    if ( ! function_exists( 'WC' ) ) {\n        return;\n    }\n\n    $current = isset( $_GET['wpide_payment'] ) ? sanitize_key( wp_unslash( $_GET['wpide_payment'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<label for=\"wpide-payment-filter\" class=\"screen-reader-text\">' . esc_html( 'Filter by payment method' ) . '</label>';\n    echo '<select name=\"wpide_payment\" id=\"wpide-payment-filter\"><option value=\"\">' . esc_html( 'All payment methods' ) . '</option>';\n\n    foreach ( WC()->payment_gateways()->payment_gateways() as $id => $gateway ) {\n        if ( 'yes' === $gateway->enabled ) {\n            printf( '<option value=\"%s\" %s>%s</option>', esc_attr( $id ), selected( $current, $id, false ), esc_html( $gateway->get_title() ) );\n        }\n    }\n\n    echo '</select>';\n};\n\nadd_action( 'woocommerce_order_list_table_restrict_manage_orders', function ( $order_type = 'shop_order' ) use ( $wpide_pm_filter_dropdown ) {\n    if ( 'shop_order' === $order_type ) {\n        $wpide_pm_filter_dropdown();\n    }\n} );\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_pm_filter_dropdown ) {\n    if ( 'shop_order' === $post_type ) {\n        $wpide_pm_filter_dropdown();\n    }\n} );\n\nadd_filter( 'woocommerce_order_list_table_prepare_items_query_args', function ( $args ) {\n    if ( ! empty( $_GET['wpide_payment'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $args['payment_method'] = sanitize_key( wp_unslash( $_GET['wpide_payment'] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    }\n\n    return $args;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || 'shop_order' !== $query->get( 'post_type' ) || empty( $_GET['wpide_payment'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array( 'key' => '_payment_method', 'value' => sanitize_key( wp_unslash( $_GET['wpide_payment'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    $query->set( 'meta_query', $meta_query );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Lists the payment methods enabled in your store and works on both the HPOS and legacy orders screens."
        },
        {
            "id": "woo-orders-filter-country",
            "version": 1,
            "title": "Filter Orders by Billing Country",
            "description": "Adds a country dropdown to the admin orders list so you can see only orders from one country, handy for customs paperwork or regional reports.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_country_dropdown = function () {\n    $current = isset( $_GET['wpide_country'] ) ? strtoupper( sanitize_key( wp_unslash( $_GET['wpide_country'] ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<label for=\"wpide-country-filter\" class=\"screen-reader-text\">' . esc_html( 'Filter by country' ) . '</label>';\n    echo '<select name=\"wpide_country\" id=\"wpide-country-filter\"><option value=\"\">' . esc_html( 'All countries' ) . '</option>';\n\n    foreach ( WC()->countries->get_allowed_countries() as $code => $name ) {\n        printf( '<option value=\"%s\" %s>%s</option>', esc_attr( $code ), selected( $current, $code, false ), esc_html( $name ) );\n    }\n\n    echo '</select>';\n};\n\nadd_action( 'woocommerce_order_list_table_restrict_manage_orders', function ( $order_type = 'shop_order' ) use ( $wpide_country_dropdown ) {\n    if ( 'shop_order' === $order_type ) {\n        $wpide_country_dropdown();\n    }\n} );\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_country_dropdown ) {\n    if ( 'shop_order' === $post_type ) {\n        $wpide_country_dropdown();\n    }\n} );\n\nadd_filter( 'woocommerce_order_list_table_prepare_items_query_args', function ( $args ) {\n    if ( ! empty( $_GET['wpide_country'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $args['billing_country'] = strtoupper( sanitize_key( wp_unslash( $_GET['wpide_country'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    }\n\n    return $args;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || 'shop_order' !== $query->get( 'post_type' ) || empty( $_GET['wpide_country'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array( 'key' => '_billing_country', 'value' => strtoupper( sanitize_key( wp_unslash( $_GET['wpide_country'] ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    $query->set( 'meta_query', $meta_query );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Lists the countries you sell to and works with both HPOS and legacy order storage."
        },
        {
            "id": "woo-orders-customer-note-column",
            "version": 1,
            "title": "Flag Orders That Have a Customer Note",
            "description": "Adds a narrow column to the admin orders list with an icon on orders where the customer left a note, and shows the note when you hover over it.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_note_column = function ( $columns ) {\n    $new = array();\n\n    foreach ( $columns as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'order_number' === $key ) {\n            $new['wpide_note'] = '<span class=\"dashicons dashicons-format-chat\" title=\"Customer note\"></span><span class=\"screen-reader-text\">Customer note</span>';\n        }\n    }\n\n    return $new;\n};\n\n$wpide_note_render = function ( $column, $order ) {\n    if ( 'wpide_note' !== $column ) {\n        return;\n    }\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order );\n    $note  = $order ? trim( (string) $order->get_customer_note() ) : '';\n\n    if ( '' !== $note ) {\n        printf( '<span class=\"dashicons dashicons-format-chat\" style=\"color:#d63638\" title=\"%1$s\"></span><span class=\"screen-reader-text\">%2$s</span>', esc_attr( $note ), esc_html( $note ) );\n    }\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_note_column );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_note_column );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_note_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_note_render, 10, 2 );\n\nadd_action( 'admin_head', function () {\n    echo '<style>.column-wpide_note{width:2.5em;text-align:center}</style>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Helps you spot delivery instructions and gift messages before packing."
        },
        {
            "id": "woo-order-note-templates",
            "version": 1,
            "title": "Ready-Made Order Note Templates",
            "description": "Adds a dropdown of saved phrases above the order notes box, such as \"Dispatched with Royal Mail\" or \"Waiting for stock\", so staff can add common notes in one click.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "orders",
                "notes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_note_templates = array(\n    'Dispatched today. Tracking details will follow by email.',\n    'Waiting for stock from our supplier; expected within 5 working days.',\n    'Customer contacted by phone about delivery.',\n    'Address checked and confirmed with the customer.',\n);\n\nadd_action( 'admin_footer', function () use ( $wpide_note_templates ) {\n    $screen = get_current_screen();\n\n    if ( ! $screen || ! function_exists( 'wc_get_page_screen_id' ) || wc_get_page_screen_id( 'shop-order' ) !== $screen->id ) {\n        return;\n    }\n    ?>\n    <script>\n    ( function () {\n        var box = document.getElementById( 'add_order_note' );\n        if ( ! box ) { return; }\n        var templates = <?php echo wp_json_encode( array_values( $wpide_note_templates ) ); ?>;\n        var select = document.createElement( 'select' );\n        select.setAttribute( 'aria-label', 'Insert a note template' );\n        select.style.width = '100%';\n        select.style.marginBottom = '6px';\n        select.add( new Option( 'Insert a note template…', '' ) );\n        templates.forEach( function ( text ) { select.add( new Option( text, text ) ); } );\n        select.addEventListener( 'change', function () {\n            if ( select.value ) { box.value = select.value; box.focus(); select.selectedIndex = 0; }\n        } );\n        box.parentNode.insertBefore( select, box );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the phrases in $wpide_note_templates; picking one fills the note box and you still choose private or customer note."
        },
        {
            "id": "woo-sequential-invoice-numbers",
            "version": 1,
            "title": "Sequential Invoice Numbers for Orders",
            "description": "Gives each paid order its own gap-free invoice number, such as INV-2026-0001, and shows it on the order screen, in emails and in the customer's order details.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "invoices",
                "orders",
                "accounting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_invoice = array(\n    'prefix' => 'INV-{year}-', // {year} is replaced with the current year.\n    'start'  => 1,             // First number used.\n    'digits' => 4,             // Zero-padded length.\n);\n\n$wpide_invoice_assign = function ( $order_id, $order = null ) use ( $wpide_invoice ) {\n    global $wpdb;\n\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );\n\n    if ( ! $order || $order->get_meta( '_wpide_invoice_number' ) ) {\n        return;\n    }\n\n    add_option( 'wpide_invoice_counter', (string) ( (int) $wpide_invoice['start'] - 1 ), '', false );\n    // Atomic increment: LAST_INSERT_ID(expr) returns the new value to this connection only.\n    $wpdb->query( $wpdb->prepare( \"UPDATE {$wpdb->options} SET option_value = LAST_INSERT_ID(option_value + 1) WHERE option_name = %s\", 'wpide_invoice_counter' ) );\n    $number = (int) $wpdb->get_var( 'SELECT LAST_INSERT_ID()' );\n    wp_cache_delete( 'wpide_invoice_counter', 'options' );\n\n    if ( $number < 1 ) {\n        return;\n    }\n\n    $invoice = str_replace( '{year}', wp_date( 'Y' ), $wpide_invoice['prefix'] ) . str_pad( (string) $number, $wpide_invoice['digits'], '0', STR_PAD_LEFT );\n    $order->update_meta_data( '_wpide_invoice_number', $invoice );\n    $order->update_meta_data( '_wpide_invoice_date', time() );\n    $order->save_meta_data();\n};\n\nadd_action( 'woocommerce_order_status_processing', $wpide_invoice_assign, 5, 2 );\nadd_action( 'woocommerce_order_status_completed', $wpide_invoice_assign, 5, 2 );\n\nadd_action( 'woocommerce_admin_order_data_after_order_details', function ( $order ) {\n    $invoice = $order->get_meta( '_wpide_invoice_number' );\n\n    if ( $invoice ) {\n        printf( '<p class=\"form-field form-field-wide\"><strong>Invoice:</strong> %s</p>', esc_html( $invoice ) );\n    }\n} );\n\nadd_filter( 'woocommerce_email_order_meta_fields', function ( $fields, $sent_to_admin, $order ) {\n    $invoice = $order->get_meta( '_wpide_invoice_number' );\n\n    if ( $invoice ) {\n        $fields['wpide_invoice'] = array( 'label' => 'Invoice number', 'value' => $invoice );\n    }\n\n    return $fields;\n}, 10, 3 );\n\nadd_filter( 'woocommerce_get_order_item_totals', function ( $rows, $order ) {\n    $invoice = $order->get_meta( '_wpide_invoice_number' );\n\n    if ( $invoice ) {\n        $rows = array( 'wpide_invoice' => array( 'label' => 'Invoice number:', 'value' => esc_html( $invoice ) ) ) + $rows;\n    }\n\n    return $rows;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Numbers are assigned when an order reaches Processing or Completed, never change, and use an atomic counter so two orders cannot share one."
        },
        {
            "id": "woo-printable-invoice-packing-slip",
            "version": 1,
            "title": "Printable Invoice and Packing Slip From the Order Screen",
            "description": "Adds \"Print invoice\" and \"Print packing slip\" links to each order in the admin, opening a clean printable page without a PDF plugin.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "invoices",
                "packing-slip",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_admin_order_data_after_order_details', function ( $order ) {\n    $base = array( 'action' => 'wpide_print_order', 'order_id' => $order->get_id() );\n    $url  = function ( $type ) use ( $base, $order ) {\n        return wp_nonce_url( add_query_arg( $base + array( 'type' => $type ), admin_url( 'admin-post.php' ) ), 'wpide_print_' . $order->get_id() );\n    };\n\n    printf(\n        '<p class=\"form-field form-field-wide\"><a class=\"button\" target=\"_blank\" href=\"%1$s\">%2$s</a> <a class=\"button\" target=\"_blank\" href=\"%3$s\">%4$s</a></p>',\n        esc_url( $url( 'invoice' ) ),\n        esc_html( 'Print invoice' ),\n        esc_url( $url( 'packing' ) ),\n        esc_html( 'Print packing slip' )\n    );\n} );\n\nadd_action( 'admin_post_wpide_print_order', function () {\n    $order_id = isset( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;\n    $type     = isset( $_GET['type'] ) && 'packing' === $_GET['type'] ? 'packing' : 'invoice';\n\n    check_admin_referer( 'wpide_print_' . $order_id );\n\n    if ( ! current_user_can( 'edit_shop_orders' ) ) {\n        wp_die( esc_html( 'You are not allowed to print this order.' ), 403 );\n    }\n\n    $order = wc_get_order( $order_id );\n\n    if ( ! $order ) {\n        wp_die( esc_html( 'Order not found.' ), 404 );\n    }\n\n    $title   = 'packing' === $type ? 'Packing slip' : 'Invoice';\n    $invoice = $order->get_meta( '_wpide_invoice_number' );\n    ?>\n    <!doctype html>\n    <html lang=\"<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>\">\n    <head>\n        <meta charset=\"utf-8\">\n        <title><?php echo esc_html( $title . ' ' . $order->get_order_number() ); ?></title>\n        <style>\n            body{font:14px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;color:#111;max-width:800px;margin:2em auto;padding:0 1em}\n            h1{font-size:24px;margin:0 0 .25em}table{width:100%;border-collapse:collapse;margin:1.5em 0}\n            th,td{text-align:left;padding:.5em;border-bottom:1px solid #ddd;vertical-align:top}.num{text-align:right}\n            .cols{display:flex;gap:2em;flex-wrap:wrap}.cols>div{flex:1;min-width:200px}\n            @media print{.no-print{display:none}body{margin:0}}\n        </style>\n    </head>\n    <body>\n        <p class=\"no-print\"><button onclick=\"window.print()\"><?php echo esc_html( 'Print' ); ?></button></p>\n        <h1><?php echo esc_html( $title ); ?></h1>\n        <p>\n            <strong><?php echo esc_html( get_bloginfo( 'name' ) ); ?></strong><br>\n            <?php echo esc_html( implode( ', ', array_filter( array( WC()->countries->get_base_address(), WC()->countries->get_base_city(), WC()->countries->get_base_postcode() ) ) ) ); ?>\n        </p>\n        <p>\n            <?php echo esc_html( 'Order: #' . $order->get_order_number() ); ?><br>\n            <?php if ( $invoice && 'invoice' === $type ) : ?>\n                <?php echo esc_html( 'Invoice: ' . $invoice ); ?><br>\n            <?php endif; ?>\n            <?php echo esc_html( 'Date: ' . wc_format_datetime( $order->get_date_created() ) ); ?>\n        </p>\n        <div class=\"cols\">\n            <div><h3><?php echo esc_html( 'Billing address' ); ?></h3><?php echo wp_kses_post( $order->get_formatted_billing_address() ); ?></div>\n            <?php if ( $order->has_shipping_address() ) : ?>\n                <div><h3><?php echo esc_html( 'Shipping address' ); ?></h3><?php echo wp_kses_post( $order->get_formatted_shipping_address() ); ?></div>\n            <?php endif; ?>\n        </div>\n        <table>\n            <thead><tr><th><?php echo esc_html( 'Product' ); ?></th><th><?php echo esc_html( 'SKU' ); ?></th><th class=\"num\"><?php echo esc_html( 'Qty' ); ?></th><?php if ( 'invoice' === $type ) : ?><th class=\"num\"><?php echo esc_html( 'Total' ); ?></th><?php endif; ?></tr></thead>\n            <tbody>\n            <?php foreach ( $order->get_items() as $item ) : ?>\n                <?php $product = $item->get_product(); ?>\n                <tr>\n                    <td><?php echo esc_html( $item->get_name() ); ?><?php echo wp_kses_post( wc_display_item_meta( $item, array( 'echo' => false ) ) ); ?></td>\n                    <td><?php echo esc_html( $product ? $product->get_sku() : '' ); ?></td>\n                    <td class=\"num\"><?php echo esc_html( $item->get_quantity() ); ?></td>\n                    <?php if ( 'invoice' === $type ) : ?><td class=\"num\"><?php echo wp_kses_post( $order->get_formatted_line_subtotal( $item ) ); ?></td><?php endif; ?>\n                </tr>\n            <?php endforeach; ?>\n            </tbody>\n            <?php if ( 'invoice' === $type ) : ?>\n                <tfoot>\n                <?php foreach ( $order->get_order_item_totals() as $row ) : ?>\n                    <tr><th colspan=\"3\" class=\"num\"><?php echo esc_html( wp_strip_all_tags( $row['label'] ) ); ?></th><td class=\"num\"><?php echo wp_kses_post( $row['value'] ); ?></td></tr>\n                <?php endforeach; ?>\n                </tfoot>\n            <?php endif; ?>\n        </table>\n        <?php if ( $order->get_customer_note() ) : ?>\n            <p><strong><?php echo esc_html( 'Customer note:' ); ?></strong> <?php echo esc_html( $order->get_customer_note() ); ?></p>\n        <?php endif; ?>\n    </body>\n    </html>\n    <?php\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the browser's print dialog (which can save as PDF), leaves prices off the packing slip and shows invoice numbers from the sequential invoice snippet."
        },
        {
            "id": "woo-customer-print-invoice",
            "version": 1,
            "title": "\"Print Invoice\" Button in My Account Orders",
            "description": "Lets customers open a printable invoice for their paid orders from the My Account orders list, so they can file receipts without emailing you.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "invoices",
                "my-account",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_my_account_my_orders_actions', function ( $actions, $order ) {\n    if ( $order->has_status( array( 'processing', 'completed' ) ) ) {\n        $actions['wpide-invoice'] = array(\n            'url'  => wp_nonce_url( add_query_arg( 'wpide_invoice', $order->get_id(), wc_get_page_permalink( 'myaccount' ) ), 'wpide_invoice_' . $order->get_id() ),\n            'name' => 'Invoice',\n        );\n    }\n\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_GET['wpide_invoice'] ) ) {\n        return;\n    }\n\n    $order_id = absint( $_GET['wpide_invoice'] );\n    $nonce    = isset( $_GET['_wpnonce'] ) ? sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ) : '';\n\n    if ( ! wp_verify_nonce( $nonce, 'wpide_invoice_' . $order_id ) || ! current_user_can( 'view_order', $order_id ) ) {\n        wp_die( esc_html( 'Sorry, you cannot view this invoice.' ), 403 );\n    }\n\n    $order = wc_get_order( $order_id );\n\n    if ( ! $order || ! $order->has_status( array( 'processing', 'completed' ) ) ) {\n        wp_die( esc_html( 'Invoice not available.' ), 404 );\n    }\n\n    nocache_headers();\n    $invoice = $order->get_meta( '_wpide_invoice_number' );\n    ?>\n    <!doctype html>\n    <html lang=\"<?php echo esc_attr( get_bloginfo( 'language' ) ); ?>\">\n    <head>\n        <meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><meta name=\"robots\" content=\"noindex\">\n        <title><?php echo esc_html( 'Invoice #' . $order->get_order_number() ); ?></title>\n        <style>body{font:14px/1.5 -apple-system,BlinkMacSystemFont,\"Segoe UI\",Roboto,sans-serif;color:#111;max-width:760px;margin:2em auto;padding:0 1em}table{width:100%;border-collapse:collapse;margin:1.5em 0}th,td{text-align:left;padding:.5em;border-bottom:1px solid #ddd}.num{text-align:right}@media print{.no-print{display:none}}</style>\n    </head>\n    <body>\n        <p class=\"no-print\"><button onclick=\"window.print()\"><?php echo esc_html( 'Print or save as PDF' ); ?></button></p>\n        <h1><?php echo esc_html( get_bloginfo( 'name' ) ); ?></h1>\n        <p><?php echo esc_html( 'Invoice for order #' . $order->get_order_number() ); ?><?php echo $invoice ? '<br>' . esc_html( 'Invoice number: ' . $invoice ) : ''; ?><br><?php echo esc_html( wc_format_datetime( $order->get_date_created() ) ); ?></p>\n        <p><?php echo wp_kses_post( $order->get_formatted_billing_address() ); ?></p>\n        <table>\n            <?php foreach ( $order->get_items() as $item ) : ?>\n                <tr><td><?php echo esc_html( $item->get_quantity() . ' × ' . $item->get_name() ); ?></td><td class=\"num\"><?php echo wp_kses_post( $order->get_formatted_line_subtotal( $item ) ); ?></td></tr>\n            <?php endforeach; ?>\n            <?php foreach ( $order->get_order_item_totals() as $row ) : ?>\n                <tr><th><?php echo esc_html( wp_strip_all_tags( $row['label'] ) ); ?></th><td class=\"num\"><?php echo wp_kses_post( $row['value'] ); ?></td></tr>\n            <?php endforeach; ?>\n        </table>\n    </body>\n    </html>\n    <?php\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Customers can only open their own processing and completed orders."
        },
        {
            "id": "woo-export-orders-csv",
            "version": 1,
            "title": "Export Selected Orders to CSV",
            "description": "Adds an \"Export to CSV\" bulk action to the admin orders list that downloads the selected orders with customer, address, items and totals for spreadsheets or accounting.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_export_action = function ( $actions ) {\n    $actions['wpide_export_csv'] = 'Export to CSV';\n\n    return $actions;\n};\n\nadd_filter( 'bulk_actions-woocommerce_page_wc-orders', $wpide_export_action, 30 );\nadd_filter( 'bulk_actions-edit-shop_order', $wpide_export_action, 30 );\n\n$wpide_export_handle = function ( $redirect, $action, $ids ) {\n    if ( 'wpide_export_csv' !== $action || ! current_user_can( 'edit_shop_orders' ) ) {\n        return $redirect;\n    }\n\n    $cell = function ( $value ) {\n        $value = (string) $value;\n\n        return preg_match( '/^[=+\\-@\\t\\r]/', $value ) ? \"'\" . $value : $value;\n    };\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=orders-' . gmdate( 'Y-m-d-His' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fwrite( $out, \"\\xEF\\xBB\\xBF\" ); // UTF-8 BOM for Excel.\n    fputcsv( $out, array( 'Order', 'Date', 'Status', 'First name', 'Last name', 'Email', 'Phone', 'Company', 'Address', 'City', 'Postcode', 'Country', 'Items', 'Shipping', 'Discount', 'Tax', 'Total', 'Currency', 'Payment method' ) );\n\n    foreach ( array_map( 'absint', (array) $ids ) as $id ) {\n        $order = wc_get_order( $id );\n\n        if ( ! $order ) {\n            continue;\n        }\n\n        $items = array();\n\n        foreach ( $order->get_items() as $item ) {\n            $items[] = $item->get_quantity() . ' x ' . $item->get_name();\n        }\n\n        fputcsv( $out, array_map( $cell, array(\n            $order->get_order_number(),\n            $order->get_date_created() ? $order->get_date_created()->date_i18n( 'Y-m-d H:i' ) : '',\n            wc_get_order_status_name( $order->get_status() ),\n            $order->get_billing_first_name(),\n            $order->get_billing_last_name(),\n            $order->get_billing_email(),\n            $order->get_billing_phone(),\n            $order->get_billing_company(),\n            trim( $order->get_billing_address_1() . ' ' . $order->get_billing_address_2() ),\n            $order->get_billing_city(),\n            $order->get_billing_postcode(),\n            $order->get_billing_country(),\n            implode( '; ', $items ),\n            $order->get_shipping_total(),\n            $order->get_discount_total(),\n            $order->get_total_tax(),\n            $order->get_total(),\n            $order->get_currency(),\n            $order->get_payment_method_title(),\n        ) ) );\n    }\n\n    fclose( $out );\n    exit;\n};\n\nadd_filter( 'handle_bulk_actions-woocommerce_page_wc-orders', $wpide_export_handle, 10, 3 );\nadd_filter( 'handle_bulk_actions-edit-shop_order', $wpide_export_handle, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Values starting with =, +, - or @ are prefixed with an apostrophe so spreadsheets do not run them as formulas."
        },
        {
            "id": "woo-export-customers-csv",
            "version": 1,
            "title": "Export Customers to CSV",
            "description": "Adds a Customers CSV export under WooCommerce > Export customers with names, emails, phone, location, order count and total spent for every customer account.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "customers",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_submenu_page( 'woocommerce', 'Export customers', 'Export customers', 'manage_woocommerce', 'wpide-export-customers', function () {\n        ?>\n        <div class=\"wrap\">\n            <h1><?php echo esc_html( 'Export customers' ); ?></h1>\n            <p><?php echo esc_html( 'Download every customer account as a CSV file you can open in Excel, Numbers or Google Sheets.' ); ?></p>\n            <form method=\"post\" action=\"<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>\">\n                <input type=\"hidden\" name=\"action\" value=\"wpide_export_customers\">\n                <?php wp_nonce_field( 'wpide_export_customers' ); ?>\n                <?php submit_button( 'Download CSV', 'primary', 'submit', false ); ?>\n            </form>\n        </div>\n        <?php\n    }, 60 );\n} );\n\nadd_action( 'admin_post_wpide_export_customers', function () {\n    check_admin_referer( 'wpide_export_customers' );\n\n    if ( ! current_user_can( 'manage_woocommerce' ) ) {\n        wp_die( esc_html( 'You are not allowed to export customers.' ), 403 );\n    }\n\n    $cell = function ( $value ) {\n        $value = (string) $value;\n\n        return preg_match( '/^[=+\\-@\\t\\r]/', $value ) ? \"'\" . $value : $value;\n    };\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=customers-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fwrite( $out, \"\\xEF\\xBB\\xBF\" );\n    fputcsv( $out, array( 'ID', 'First name', 'Last name', 'Email', 'Phone', 'City', 'Postcode', 'Country', 'Registered', 'Orders', 'Total spent' ) );\n\n    $page = 1;\n\n    do {\n        $ids = get_users( array( 'role' => 'customer', 'fields' => 'ID', 'number' => 500, 'paged' => $page, 'orderby' => 'ID', 'count_total' => false ) );\n\n        foreach ( $ids as $id ) {\n            $customer = new WC_Customer( $id );\n\n            fputcsv( $out, array_map( $cell, array(\n                $id,\n                $customer->get_first_name(),\n                $customer->get_last_name(),\n                $customer->get_email(),\n                $customer->get_billing_phone(),\n                $customer->get_billing_city(),\n                $customer->get_billing_postcode(),\n                $customer->get_billing_country(),\n                $customer->get_date_created() ? $customer->get_date_created()->date( 'Y-m-d' ) : '',\n                $customer->get_order_count(),\n                wc_format_decimal( $customer->get_total_spent(), 2 ),\n            ) ) );\n        }\n\n        $page++;\n    } while ( count( $ids ) === 500 );\n\n    fclose( $out );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Exports customer accounts, not guest orders, in batches of 500, and only store managers can run it."
        },
        {
            "id": "woo-export-stock-csv",
            "version": 1,
            "title": "Export a Stock Report to CSV",
            "description": "Adds a one-click stock report download under WooCommerce > Stock report, listing every product and variation with SKU, stock status, quantity and price.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "inventory",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_submenu_page( 'woocommerce', 'Stock report', 'Stock report', 'manage_woocommerce', 'wpide-stock-report', function () {\n        ?>\n        <div class=\"wrap\">\n            <h1><?php echo esc_html( 'Stock report' ); ?></h1>\n            <p><?php echo esc_html( 'Download the stock level of every product and variation as a CSV file.' ); ?></p>\n            <form method=\"post\" action=\"<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>\">\n                <input type=\"hidden\" name=\"action\" value=\"wpide_export_stock\">\n                <?php wp_nonce_field( 'wpide_export_stock' ); ?>\n                <?php submit_button( 'Download CSV', 'primary', 'submit', false ); ?>\n            </form>\n        </div>\n        <?php\n    }, 61 );\n} );\n\nadd_action( 'admin_post_wpide_export_stock', function () {\n    global $wpdb;\n\n    check_admin_referer( 'wpide_export_stock' );\n\n    if ( ! current_user_can( 'manage_woocommerce' ) ) {\n        wp_die( esc_html( 'You are not allowed to export stock.' ), 403 );\n    }\n\n    $rows = $wpdb->get_results(\n        \"SELECT p.ID, p.post_title, p.post_parent, l.sku, l.stock_status, l.stock_quantity, l.min_price\n         FROM {$wpdb->posts} p\n         INNER JOIN {$wpdb->wc_product_meta_lookup} l ON l.product_id = p.ID\n         WHERE p.post_type IN ('product','product_variation') AND p.post_status = 'publish'\n         ORDER BY COALESCE(NULLIF(p.post_parent, 0), p.ID), p.post_parent, p.ID\"\n    );\n\n    $cell = function ( $value ) {\n        $value = (string) $value;\n\n        return preg_match( '/^[=+\\-@\\t\\r]/', $value ) ? \"'\" . $value : $value;\n    };\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=stock-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fwrite( $out, \"\\xEF\\xBB\\xBF\" );\n    fputcsv( $out, array( 'ID', 'Parent ID', 'Product', 'SKU', 'Stock status', 'Quantity', 'Price' ) );\n\n    foreach ( $rows as $row ) {\n        fputcsv( $out, array_map( $cell, array( $row->ID, $row->post_parent ? $row->post_parent : '', $row->post_title, $row->sku, $row->stock_status, null === $row->stock_quantity ? '' : (float) $row->stock_quantity, $row->min_price ) ) );\n    }\n\n    fclose( $out );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Reads WooCommerce's product lookup table, so even large catalogues export quickly."
        },
        {
            "id": "woo-save-cart-for-later",
            "version": 1,
            "title": "\"Save Cart for Later\" for Logged-In Customers",
            "description": "Adds a \"Save cart for later\" button to the cart; customers can restore the saved cart from their My Account dashboard on any device.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "my-account",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_cart_actions', function () {\n    if ( is_user_logged_in() ) {\n        echo '<button type=\"submit\" class=\"button\" name=\"wpide_save_cart\" value=\"1\">' . esc_html( 'Save cart for later' ) . '</button>';\n    }\n} );\n\nadd_action( 'wp_loaded', function () {\n    if ( ! is_user_logged_in() || ! function_exists( 'WC' ) || ! WC()->cart ) {\n        return;\n    }\n\n    if ( isset( $_POST['wpide_save_cart'], $_POST['woocommerce-cart-nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['woocommerce-cart-nonce'] ), 'woocommerce-cart' ) ) {\n        $saved = array();\n\n        foreach ( WC()->cart->get_cart() as $item ) {\n            $saved[] = array(\n                'product_id'   => (int) $item['product_id'],\n                'variation_id' => (int) $item['variation_id'],\n                'quantity'     => (int) $item['quantity'],\n                'variation'    => array_map( 'sanitize_text_field', (array) $item['variation'] ),\n            );\n        }\n\n        update_user_meta( get_current_user_id(), '_wpide_saved_cart', array( 'time' => time(), 'items' => $saved ) );\n        wc_add_notice( 'Your cart has been saved. Restore it any time from your account dashboard.' );\n        wp_safe_redirect( wc_get_cart_url() );\n        exit;\n    }\n\n    if ( isset( $_GET['wpide_restore_cart'], $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wpide_restore_cart' ) ) {\n        $saved = get_user_meta( get_current_user_id(), '_wpide_saved_cart', true );\n\n        foreach ( (array) ( $saved['items'] ?? array() ) as $item ) {\n            WC()->cart->add_to_cart( $item['product_id'], max( 1, $item['quantity'] ), $item['variation_id'], $item['variation'] );\n        }\n\n        wp_safe_redirect( wc_get_cart_url() );\n        exit;\n    }\n}, 30 );\n\nadd_action( 'woocommerce_account_dashboard', function () {\n    $saved = get_user_meta( get_current_user_id(), '_wpide_saved_cart', true );\n\n    if ( empty( $saved['items'] ) ) {\n        return;\n    }\n\n    $count = array_sum( wp_list_pluck( $saved['items'], 'quantity' ) );\n    printf(\n        '<div class=\"woocommerce-info\">%1$s <a class=\"button\" href=\"%2$s\">%3$s</a></div>',\n        esc_html( sprintf( 'You have a saved cart with %1$d item(s) from %2$s.', $count, wp_date( get_option( 'date_format' ), $saved['time'] ) ) ),\n        esc_url( wp_nonce_url( add_query_arg( 'wpide_restore_cart', 1, wc_get_cart_url() ), 'wpide_restore_cart' ) ),\n        esc_html( 'Restore saved cart' )\n    );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Saving replaces any earlier saved cart, and restoring skips products that are no longer available."
        },
        {
            "id": "woo-shareable-cart-link",
            "version": 1,
            "title": "Shareable Cart Link",
            "description": "Shows a link under the cart that recreates the same cart for anyone who opens it, so customers can send a cart to a colleague or you can send ready-made carts to clients.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "sharing",
                "sales"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_after_cart_table', function () {\n    $parts = array();\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $id      = $item['variation_id'] ? $item['variation_id'] : $item['product_id'];\n        $parts[] = (int) $id . ':' . (int) $item['quantity'];\n    }\n\n    if ( ! $parts ) {\n        return;\n    }\n\n    $link = add_query_arg( 'wpide_cart', implode( ',', $parts ), wc_get_cart_url() );\n    printf(\n        '<p class=\"wpide-share-cart\"><label for=\"wpide-share-cart\">%1$s</label><input type=\"text\" id=\"wpide-share-cart\" readonly value=\"%2$s\" onclick=\"this.select()\" style=\"width:100%%\"></p>',\n        esc_html( 'Share this cart:' ),\n        esc_url( $link )\n    );\n} );\n\nadd_action( 'wp_loaded', function () {\n    if ( empty( $_GET['wpide_cart'] ) || ! function_exists( 'WC' ) || ! WC()->cart ) {\n        return;\n    }\n\n    $lines = array_slice( explode( ',', sanitize_text_field( wp_unslash( $_GET['wpide_cart'] ) ) ), 0, 30 );\n\n    foreach ( $lines as $line ) {\n        list( $id, $qty ) = array_pad( array_map( 'absint', explode( ':', $line ) ), 2, 1 );\n        $product          = $id ? wc_get_product( $id ) : null;\n\n        if ( ! $product || ! $product->is_purchasable() || 'publish' !== get_post_status( $product->get_parent_id() ? $product->get_parent_id() : $id ) ) {\n            continue;\n        }\n\n        if ( $product->is_type( 'variation' ) ) {\n            WC()->cart->add_to_cart( $product->get_parent_id(), max( 1, $qty ), $id, $product->get_variation_attributes() );\n        } else {\n            WC()->cart->add_to_cart( $id, max( 1, $qty ) );\n        }\n    }\n\n    wp_safe_redirect( remove_query_arg( 'wpide_cart' ) );\n    exit;\n}, 25 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Opening the link adds up to 30 lines to the visitor's current cart; variations that need customer choices must be completed in the cart."
        },
        {
            "id": "woo-refund-reason-required",
            "version": 1,
            "title": "Require a Reason Before Issuing a Refund",
            "description": "Stops staff from issuing a refund in the order screen until they have typed a reason, so every refund in your records is explained.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "refunds",
                "admin",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_footer', function () {\n    $screen = get_current_screen();\n\n    if ( ! $screen || ! function_exists( 'wc_get_page_screen_id' ) || wc_get_page_screen_id( 'shop-order' ) !== $screen->id ) {\n        return;\n    }\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var button = event.target.closest( '.do-api-refund, .do-manual-refund' );\n        var reason = document.getElementById( 'refund_reason' );\n        if ( ! button || ! reason || reason.value.trim() !== '' ) { return; }\n        event.preventDefault();\n        event.stopImmediatePropagation();\n        window.alert( <?php echo wp_json_encode( 'Please enter a reason for this refund first.' ); ?> );\n        reason.focus();\n    }, true );\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with both manual and gateway (API) refunds on HPOS and legacy order screens."
        },
        {
            "id": "woo-orders-refunded-column",
            "version": 1,
            "title": "Refunded Amount Column in the Orders List",
            "description": "Shows how much has been refunded on each order in the admin orders list, so partial refunds are visible without opening every order.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "refunds",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_refund_column = function ( $columns ) {\n    $columns['wpide_refunded'] = 'Refunded';\n\n    return $columns;\n};\n\n$wpide_refund_render = function ( $column, $order ) {\n    if ( 'wpide_refunded' !== $column ) {\n        return;\n    }\n\n    $order    = $order instanceof WC_Order ? $order : wc_get_order( $order );\n    $refunded = $order ? (float) $order->get_total_refunded() : 0;\n\n    echo $refunded > 0 ? wp_kses_post( '<span style=\"color:#b32d2e\">' . wc_price( $refunded, array( 'currency' => $order->get_currency() ) ) . '</span>' ) : '–';\n};\n\nadd_filter( 'manage_woocommerce_page_wc-orders_columns', $wpide_refund_column );\nadd_filter( 'manage_edit-shop_order_columns', $wpide_refund_column );\nadd_action( 'manage_woocommerce_page_wc-orders_custom_column', $wpide_refund_render, 10, 2 );\nadd_action( 'manage_shop_order_posts_custom_column', $wpide_refund_render, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Orders without refunds show a dash."
        },
        {
            "id": "woo-refund-request-button",
            "version": 1,
            "title": "\"Request a Refund\" Button for Customers",
            "description": "Adds a \"Request refund\" button to recent completed orders in My Account; clicking it notes the request on the order and emails the shop owner.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "refunds",
                "my-account",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_refund_days = 30; // Days after completion that refunds can be requested.\n\nadd_filter( 'woocommerce_my_account_my_orders_actions', function ( $actions, $order ) use ( $wpide_refund_days ) {\n    $completed = $order->get_date_completed();\n\n    if ( $order->has_status( 'completed' ) && $completed && ( time() - $completed->getTimestamp() ) < $wpide_refund_days * DAY_IN_SECONDS && ! $order->get_meta( '_wpide_refund_requested' ) ) {\n        $actions['wpide-refund'] = array(\n            'url'  => wp_nonce_url( add_query_arg( 'wpide_refund_request', $order->get_id(), wc_get_account_endpoint_url( 'orders' ) ), 'wpide_refund_' . $order->get_id() ),\n            'name' => 'Request refund',\n        );\n    }\n\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_GET['wpide_refund_request'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $order_id = absint( $_GET['wpide_refund_request'] );\n    $nonce    = isset( $_GET['_wpnonce'] ) ? sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ) : '';\n    $order    = wc_get_order( $order_id );\n\n    if ( ! $order || ! wp_verify_nonce( $nonce, 'wpide_refund_' . $order_id ) || ! current_user_can( 'view_order', $order_id ) ) {\n        wc_add_notice( 'Sorry, that refund request could not be sent.', 'error' );\n    } elseif ( ! $order->get_meta( '_wpide_refund_requested' ) ) {\n        $order->update_meta_data( '_wpide_refund_requested', time() );\n        $order->add_order_note( 'The customer requested a refund from their account.' );\n        $order->save();\n\n        $mailer = WC()->mailer();\n        $body   = sprintf( '%1$s asked for a refund on order #%2$s. Open the order: %3$s', $order->get_formatted_billing_full_name(), $order->get_order_number(), $order->get_edit_order_url() );\n        $mailer->send( get_option( 'admin_email' ), sprintf( 'Refund request for order #%s', $order->get_order_number() ), $mailer->wrap_message( 'Refund request', wpautop( esc_html( $body ) ) ) );\n\n        wc_add_notice( 'Thanks, we have received your refund request and will be in touch shortly.' );\n    }\n\n    wp_safe_redirect( wc_get_account_endpoint_url( 'orders' ) );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The button shows only within the return window you set and disappears once a request is sent; you still issue refunds from the order screen."
        },
        {
            "id": "woo-no-restock-on-refund",
            "version": 1,
            "title": "Untick \"Restock Refunded Items\" by Default",
            "description": "Leaves the \"Restock refunded items\" box unticked when you refund an order, for shops where returned goods are usually damaged or not resold.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "refunds",
                "stock",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_restock_refunded_items', '__return_false' );",
            "requires": [
                "woocommerce"
            ],
            "notes": "You can still tick the box for an individual refund when the items do go back on the shelf."
        },
        {
            "id": "woo-low-stock-daily-digest",
            "version": 1,
            "title": "Daily Low Stock Digest Email",
            "description": "Sends one email each morning listing every product at or below your low stock threshold, instead of a separate alert per product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "inventory",
                "email",
                "stock"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_digest_to = ''; // Recipient; empty uses the WooCommerce stock email recipient or admin email.\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_low_stock_digest' ) ) {\n        wp_schedule_event( strtotime( 'tomorrow 07:00' ) - (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ), 'daily', 'wpide_low_stock_digest' );\n    }\n} );\n\nadd_action( 'wpide_low_stock_digest', function () use ( $wpide_digest_to ) {\n    global $wpdb;\n\n    if ( ! function_exists( 'WC' ) ) {\n        return;\n    }\n\n    $threshold = (int) get_option( 'woocommerce_notify_low_stock_amount', 2 );\n    $rows      = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT l.product_id, l.sku, l.stock_quantity FROM {$wpdb->wc_product_meta_lookup} l\n         INNER JOIN {$wpdb->posts} p ON p.ID = l.product_id AND p.post_status = 'publish'\n         WHERE l.stock_quantity IS NOT NULL AND l.stock_quantity <= %d\n         ORDER BY l.stock_quantity ASC LIMIT 200\",\n        $threshold\n    ) );\n\n    if ( ! $rows ) {\n        return;\n    }\n\n    $lines = '';\n\n    foreach ( $rows as $row ) {\n        $lines .= sprintf( '<tr><td><a href=\"%1$s\">%2$s</a></td><td>%3$s</td><td style=\"text-align:right\">%4$s</td></tr>', esc_url( admin_url( 'post.php?action=edit&post=' . ( wp_get_post_parent_id( $row->product_id ) ?: $row->product_id ) ) ), esc_html( get_the_title( $row->product_id ) ), esc_html( $row->sku ), esc_html( wc_stock_amount( $row->stock_quantity ) ) );\n    }\n\n    $to      = $wpide_digest_to ? $wpide_digest_to : get_option( 'woocommerce_stock_email_recipient', get_option( 'admin_email' ) );\n    $mailer  = WC()->mailer();\n    $content = '<table style=\"width:100%;border-collapse:collapse\" cellpadding=\"6\"><tr><th style=\"text-align:left\">Product</th><th style=\"text-align:left\">SKU</th><th style=\"text-align:right\">Stock</th></tr>' . $lines . '</table>';\n\n    $mailer->send( $to, sprintf( '%d products are low on stock', count( $rows ) ), $mailer->wrap_message( 'Low stock digest', $content ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the low stock threshold from WooCommerce > Settings > Products > Inventory and sends nothing on days when no product is low."
        },
        {
            "id": "woo-out-of-stock-admin-notice",
            "version": 1,
            "title": "Out-of-Stock Count Notice in the Admin",
            "description": "Shows store managers a notice on the dashboard and WooCommerce screens with how many products are out of stock, linking straight to the filtered product list.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "inventory",
                "admin",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_notices', function () {\n    global $wpdb;\n\n    $screen = get_current_screen();\n\n    if ( ! current_user_can( 'manage_woocommerce' ) || ! $screen || ( 'dashboard' !== $screen->id && false === strpos( (string) $screen->id, 'woocommerce' ) && 'edit-product' !== $screen->id ) ) {\n        return;\n    }\n\n    $count = get_transient( 'wpide_oos_count' );\n\n    if ( false === $count ) {\n        $count = (int) $wpdb->get_var(\n            \"SELECT COUNT(*) FROM {$wpdb->wc_product_meta_lookup} l INNER JOIN {$wpdb->posts} p ON p.ID = l.product_id\n             WHERE l.stock_status = 'outofstock' AND p.post_type = 'product' AND p.post_status = 'publish'\"\n        );\n        set_transient( 'wpide_oos_count', $count, HOUR_IN_SECONDS );\n    }\n\n    if ( $count > 0 ) {\n        printf(\n            '<div class=\"notice notice-warning\"><p>%1$s <a href=\"%2$s\">%3$s</a></p></div>',\n            esc_html( sprintf( _n( '%d product is out of stock.', '%d products are out of stock.', $count ), $count ) ),\n            esc_url( admin_url( 'edit.php?post_type=product&stock_status=outofstock' ) ),\n            esc_html( 'View them' )\n        );\n    }\n} );\n\n$wpide_oos_flush = function () {\n    delete_transient( 'wpide_oos_count' );\n};\n\nadd_action( 'woocommerce_product_set_stock_status', $wpide_oos_flush );\nadd_action( 'woocommerce_variation_set_stock_status', $wpide_oos_flush );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The count is cached for an hour and refreshed as soon as any product's stock status changes."
        },
        {
            "id": "woo-backorder-cart-notice",
            "version": 1,
            "title": "Backorder Notice in the Cart and Checkout",
            "description": "Warns customers in the cart and at checkout which of their items are on backorder and roughly when they will ship, so nobody is surprised by a delay.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "backorders",
                "cart",
                "notices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_backorder_eta = '7 to 10 working days'; // Expected dispatch time for backorders.\n\n$wpide_backorder_notice = function () use ( $wpide_backorder_eta ) {\n    $names = array();\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        $product = $item['data'];\n\n        if ( $product->managing_stock() && $product->is_on_backorder( (int) $item['quantity'] ) ) {\n            $names[] = $product->get_name();\n        }\n    }\n\n    if ( $names ) {\n        wc_print_notice( sprintf( '%1$s %2$s on backorder and will be dispatched in about %3$s.', esc_html( implode( ', ', $names ) ), 1 === count( $names ) ? 'is' : 'are', esc_html( $wpide_backorder_eta ) ), 'notice' );\n    }\n};\n\nadd_action( 'woocommerce_before_cart', $wpide_backorder_notice );\nadd_action( 'woocommerce_before_checkout_form', $wpide_backorder_notice, 6 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_backorder_eta to your usual restock time."
        },
        {
            "id": "woo-stock-change-history",
            "version": 1,
            "title": "Stock Change History on Products",
            "description": "Records every stock level change with the date and the person (or order) responsible, and shows the last 25 changes in a box on the product edit screen.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "inventory",
                "stock",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_stock_log_size = 25; // Entries kept per product.\n\n$wpide_stock_log = function ( $product ) use ( $wpide_stock_log_size ) {\n    if ( ! $product instanceof WC_Product || ! $product->managing_stock() ) {\n        return;\n    }\n\n    $parent = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n    $user   = wp_get_current_user();\n    $log    = (array) get_post_meta( $parent, '_wpide_stock_log', true );\n\n    array_unshift( $log, array(\n        'time' => time(),\n        'id'   => $product->get_id(),\n        'qty'  => wc_stock_amount( $product->get_stock_quantity() ),\n        'by'   => $user->exists() ? $user->display_name : ( wp_doing_cron() ? 'Scheduled task' : 'System / order' ),\n    ) );\n\n    update_post_meta( $parent, '_wpide_stock_log', array_slice( array_filter( $log ), 0, $wpide_stock_log_size ) );\n};\n\nadd_action( 'woocommerce_product_set_stock', $wpide_stock_log );\nadd_action( 'woocommerce_variation_set_stock', $wpide_stock_log );\n\nadd_action( 'add_meta_boxes_product', function () {\n    add_meta_box( 'wpide-stock-log', 'Stock history', function ( $post ) {\n        $log = array_filter( (array) get_post_meta( $post->ID, '_wpide_stock_log', true ) );\n\n        if ( ! $log ) {\n            echo '<p>' . esc_html( 'No stock changes recorded yet.' ) . '</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>' . esc_html( 'Date' ) . '</th><th>' . esc_html( 'Item' ) . '</th><th>' . esc_html( 'Stock' ) . '</th><th>' . esc_html( 'By' ) . '</th></tr></thead><tbody>';\n\n        foreach ( $log as $row ) {\n            printf(\n                '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td><td>%4$s</td></tr>',\n                esc_html( wp_date( 'Y-m-d H:i', $row['time'] ) ),\n                esc_html( (int) $row['id'] === (int) $post->ID ? '–' : get_the_title( $row['id'] ) ),\n                esc_html( $row['qty'] ),\n                esc_html( $row['by'] )\n            );\n        }\n\n        echo '</tbody></table>';\n    }, 'product', 'side', 'low' );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only products that manage stock are logged, and variation changes are listed on their parent product."
        },
        {
            "id": "woo-account-recent-orders",
            "version": 1,
            "title": "Recent Orders on the My Account Dashboard",
            "description": "Lists the customer's three latest orders with status and total on the My Account dashboard, so they see what matters without clicking through.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "orders",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_recent_count = 3; // Orders to show.\n\nadd_action( 'woocommerce_account_dashboard', function () use ( $wpide_recent_count ) {\n    $orders = wc_get_orders( array(\n        'customer_id' => get_current_user_id(),\n        'limit'       => $wpide_recent_count,\n        'orderby'     => 'date',\n        'order'       => 'DESC',\n        'status'      => array_keys( wc_get_order_statuses() ),\n    ) );\n\n    if ( ! $orders ) {\n        return;\n    }\n\n    echo '<h3>' . esc_html( 'Your recent orders' ) . '</h3><table class=\"woocommerce-orders-table shop_table shop_table_responsive\"><thead><tr><th>' . esc_html( 'Order' ) . '</th><th>' . esc_html( 'Date' ) . '</th><th>' . esc_html( 'Status' ) . '</th><th>' . esc_html( 'Total' ) . '</th></tr></thead><tbody>';\n\n    foreach ( $orders as $order ) {\n        printf(\n            '<tr><td data-title=\"%1$s\"><a href=\"%2$s\">#%3$s</a></td><td data-title=\"%4$s\">%5$s</td><td data-title=\"%6$s\">%7$s</td><td data-title=\"%8$s\">%9$s</td></tr>',\n            esc_attr( 'Order' ),\n            esc_url( $order->get_view_order_url() ),\n            esc_html( $order->get_order_number() ),\n            esc_attr( 'Date' ),\n            esc_html( wc_format_datetime( $order->get_date_created() ) ),\n            esc_attr( 'Status' ),\n            esc_html( wc_get_order_status_name( $order->get_status() ) ),\n            esc_attr( 'Total' ),\n            wp_kses_post( $order->get_formatted_order_total() )\n        );\n    }\n\n    echo '</tbody></table>';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change $wpide_recent_count to show more or fewer orders."
        },
        {
            "id": "woo-account-open-on-orders",
            "version": 1,
            "title": "Open My Account on the Orders Page",
            "description": "Sends customers straight to their order list when they open My Account and removes the mostly empty Dashboard tab.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "orders",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_account_landing = 'orders'; // Endpoint to open.\n\nadd_filter( 'woocommerce_account_menu_items', function ( $items ) {\n    unset( $items['dashboard'] );\n\n    return $items;\n} );\n\nadd_action( 'template_redirect', function () use ( $wpide_account_landing ) {\n    if ( ! is_user_logged_in() || ! is_account_page() || is_wc_endpoint_url() ) {\n        return;\n    }\n\n    global $wp;\n\n    if ( empty( $wp->query_vars ) || array_intersect_key( $wp->query_vars, array_flip( array( 'pagename', 'page_id', 'page' ) ) ) === $wp->query_vars ) {\n        wp_safe_redirect( wc_get_account_endpoint_url( $wpide_account_landing ) );\n        exit;\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Change 'orders' to another endpoint, such as 'downloads' or 'edit-account', to land there instead."
        },
        {
            "id": "woo-account-order-again-button",
            "version": 1,
            "title": "\"Order Again\" Button in the My Account Orders List",
            "description": "Adds an \"Order again\" button next to completed orders in My Account, refilling the cart with the same products in one click.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "reorder",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_my_account_my_orders_actions', function ( $actions, $order ) {\n    if ( $order->has_status( apply_filters( 'woocommerce_valid_order_statuses_for_order_again', array( 'completed' ) ) ) && count( $order->get_items() ) ) {\n        $actions['wpide-order-again'] = array(\n            'url'  => wp_nonce_url( add_query_arg( 'order_again', $order->get_id(), wc_get_cart_url() ), 'woocommerce-order_again' ),\n            'name' => 'Order again',\n        );\n    }\n\n    return $actions;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's built-in order-again feature, so stock and availability are checked as usual."
        },
        {
            "id": "woo-order-tracking-number",
            "version": 1,
            "title": "Tracking Number on Orders, My Account and Emails",
            "description": "Adds carrier and tracking number fields to the order screen, then shows a tracking link to the customer on their order page and in the completed order email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "shipping",
                "tracking",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_carriers = array( // Carrier => tracking URL ({number} is replaced).\n    'Royal Mail' => 'https://www.royalmail.com/track-your-item#/tracking-results/{number}',\n    'DHL'        => 'https://www.dhl.com/global-en/home/tracking.html?tracking-id={number}',\n    'UPS'        => 'https://www.ups.com/track?tracknum={number}',\n    'USPS'       => 'https://tools.usps.com/go/TrackConfirmAction?tLabels={number}',\n    'FedEx'      => 'https://www.fedex.com/fedextrack/?trknbr={number}',\n);\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_carriers ) {\n    if ( ! function_exists( 'wc_get_page_screen_id' ) ) {\n        return;\n    }\n\n    add_meta_box( 'wpide-tracking', 'Shipment tracking', function ( $post_or_order ) use ( $wpide_carriers ) {\n        $order = $post_or_order instanceof WC_Order ? $post_or_order : wc_get_order( $post_or_order->ID );\n\n        if ( ! $order ) {\n            return;\n        }\n\n        wp_nonce_field( 'wpide_tracking', 'wpide_tracking_nonce' );\n        echo '<p><label for=\"wpide-carrier\">' . esc_html( 'Carrier' ) . '</label><br><select id=\"wpide-carrier\" name=\"wpide_carrier\" style=\"width:100%\"><option value=\"\">—</option>';\n\n        foreach ( array_keys( $wpide_carriers ) as $carrier ) {\n            printf( '<option %s>%s</option>', selected( $order->get_meta( '_wpide_carrier' ), $carrier, false ), esc_html( $carrier ) );\n        }\n\n        echo '</select></p>';\n        printf( '<p><label for=\"wpide-tracking\">%s</label><br><input type=\"text\" id=\"wpide-tracking\" name=\"wpide_tracking\" value=\"%s\" style=\"width:100%%\"></p>', esc_html( 'Tracking number' ), esc_attr( $order->get_meta( '_wpide_tracking' ) ) );\n    }, wc_get_page_screen_id( 'shop-order' ), 'side', 'default' );\n} );\n\nadd_action( 'woocommerce_process_shop_order_meta', function ( $order_id ) use ( $wpide_carriers ) {\n    if ( ! isset( $_POST['wpide_tracking_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_tracking_nonce'] ), 'wpide_tracking' ) || ! current_user_can( 'edit_shop_orders' ) ) {\n        return;\n    }\n\n    $order   = wc_get_order( $order_id );\n    $carrier = isset( $_POST['wpide_carrier'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_carrier'] ) ) : '';\n\n    if ( $order ) {\n        $order->update_meta_data( '_wpide_carrier', isset( $wpide_carriers[ $carrier ] ) ? $carrier : '' );\n        $order->update_meta_data( '_wpide_tracking', isset( $_POST['wpide_tracking'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_tracking'] ) ) : '' );\n        $order->save();\n    }\n} );\n\n$wpide_tracking_html = function ( $order ) use ( $wpide_carriers ) {\n    $number  = $order->get_meta( '_wpide_tracking' );\n    $carrier = $order->get_meta( '_wpide_carrier' );\n\n    if ( ! $number ) {\n        return '';\n    }\n\n    $label = $carrier ? $carrier . ': ' . $number : $number;\n    $url   = isset( $wpide_carriers[ $carrier ] ) ? str_replace( '{number}', rawurlencode( $number ), $wpide_carriers[ $carrier ] ) : '';\n\n    return '<p class=\"wpide-tracking\"><strong>' . esc_html( 'Tracking:' ) . '</strong> ' . ( $url ? '<a href=\"' . esc_url( $url ) . '\">' . esc_html( $label ) . '</a>' : esc_html( $label ) ) . '</p>';\n};\n\nadd_action( 'woocommerce_order_details_after_order_table', function ( $order ) use ( $wpide_tracking_html ) {\n    echo wp_kses_post( $wpide_tracking_html( $order ) );\n} );\n\nadd_action( 'woocommerce_email_before_order_table', function ( $order, $sent_to_admin, $plain_text, $email ) use ( $wpide_tracking_html ) {\n    if ( $sent_to_admin || ! $email || 'customer_completed_order' !== $email->id ) {\n        return;\n    }\n\n    $html = $wpide_tracking_html( $order );\n    echo $plain_text ? esc_html( wp_strip_all_tags( $html ) ) . \"\\n\\n\" : wp_kses_post( $html );\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Add carriers and their tracking URLs to $wpide_carriers ({number} is replaced); works with HPOS and legacy orders."
        },
        {
            "id": "woo-account-buy-again-tab",
            "version": 1,
            "title": "\"Buy Again\" Tab With Previously Purchased Products",
            "description": "Adds a My Account tab listing every product the customer has bought before, each with an add-to-cart button, ideal for consumables and repeat orders.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "reorder",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_buy_again_slug = 'buy-again'; // URL slug: /my-account/buy-again/\n\nadd_action( 'init', function () use ( $wpide_buy_again_slug ) {\n    add_rewrite_endpoint( $wpide_buy_again_slug, EP_ROOT | EP_PAGES );\n} );\n\nadd_filter( 'woocommerce_get_query_vars', function ( $vars ) use ( $wpide_buy_again_slug ) {\n    $vars[ $wpide_buy_again_slug ] = $wpide_buy_again_slug;\n\n    return $vars;\n} );\n\nadd_filter( 'woocommerce_account_menu_items', function ( $items ) use ( $wpide_buy_again_slug ) {\n    $new = array();\n\n    foreach ( $items as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'orders' === $key ) {\n            $new[ $wpide_buy_again_slug ] = 'Buy again';\n        }\n    }\n\n    return $new;\n} );\n\nadd_action( 'woocommerce_account_' . $wpide_buy_again_slug . '_endpoint', function () {\n    $user_id = get_current_user_id();\n    $ids     = get_transient( 'wpide_buy_again_' . $user_id );\n\n    if ( false === $ids ) {\n        $ids    = array();\n        $orders = wc_get_orders( array( 'customer_id' => $user_id, 'status' => wc_get_is_paid_statuses(), 'limit' => 50 ) );\n\n        foreach ( $orders as $order ) {\n            foreach ( $order->get_items() as $item ) {\n                $ids[] = $item->get_variation_id() ? $item->get_variation_id() : $item->get_product_id();\n            }\n        }\n\n        $ids = array_values( array_unique( array_filter( $ids ) ) );\n        set_transient( 'wpide_buy_again_' . $user_id, $ids, DAY_IN_SECONDS );\n    }\n\n    $shown = 0;\n    echo '<ul class=\"wpide-buy-again\" style=\"list-style:none;margin:0;padding:0\">';\n\n    foreach ( $ids as $id ) {\n        $product = wc_get_product( $id );\n\n        if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) {\n            continue;\n        }\n\n        $shown++;\n        printf(\n            '<li style=\"display:flex;gap:1em;align-items:center;padding:.75em 0;border-bottom:1px solid #eee\"><a href=\"%1$s\" style=\"flex:0 0 64px\">%2$s</a><span style=\"flex:1\"><a href=\"%1$s\">%3$s</a><br>%4$s</span><a class=\"button\" href=\"%5$s\">%6$s</a></li>',\n            esc_url( $product->get_permalink() ),\n            wp_kses_post( $product->get_image( array( 64, 64 ) ) ),\n            esc_html( $product->get_name() ),\n            wp_kses_post( $product->get_price_html() ),\n            esc_url( $product->is_type( 'variation' ) ? add_query_arg( array_merge( array( 'add-to-cart' => $product->get_parent_id(), 'variation_id' => $id ), $product->get_variation_attributes() ), wc_get_cart_url() ) : add_query_arg( 'add-to-cart', $id, wc_get_cart_url() ) ),\n            esc_html( 'Add to cart' )\n        );\n    }\n\n    echo '</ul>';\n\n    if ( ! $shown ) {\n        echo '<p>' . esc_html( 'Products you buy will appear here for quick reordering.' ) . '</p>';\n    }\n} );\n\nadd_action( 'woocommerce_order_status_changed', function ( $order_id, $from, $to, $order ) {\n    if ( $order instanceof WC_Order && $order->get_customer_id() ) {\n        delete_transient( 'wpide_buy_again_' . $order->get_customer_id() );\n    }\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Visit Settings > Permalinks once after activating so the new tab's URL works."
        },
        {
            "id": "woo-account-phone-field",
            "version": 1,
            "title": "Phone Number on the Account Details Form",
            "description": "Adds the billing phone number to the My Account \"Account details\" form, so customers can update it in the same place as their name and email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "phone",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_edit_account_form', function () {\n    $phone = get_user_meta( get_current_user_id(), 'billing_phone', true );\n    ?>\n    <p class=\"woocommerce-form-row woocommerce-form-row--wide form-row form-row-wide\">\n        <label for=\"account_phone\"><?php echo esc_html( 'Phone number' ); ?></label>\n        <input type=\"tel\" class=\"woocommerce-Input input-text\" name=\"account_phone\" id=\"account_phone\" autocomplete=\"tel\" value=\"<?php echo esc_attr( $phone ); ?>\">\n    </p>\n    <?php\n} );\n\nadd_action( 'woocommerce_save_account_details_errors', function ( $errors ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the account form nonce.\n    $phone = isset( $_POST['account_phone'] ) ? wc_sanitize_phone_number( wp_unslash( $_POST['account_phone'] ) ) : '';\n\n    if ( '' !== $phone && ! WC_Validation::is_phone( $phone ) ) {\n        $errors->add( 'account_phone', 'Please enter a valid phone number.' );\n    }\n} );\n\nadd_action( 'woocommerce_save_account_details', function ( $user_id ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the account form nonce.\n    if ( isset( $_POST['account_phone'] ) ) {\n        update_user_meta( $user_id, 'billing_phone', wc_sanitize_phone_number( wp_unslash( $_POST['account_phone'] ) ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Saves to the same billing phone used at checkout, so both stay in sync."
        },
        {
            "id": "woo-menu-logout-nonce",
            "version": 1,
            "title": "Skip \"Are You Sure You Want to Log Out?\" for Menu Links",
            "description": "Adds the security token to My Account log out links placed in your navigation menus, so customers log out in one click instead of seeing a confirmation page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "logout",
                "menus"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_nav_menu_objects', function ( $items ) {\n    if ( ! function_exists( 'wc_get_account_endpoint_url' ) || ! is_user_logged_in() ) {\n        return $items;\n    }\n\n    $logout = untrailingslashit( wc_get_account_endpoint_url( 'customer-logout' ) );\n\n    foreach ( $items as $item ) {\n        if ( untrailingslashit( strtok( (string) $item->url, '?' ) ) === $logout ) {\n            $item->url = wc_logout_url();\n        }\n    }\n\n    return $items;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Point a custom menu link at /my-account/customer-logout/ and this snippet turns it into a direct, secure log out link."
        },
        {
            "id": "woo-registration-name-fields",
            "version": 1,
            "title": "First and Last Name on the WooCommerce Registration Form",
            "description": "Adds required first and last name fields to the My Account registration form and saves them to the customer's profile and billing details.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "registration",
                "my-account",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_register_form_start', function () {\n    // phpcs:disable WordPress.Security.NonceVerification -- Repopulating the form after a failed submit.\n    $first = isset( $_POST['billing_first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['billing_first_name'] ) ) : '';\n    $last  = isset( $_POST['billing_last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['billing_last_name'] ) ) : '';\n    // phpcs:enable\n    ?>\n    <p class=\"form-row form-row-first\">\n        <label for=\"reg_billing_first_name\"><?php echo esc_html( 'First name' ); ?> <span class=\"required\" aria-hidden=\"true\">*</span></label>\n        <input type=\"text\" class=\"input-text\" name=\"billing_first_name\" id=\"reg_billing_first_name\" autocomplete=\"given-name\" required value=\"<?php echo esc_attr( $first ); ?>\">\n    </p>\n    <p class=\"form-row form-row-last\">\n        <label for=\"reg_billing_last_name\"><?php echo esc_html( 'Last name' ); ?> <span class=\"required\" aria-hidden=\"true\">*</span></label>\n        <input type=\"text\" class=\"input-text\" name=\"billing_last_name\" id=\"reg_billing_last_name\" autocomplete=\"family-name\" required value=\"<?php echo esc_attr( $last ); ?>\">\n    </p>\n    <div class=\"clear\"></div>\n    <?php\n} );\n\nadd_filter( 'woocommerce_registration_errors', function ( $errors ) {\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies the registration nonce.\n    if ( empty( $_POST['billing_first_name'] ) || '' === trim( sanitize_text_field( wp_unslash( $_POST['billing_first_name'] ) ) ) ) {\n        $errors->add( 'billing_first_name_error', 'Please enter your first name.' );\n    }\n\n    if ( empty( $_POST['billing_last_name'] ) || '' === trim( sanitize_text_field( wp_unslash( $_POST['billing_last_name'] ) ) ) ) {\n        $errors->add( 'billing_last_name_error', 'Please enter your last name.' );\n    }\n    // phpcs:enable\n\n    return $errors;\n} );\n\nadd_action( 'woocommerce_created_customer', function ( $customer_id ) {\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies the registration nonce.\n    foreach ( array( 'first_name', 'last_name' ) as $field ) {\n        if ( isset( $_POST[ 'billing_' . $field ] ) ) {\n            $value = sanitize_text_field( wp_unslash( $_POST[ 'billing_' . $field ] ) );\n            update_user_meta( $customer_id, $field, $value );\n            update_user_meta( $customer_id, 'billing_' . $field, $value );\n        }\n    }\n    // phpcs:enable\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Enable \"Allow customers to create an account on the My account page\" in WooCommerce > Settings > Accounts to see the form."
        },
        {
            "id": "woo-account-lifetime-stats",
            "version": 1,
            "title": "Order Count and Total Spent on the Account Dashboard",
            "description": "Shows customers how many orders they have placed and how much they have spent in total, a nice touch for loyalty or VIP programmes.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "loyalty",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_account_dashboard', function () {\n    $user_id = get_current_user_id();\n    $count   = wc_get_customer_order_count( $user_id );\n\n    if ( ! $count ) {\n        return;\n    }\n\n    printf(\n        '<p class=\"wpide-account-stats\">%s</p>',\n        wp_kses_post( sprintf( 'You have placed <strong>%1$d</strong> order(s) with us, totalling <strong>%2$s</strong>. Thank you!', $count, wc_price( wc_get_customer_total_spent( $user_id ) ) ) )\n    );\n}, 5 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's cached customer totals, so it adds no heavy queries."
        },
        {
            "id": "woo-account-deletion-request",
            "version": 1,
            "title": "\"Delete My Account\" Request in My Account",
            "description": "Adds a button to the Account details page that sends a personal data erasure request through WordPress's privacy tools, which the customer confirms by email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "privacy",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_edit_account_form_end', function () {\n    printf(\n        '<p class=\"wpide-delete-account\"><a class=\"button\" href=\"%1$s\" onclick=\"return confirm(%2$s)\">%3$s</a></p>',\n        esc_url( wp_nonce_url( add_query_arg( 'wpide_erase_request', 1, wc_get_account_endpoint_url( 'edit-account' ) ), 'wpide_erase_request' ) ),\n        esc_attr( wp_json_encode( 'We will email you a link to confirm that you want your account and personal data deleted. Continue?' ) ),\n        esc_html( 'Request account deletion' )\n    );\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_GET['wpide_erase_request'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wpide_erase_request' ) ) {\n        return;\n    }\n\n    $request_id = wp_create_user_request( wp_get_current_user()->user_email, 'remove_personal_data' );\n\n    if ( is_wp_error( $request_id ) ) {\n        wc_add_notice( 'A deletion request is already pending for your account. Please check your inbox for the confirmation email.', 'notice' );\n    } else {\n        wp_send_user_request( $request_id );\n        wc_add_notice( 'Please check your inbox and click the link to confirm your account deletion request.' );\n    }\n\n    wp_safe_redirect( wc_get_account_endpoint_url( 'edit-account' ) );\n    exit;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Requests appear under Tools > Erase Personal Data for you to complete; nothing is deleted automatically."
        },
        {
            "id": "woo-completed-email-unique-coupon",
            "version": 1,
            "title": "Unique Thank-You Coupon in the Order Completed Email",
            "description": "Creates a personal, single-use discount code for each completed order and adds it to the customer's \"Order completed\" email to encourage a second purchase.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "email",
                "retention"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_thanks_coupon = array(\n    'type'    => 'percent', // percent or fixed_cart.\n    'amount'  => 10,\n    'days'    => 60,        // Days until it expires.\n    'prefix'  => 'THANKS-',\n);\n\nadd_action( 'woocommerce_order_status_completed', function ( $order_id, $order = null ) use ( $wpide_thanks_coupon ) {\n    $order = $order instanceof WC_Order ? $order : wc_get_order( $order_id );\n\n    if ( ! $order || $order->get_meta( '_wpide_thanks_coupon' ) || ! $order->get_billing_email() ) {\n        return;\n    }\n\n    $code   = $wpide_thanks_coupon['prefix'] . strtoupper( wp_generate_password( 8, false, false ) );\n    $coupon = new WC_Coupon();\n    $coupon->set_code( $code );\n    $coupon->set_discount_type( $wpide_thanks_coupon['type'] );\n    $coupon->set_amount( $wpide_thanks_coupon['amount'] );\n    $coupon->set_usage_limit( 1 );\n    $coupon->set_individual_use( true );\n    $coupon->set_email_restrictions( array( $order->get_billing_email() ) );\n    $coupon->set_date_expires( time() + $wpide_thanks_coupon['days'] * DAY_IN_SECONDS );\n    $coupon->set_description( 'Thank-you coupon for order #' . $order->get_order_number() );\n    $coupon->save();\n\n    $order->update_meta_data( '_wpide_thanks_coupon', $code );\n    $order->save_meta_data();\n}, 1, 2 );\n\nadd_action( 'woocommerce_email_after_order_table', function ( $order, $sent_to_admin, $plain_text, $email ) use ( $wpide_thanks_coupon ) {\n    $code = $order->get_meta( '_wpide_thanks_coupon' );\n\n    if ( $sent_to_admin || ! $code || ! $email || 'customer_completed_order' !== $email->id ) {\n        return;\n    }\n\n    $offer = 'percent' === $wpide_thanks_coupon['type'] ? $wpide_thanks_coupon['amount'] . '%' : wp_strip_all_tags( wc_price( $wpide_thanks_coupon['amount'] ) );\n    $text  = sprintf( 'Thank you! Here is %1$s off your next order with code %2$s, valid for %3$d days.', $offer, $code, $wpide_thanks_coupon['days'] );\n\n    if ( $plain_text ) {\n        echo esc_html( $text ) . \"\\n\\n\";\n        return;\n    }\n\n    printf( '<div style=\"margin:20px 0;padding:16px;border:2px dashed #7f54b3;text-align:center\"><p style=\"margin:0 0 8px\">%1$s</p><p style=\"margin:0;font-size:22px;font-weight:700;letter-spacing:2px\">%2$s</p></div>', esc_html( sprintf( 'Thank you! Enjoy %1$s off your next order (valid %2$d days):', $offer, $wpide_thanks_coupon['days'] ) ), esc_html( $code ) );\n}, 10, 4 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The coupon only works for the customer's billing email, expires after the set number of days and is created once per order."
        },
        {
            "id": "woo-bcc-customer-emails",
            "version": 1,
            "title": "Blind Copy Customer Emails to the Shop",
            "description": "Sends you a hidden copy (BCC) of chosen customer emails, such as processing and completed order emails, so you can see exactly what customers receive.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "bcc",
                "orders"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_bcc = array(\n    'address' => 'orders@example.com', // Copy recipient.\n    'emails'  => array( 'customer_processing_order', 'customer_completed_order', 'customer_refunded_order' ),\n);\n\nadd_filter( 'woocommerce_email_headers', function ( $headers, $email_id ) use ( $wpide_bcc ) {\n    if ( in_array( $email_id, $wpide_bcc['emails'], true ) && is_email( $wpide_bcc['address'] ) ) {\n        $headers .= 'Bcc: ' . $wpide_bcc['address'] . \"\\r\\n\";\n    }\n\n    return $headers;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Email IDs include customer_processing_order, customer_completed_order, customer_invoice, customer_note and customer_refunded_order."
        },
        {
            "id": "woo-email-order-meta-keys",
            "version": 1,
            "title": "Show Custom Order Fields in Order Emails",
            "description": "Adds the order details you choose, such as a delivery date or gift message saved by another plugin, to WooCommerce's order emails with friendly labels.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "order-meta",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_email_meta = array( // Meta key => array( label, admin only ).\n    '_delivery_date' => array( 'Delivery date', false ),\n    '_gift_message'  => array( 'Gift message', false ),\n    '_source'        => array( 'Order source', true ),\n);\n\nadd_filter( 'woocommerce_email_order_meta_fields', function ( $fields, $sent_to_admin, $order ) use ( $wpide_email_meta ) {\n    foreach ( $wpide_email_meta as $key => $config ) {\n        $value = $order->get_meta( $key );\n\n        if ( '' === $value || is_array( $value ) || ( $config[1] && ! $sent_to_admin ) ) {\n            continue;\n        }\n\n        $fields[ 'wpide_' . sanitize_key( $key ) ] = array(\n            'label' => $config[0],\n            'value' => wp_strip_all_tags( (string) $value ),\n        );\n    }\n\n    return $fields;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "List meta keys and labels in $wpide_email_meta, and set the second value to true for details only the shop should see."
        },
        {
            "id": "woo-email-personal-heading",
            "version": 1,
            "title": "Personal Email Headings With the Customer's Name",
            "description": "Changes the headings of customer order emails to greet the customer by first name, for example \"Thanks for your order, Anna!\".",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "personalisation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_email_headings = array( // Email ID => heading.\n    'customer_processing_order' => 'Thanks for your order, {first_name}!',\n    'customer_completed_order'  => 'Good news, {first_name}: it is on its way!',\n    'customer_on_hold_order'    => 'We have received order #{order_number}',\n);\n\nforeach ( $wpide_email_headings as $wpide_email_id => $wpide_heading ) {\n    add_filter( 'woocommerce_email_heading_' . $wpide_email_id, function ( $heading, $order ) use ( $wpide_heading ) {\n        if ( ! $order instanceof WC_Order || ! $order->get_billing_first_name() ) {\n            return $heading;\n        }\n\n        return strtr( $wpide_heading, array(\n            '{first_name}'   => $order->get_billing_first_name(),\n            '{order_number}' => $order->get_order_number(),\n        ) );\n    }, 10, 2 );\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the headings in $wpide_email_headings; {first_name} and {order_number} are replaced."
        },
        {
            "id": "woo-new-order-email-by-category",
            "version": 1,
            "title": "Send New Order Emails to a Supplier by Category",
            "description": "Adds an extra recipient to the \"New order\" email when the order contains products from a category, so suppliers or warehouse teams only get the orders they fulfil.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "dropshipping",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_category_recipients = array( // Category slug => email address.\n    'furniture' => 'warehouse@example.com',\n    'prints'    => 'printshop@example.com',\n);\n\nadd_filter( 'woocommerce_email_recipient_new_order', function ( $recipient, $order ) use ( $wpide_category_recipients ) {\n    if ( ! $order instanceof WC_Order ) {\n        return $recipient;\n    }\n\n    $extra = array();\n\n    foreach ( $order->get_items() as $item ) {\n        foreach ( $wpide_category_recipients as $slug => $email ) {\n            if ( has_term( $slug, 'product_cat', $item->get_product_id() ) && is_email( $email ) ) {\n                $extra[] = $email;\n            }\n        }\n    }\n\n    return $extra ? implode( ',', array_unique( array_merge( array_filter( array_map( 'trim', explode( ',', (string) $recipient ) ) ), $extra ) ) ) : $recipient;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Map category slugs to email addresses; the usual recipients still receive every new order email."
        },
        {
            "id": "woo-email-custom-styles",
            "version": 1,
            "title": "Custom Styles for WooCommerce Emails",
            "description": "Adds your own CSS to every WooCommerce email, for example to change fonts, button colours or table borders, without overriding email templates.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "styling",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_email_css = '\n#template_header h1 { font-weight: 600; letter-spacing: -0.5px; }\n#body_content table td, #body_content table th { border-color: #e5e5e5 !important; }\n#body_content a { color: #2563eb; }\n.td { padding: 10px 12px !important; }\n#template_footer #credit { font-size: 12px; color: #6b7280; }\n';\n\nadd_filter( 'woocommerce_email_styles', function ( $css ) use ( $wpide_email_css ) {\n    return $css . \"\\n\" . wp_strip_all_tags( $wpide_email_css );\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "WooCommerce inlines this CSS into each email, so keep to simple selectors that email apps support."
        },
        {
            "id": "woo-new-customer-admin-alert",
            "version": 1,
            "title": "Email the Shop When a New Customer Registers",
            "description": "Sends the shop owner a short email whenever someone creates a customer account, at checkout or on the My Account page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "customers",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_new_customer_to = ''; // Recipient; empty uses the admin email.\n\nadd_action( 'woocommerce_created_customer', function ( $customer_id ) use ( $wpide_new_customer_to ) {\n    $user = get_userdata( $customer_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $mailer = WC()->mailer();\n    $body   = sprintf(\n        '<p>%1$s</p><p><a href=\"%2$s\">%3$s</a></p>',\n        esc_html( sprintf( 'A new customer account was created: %1$s (%2$s).', $user->display_name, $user->user_email ) ),\n        esc_url( admin_url( 'user-edit.php?user_id=' . $customer_id ) ),\n        esc_html( 'View customer' )\n    );\n\n    $mailer->send( $wpide_new_customer_to ? $wpide_new_customer_to : get_option( 'admin_email' ), sprintf( '[%s] New customer registered', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), $mailer->wrap_message( 'New customer', $body ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_new_customer_to to send it to someone other than the site admin."
        },
        {
            "id": "woo-email-product-attachment",
            "version": 1,
            "title": "Attach a Product's Manual to Its Order Email",
            "description": "Adds a field to each product for a file from the media library, such as a manual or warranty card, and attaches it to the customer's completed order email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "attachments",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_general_product_data', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'          => '_wpide_email_attachment',\n        'label'       => 'Email attachment',\n        'type'        => 'url',\n        'placeholder' => 'Media library file URL',\n        'description' => 'Attached to the order completed email.',\n        'desc_tip'    => true,\n        'value'       => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_email_attachment' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_email_attachment'] ) ) {\n        $product->update_meta_data( '_wpide_email_attachment', esc_url_raw( wp_unslash( $_POST['_wpide_email_attachment'] ) ) );\n    }\n} );\n\nadd_filter( 'woocommerce_email_attachments', function ( $attachments, $email_id, $order ) {\n    if ( 'customer_completed_order' !== $email_id || ! $order instanceof WC_Order ) {\n        return $attachments;\n    }\n\n    foreach ( $order->get_items() as $item ) {\n        $url = get_post_meta( $item->get_product_id(), '_wpide_email_attachment', true );\n        $id  = $url ? attachment_url_to_postid( $url ) : 0;\n        $file = $id ? get_attached_file( $id ) : '';\n\n        if ( $file && file_exists( $file ) && ! in_array( $file, $attachments, true ) ) {\n            $attachments[] = $file;\n        }\n    }\n\n    return $attachments;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Paste the file's media library URL into the product's \"Email attachment\" field; only files from your own media library are attached."
        },
        {
            "id": "woo-email-help-box",
            "version": 1,
            "title": "Help and Contact Box in Customer Emails",
            "description": "Adds a short \"Questions about your order?\" box with your phone number and email to the bottom of customer order emails.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "support",
                "customers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_email_help = array(\n    'title' => 'Questions about your order?',\n    'text'  => 'Just reply to this email or call us on 020 7946 0000 (Mon to Fri, 9am to 5pm).',\n);\n\nadd_action( 'woocommerce_email_customer_details', function ( $order, $sent_to_admin, $plain_text ) use ( $wpide_email_help ) {\n    if ( $sent_to_admin ) {\n        return;\n    }\n\n    if ( $plain_text ) {\n        echo \"\\n\" . esc_html( $wpide_email_help['title'] ) . \"\\n\" . esc_html( $wpide_email_help['text'] ) . \"\\n\";\n        return;\n    }\n\n    printf( '<div style=\"margin:24px 0 0;padding:16px;background:#f6f7f7;border-radius:4px\"><strong>%1$s</strong><br>%2$s</div>', esc_html( $wpide_email_help['title'] ), esc_html( $wpide_email_help['text'] ) );\n}, 30, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the text and contact details at the top; emails sent to the shop are left unchanged."
        },
        {
            "id": "woo-admin-email-map-link",
            "version": 1,
            "title": "Map Link for the Shipping Address in New Order Emails",
            "description": "Adds an \"Open in Maps\" link under the shipping address in the shop's new order email, handy for local delivery drivers.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "email",
                "local-delivery",
                "maps"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_email_customer_details', function ( $order, $sent_to_admin, $plain_text ) {\n    if ( ! $sent_to_admin || ! $order instanceof WC_Order || ! $order->has_shipping_address() ) {\n        return;\n    }\n\n    $address = implode( ', ', array_filter( array( $order->get_shipping_address_1(), $order->get_shipping_address_2(), $order->get_shipping_city(), $order->get_shipping_postcode(), $order->get_shipping_country() ) ) );\n    $url     = 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode( $address );\n\n    if ( $plain_text ) {\n        echo \"\\n\" . esc_html( 'Map: ' ) . esc_url_raw( $url ) . \"\\n\";\n        return;\n    }\n\n    printf( '<p><a href=\"%s\">%s</a></p>', esc_url( $url ), esc_html( 'Open the delivery address in Maps' ) );\n}, 25, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only added to emails sent to the shop, and nothing is requested from Google until someone clicks the link."
        },
        {
            "id": "woo-dashboard-month-sales",
            "version": 1,
            "title": "Month-to-Date Sales vs Last Month Widget",
            "description": "Adds a dashboard widget comparing this month's revenue and order count so far with the same days of last month, with the change in percent.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "dashboard",
                "reports",
                "sales"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'view_woocommerce_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_month_sales', 'Sales this month', function () {\n        global $wpdb;\n\n        $data = get_transient( 'wpide_month_sales' );\n\n        if ( false === $data ) {\n            $tz        = wp_timezone();\n            $now       = new DateTimeImmutable( 'now', $tz );\n            $this_from = $now->modify( 'first day of this month midnight' );\n            $last_from = $this_from->modify( '-1 month' );\n            $last_to   = $last_from->add( $this_from->diff( $now ) );\n            $hpos      = class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) && \\Automattic\\WooCommerce\\Utilities\\OrderUtil::custom_orders_table_usage_is_enabled();\n\n            $sum = function ( $from, $to ) use ( $wpdb, $hpos ) {\n                $from = $from->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );\n                $to   = $to->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );\n\n                if ( $hpos ) {\n                    return $wpdb->get_row( $wpdb->prepare( \"SELECT COUNT(*) AS orders, COALESCE(SUM(total_amount),0) AS total FROM {$wpdb->prefix}wc_orders WHERE type = 'shop_order' AND status IN ('wc-processing','wc-completed') AND date_created_gmt BETWEEN %s AND %s\", $from, $to ) );\n                }\n\n                return $wpdb->get_row( $wpdb->prepare( \"SELECT COUNT(*) AS orders, COALESCE(SUM(m.meta_value),0) AS total FROM {$wpdb->posts} p INNER JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_order_total' WHERE p.post_type = 'shop_order' AND p.post_status IN ('wc-processing','wc-completed') AND p.post_date_gmt BETWEEN %s AND %s\", $from, $to ) );\n            };\n\n            $data = array( 'this' => (array) $sum( $this_from, $now ), 'last' => (array) $sum( $last_from, $last_to ) );\n            set_transient( 'wpide_month_sales', $data, 30 * MINUTE_IN_SECONDS );\n        }\n\n        $change = (float) $data['last']['total'] > 0 ? ( (float) $data['this']['total'] - (float) $data['last']['total'] ) / (float) $data['last']['total'] * 100 : null;\n\n        printf(\n            '<p style=\"font-size:24px;margin:.2em 0\"><strong>%1$s</strong></p><p>%2$s</p><p>%3$s</p>',\n            wp_kses_post( wc_price( $data['this']['total'] ) ),\n            esc_html( sprintf( '%d orders so far this month', $data['this']['orders'] ) ),\n            null === $change\n                ? esc_html( 'No sales in the same period last month.' )\n                : wp_kses_post( sprintf( '<span style=\"color:%1$s\">%2$s%3$s%%</span> compared with the same days last month (%4$s).', $change >= 0 ? '#008a20' : '#d63638', $change >= 0 ? '+' : '', number_format_i18n( $change, 1 ), wc_price( $data['last']['total'] ) ) )\n        );\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Counts processing and completed orders, caches the figures for 30 minutes and works with HPOS and legacy order storage."
        },
        {
            "id": "woo-dashboard-top-sellers",
            "version": 1,
            "title": "Top Sellers of the Last 30 Days Widget",
            "description": "Adds a dashboard widget listing your five best-selling products of the past 30 days by units sold, with links to edit each product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "dashboard",
                "reports",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'view_woocommerce_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_top_sellers', 'Top sellers (30 days)', function () {\n        global $wpdb;\n\n        $rows = get_transient( 'wpide_top_sellers' );\n\n        if ( false === $rows ) {\n            $since = gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS );\n            $hpos  = class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) && \\Automattic\\WooCommerce\\Utilities\\OrderUtil::custom_orders_table_usage_is_enabled();\n            $join  = $hpos\n                ? \"INNER JOIN {$wpdb->prefix}wc_orders o ON o.id = oi.order_id AND o.type = 'shop_order' AND o.status IN ('wc-processing','wc-completed') AND o.date_created_gmt >= %s\"\n                : \"INNER JOIN {$wpdb->posts} o ON o.ID = oi.order_id AND o.post_type = 'shop_order' AND o.post_status IN ('wc-processing','wc-completed') AND o.post_date_gmt >= %s\";\n\n            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $join contains only table names and a placeholder.\n            $rows = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT pid.meta_value AS product_id, SUM(qty.meta_value) AS units\n                 FROM {$wpdb->prefix}woocommerce_order_items oi\n                 {$join}\n                 INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta pid ON pid.order_item_id = oi.order_item_id AND pid.meta_key = '_product_id'\n                 INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta qty ON qty.order_item_id = oi.order_item_id AND qty.meta_key = '_qty'\n                 WHERE oi.order_item_type = 'line_item'\n                 GROUP BY pid.meta_value ORDER BY units DESC LIMIT 5\",\n                $since\n            ) );\n            set_transient( 'wpide_top_sellers', $rows, HOUR_IN_SECONDS );\n        }\n\n        if ( ! $rows ) {\n            echo '<p>' . esc_html( 'No sales in the last 30 days.' ) . '</p>';\n            return;\n        }\n\n        echo '<ol>';\n\n        foreach ( $rows as $row ) {\n            printf( '<li><a href=\"%1$s\">%2$s</a> &ndash; %3$s</li>', esc_url( get_edit_post_link( $row->product_id ) ), esc_html( get_the_title( $row->product_id ) ), esc_html( sprintf( '%s sold', wc_stock_amount( $row->units ) ) ) );\n        }\n\n        echo '</ol>';\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Counts processing and completed orders, caches the list for an hour and works with HPOS and legacy order storage."
        },
        {
            "id": "woo-dashboard-latest-reviews",
            "version": 1,
            "title": "Latest Product Reviews Dashboard Widget",
            "description": "Shows the five newest product reviews with star rating, product and approval status on the dashboard, so you can answer or approve them quickly.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "dashboard",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_latest_reviews', 'Latest product reviews', function () {\n        $reviews = get_comments( array(\n            'post_type' => 'product',\n            'type'      => 'review',\n            'status'    => 'all',\n            'number'    => 5,\n        ) );\n\n        if ( ! $reviews ) {\n            echo '<p>' . esc_html( 'No reviews yet.' ) . '</p>';\n            return;\n        }\n\n        echo '<ul>';\n\n        foreach ( $reviews as $review ) {\n            $rating = (int) get_comment_meta( $review->comment_ID, 'rating', true );\n\n            printf(\n                '<li style=\"margin-bottom:10px\"><span aria-label=\"%1$s\" style=\"color:#dba617\">%2$s</span> <a href=\"%3$s\">%4$s</a>%5$s<br><em>%6$s</em> &ndash; %7$s</li>',\n                esc_attr( sprintf( 'Rated %d out of 5', $rating ) ),\n                esc_html( str_repeat( '★', $rating ) . str_repeat( '☆', 5 - $rating ) ),\n                esc_url( get_edit_post_link( $review->comment_post_ID ) ),\n                esc_html( get_the_title( $review->comment_post_ID ) ),\n                '0' === $review->comment_approved ? ' <strong style=\"color:#d63638\">' . esc_html( '(pending)' ) . '</strong>' : '',\n                esc_html( wp_trim_words( $review->comment_content, 18 ) ),\n                esc_html( $review->comment_author )\n            );\n        }\n\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Pending reviews are marked so you can moderate them from Products > Reviews."
        },
        {
            "id": "woo-bulk-sale-actions",
            "version": 1,
            "title": "Bulk \"Put on Sale\" and \"Remove Sale\" Actions for Products",
            "description": "Adds bulk actions to the Products list that put selected products on sale at a set percentage, or clear their sale prices, including all variations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "bulk-edit",
                "sale",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_bulk_sale_percent = 20; // Percent off the regular price.\n\nadd_filter( 'bulk_actions-edit-product', function ( $actions ) use ( $wpide_bulk_sale_percent ) {\n    $actions['wpide_sale_on']  = sprintf( 'Put on sale (-%d%%)', $wpide_bulk_sale_percent );\n    $actions['wpide_sale_off'] = 'Remove sale price';\n\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-edit-product', function ( $redirect, $action, $ids ) use ( $wpide_bulk_sale_percent ) {\n    if ( ! in_array( $action, array( 'wpide_sale_on', 'wpide_sale_off' ), true ) ) {\n        return $redirect;\n    }\n\n    $changed = 0;\n\n    foreach ( array_map( 'absint', (array) $ids ) as $id ) {\n        $product = wc_get_product( $id );\n\n        if ( ! $product || ! current_user_can( 'edit_product', $id ) ) {\n            continue;\n        }\n\n        $targets = $product->is_type( 'variable' ) ? array_filter( array_map( 'wc_get_product', $product->get_children() ) ) : array( $product );\n\n        foreach ( $targets as $target ) {\n            $regular = (float) $target->get_regular_price( 'edit' );\n\n            if ( 'wpide_sale_on' === $action && $regular > 0 ) {\n                $target->set_sale_price( (string) round( $regular * ( 100 - $wpide_bulk_sale_percent ) / 100, wc_get_price_decimals() ) );\n            } elseif ( 'wpide_sale_off' === $action ) {\n                $target->set_sale_price( '' );\n                $target->set_date_on_sale_from( null );\n                $target->set_date_on_sale_to( null );\n            }\n\n            $target->save();\n        }\n\n        if ( $product->is_type( 'variable' ) ) {\n            WC_Product_Variable::sync( $id );\n        }\n\n        $changed++;\n    }\n\n    return add_query_arg( 'wpide_sale_updated', $changed, $redirect );\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_sale_updated'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>%s</p></div>', esc_html( sprintf( 'Sale prices updated on %d product(s).', absint( $_GET['wpide_sale_updated'] ) ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set $wpide_bulk_sale_percent before use; sale prices are rounded to your store's decimals."
        },
        {
            "id": "woo-product-data-health",
            "version": 1,
            "title": "Product Data Health Report",
            "description": "Adds a report under Products > Data health listing published products that are missing an image, SKU, price, short description or category, with edit links.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "products",
                "data-quality",
                "reports"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_submenu_page( 'edit.php?post_type=product', 'Product data health', 'Data health', 'edit_products', 'wpide-product-health', function () {\n        global $wpdb;\n\n        $uncategorized = (int) get_option( 'default_product_cat' );\n        $checks        = array(\n            'No featured image'     => \"SELECT p.ID FROM {$wpdb->posts} p LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_thumbnail_id' WHERE p.post_type = 'product' AND p.post_status = 'publish' AND ( m.meta_value IS NULL OR m.meta_value = '' OR m.meta_value = '0' ) LIMIT 100\",\n            'No SKU'                => \"SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->wc_product_meta_lookup} l ON l.product_id = p.ID WHERE p.post_type = 'product' AND p.post_status = 'publish' AND ( l.sku IS NULL OR l.sku = '' ) LIMIT 100\",\n            'No price'              => \"SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->wc_product_meta_lookup} l ON l.product_id = p.ID WHERE p.post_type = 'product' AND p.post_status = 'publish' AND l.max_price IS NULL LIMIT 100\",\n            'No short description'  => \"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'product' AND post_status = 'publish' AND post_excerpt = '' LIMIT 100\",\n            'Only in Uncategorized' => $uncategorized ? $wpdb->prepare( \"SELECT p.ID FROM {$wpdb->posts} p INNER JOIN {$wpdb->term_relationships} r ON r.object_id = p.ID INNER JOIN {$wpdb->term_taxonomy} t ON t.term_taxonomy_id = r.term_taxonomy_id AND t.taxonomy = 'product_cat' WHERE p.post_type = 'product' AND p.post_status = 'publish' GROUP BY p.ID HAVING COUNT(*) = 1 AND MAX(t.term_id) = %d LIMIT 100\", $uncategorized ) : '',\n        );\n\n        echo '<div class=\"wrap\"><h1>' . esc_html( 'Product data health' ) . '</h1>';\n\n        foreach ( $checks as $label => $sql ) {\n            $ids = $sql ? array_map( 'intval', $wpdb->get_col( $sql ) ) : array(); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- Static queries.\n\n            printf( '<h2>%1$s <span class=\"count\">(%2$s)</span></h2>', esc_html( $label ), esc_html( count( $ids ) >= 100 ? '100+' : (string) count( $ids ) ) );\n\n            if ( ! $ids ) {\n                echo '<p>' . esc_html( 'All good.' ) . '</p>';\n                continue;\n            }\n\n            _prime_post_caches( $ids, false, false );\n            echo '<ul style=\"columns:2;max-width:900px\">';\n\n            foreach ( $ids as $id ) {\n                printf( '<li><a href=\"%1$s\">%2$s</a></li>', esc_url( get_edit_post_link( $id ) ), esc_html( get_the_title( $id ) ) );\n            }\n\n            echo '</ul>';\n        }\n\n        echo '</div>';\n    } );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Lists up to 100 products per problem; variations are checked through their parent product."
        },
        {
            "id": "woo-require-product-data-to-publish",
            "version": 1,
            "title": "Require an Image, SKU and Price Before Publishing Products",
            "description": "Keeps a product as a draft, with a notice explaining what is missing, when someone tries to publish it without a featured image, SKU or price.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "products",
                "data-quality",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_publish_requires = array( 'image', 'sku', 'price' ); // Checks to run.\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) use ( $wpide_publish_requires ) {\n    if ( 'publish' !== $product->get_status() ) {\n        return;\n    }\n\n    $missing = array();\n\n    // phpcs:disable WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( in_array( 'image', $wpide_publish_requires, true ) && ! $product->get_image_id() && empty( $_POST['_thumbnail_id'] ) ) {\n        $missing[] = 'a featured image';\n    }\n    // phpcs:enable\n\n    if ( in_array( 'sku', $wpide_publish_requires, true ) && '' === (string) $product->get_sku() ) {\n        $missing[] = 'a SKU';\n    }\n\n    if ( in_array( 'price', $wpide_publish_requires, true ) && ! $product->is_type( array( 'variable', 'grouped', 'external' ) ) && '' === (string) $product->get_regular_price() ) {\n        $missing[] = 'a regular price';\n    }\n\n    if ( in_array( 'price', $wpide_publish_requires, true ) && $product->is_type( 'variable' ) && ! $product->get_variation_price( 'max' ) ) {\n        $missing[] = 'a priced variation';\n    }\n\n    if ( $missing ) {\n        $product->set_status( 'draft' );\n        set_transient( 'wpide_publish_blocked_' . get_current_user_id(), $missing, 60 );\n    }\n}, 99 );\n\nadd_action( 'admin_notices', function () {\n    $missing = get_transient( 'wpide_publish_blocked_' . get_current_user_id() );\n\n    if ( $missing ) {\n        delete_transient( 'wpide_publish_blocked_' . get_current_user_id() );\n        printf( '<div class=\"notice notice-error\"><p>%s</p></div>', esc_html( 'The product was saved as a draft because it is missing ' . implode( ', ', $missing ) . '. Add them and publish again.' ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Remove checks you do not need from $wpide_publish_requires; variable products need at least one priced variation."
        },
        {
            "id": "woo-products-on-sale-filter",
            "version": 1,
            "title": "\"On Sale\" Filter in the Admin Products List",
            "description": "Adds a dropdown to the admin Products list to show only products that are currently on sale, or only those that are not.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "products",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'restrict_manage_posts', function ( $post_type ) {\n    if ( 'product' !== $post_type ) {\n        return;\n    }\n\n    $current = isset( $_GET['wpide_on_sale'] ) ? sanitize_key( wp_unslash( $_GET['wpide_on_sale'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<label for=\"wpide-on-sale\" class=\"screen-reader-text\">' . esc_html( 'Filter by sale' ) . '</label>';\n    printf(\n        '<select name=\"wpide_on_sale\" id=\"wpide-on-sale\"><option value=\"\">%1$s</option><option value=\"yes\" %2$s>%3$s</option><option value=\"no\" %4$s>%5$s</option></select>',\n        esc_html( 'Sale status' ),\n        selected( $current, 'yes', false ),\n        esc_html( 'On sale' ),\n        selected( $current, 'no', false ),\n        esc_html( 'Not on sale' )\n    );\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || 'product' !== $query->get( 'post_type' ) || empty( $_GET['wpide_on_sale'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $ids = wc_get_product_ids_on_sale();\n    $ids = array_values( array_unique( array_filter( array_map( 'wp_get_post_parent_id', $ids ) ) + $ids ) );\n\n    if ( 'yes' === $_GET['wpide_on_sale'] ) { // phpcs:ignore WordPress.Security.NonceVerification\n        $query->set( 'post__in', $ids ? $ids : array( 0 ) );\n    } else {\n        $query->set( 'post__not_in', $ids );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's cached list of products on sale, so filtering is fast."
        },
        {
            "id": "woo-product-cost-and-margin",
            "version": 1,
            "title": "Cost Price Field and Margin Column for Products",
            "description": "Adds a \"Cost price\" field to simple products and variations and a Margin column to the Products list showing profit per sale and margin percent.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "products",
                "profit",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_product_options_pricing', function () {\n    global $product_object;\n\n    woocommerce_wp_text_input( array(\n        'id'        => '_wpide_cost',\n        'label'     => 'Cost price (' . get_woocommerce_currency_symbol() . ')',\n        'data_type' => 'price',\n        'value'     => $product_object instanceof WC_Product ? $product_object->get_meta( '_wpide_cost' ) : '',\n    ) );\n} );\n\nadd_action( 'woocommerce_variation_options_pricing', function ( $loop, $data, $variation ) {\n    woocommerce_wp_text_input( array(\n        'id'            => '_wpide_cost[' . $loop . ']',\n        'label'         => 'Cost price (' . get_woocommerce_currency_symbol() . ')',\n        'data_type'     => 'price',\n        'wrapper_class' => 'form-row form-row-full',\n        'value'         => get_post_meta( $variation->ID, '_wpide_cost', true ),\n    ) );\n}, 10, 3 );\n\nadd_action( 'woocommerce_admin_process_product_object', function ( $product ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the product save nonce.\n    if ( isset( $_POST['_wpide_cost'] ) && ! is_array( $_POST['_wpide_cost'] ) ) {\n        $product->update_meta_data( '_wpide_cost', wc_format_decimal( wp_unslash( $_POST['_wpide_cost'] ) ) );\n    }\n} );\n\nadd_action( 'woocommerce_save_product_variation', function ( $variation_id, $i ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- WooCommerce verifies the variations save nonce.\n    if ( isset( $_POST['_wpide_cost'][ $i ] ) && current_user_can( 'edit_product', $variation_id ) ) {\n        update_post_meta( $variation_id, '_wpide_cost', wc_format_decimal( wp_unslash( $_POST['_wpide_cost'][ $i ] ) ) );\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_edit-product_columns', function ( $columns ) {\n    $new = array();\n\n    foreach ( $columns as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'price' === $key ) {\n            $new['wpide_margin'] = 'Margin';\n        }\n    }\n\n    return $new;\n}, 20 );\n\nadd_action( 'manage_product_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_margin' !== $column ) {\n        return;\n    }\n\n    $product = wc_get_product( $post_id );\n    $target  = $product && $product->is_type( 'variable' ) ? wc_get_product( current( $product->get_visible_children() ) ) : $product;\n    $cost    = $target ? (float) $target->get_meta( '_wpide_cost' ) : 0;\n    $price   = $target && '' !== $target->get_price() ? (float) wc_get_price_excluding_tax( $target ) : 0;\n\n    if ( $cost <= 0 || $price <= 0 ) {\n        echo '–';\n        return;\n    }\n\n    $profit = $price - $cost;\n    printf( '<span style=\"color:%1$s\">%2$s (%3$s%%)</span>', esc_attr( $profit >= 0 ? '#008a20' : '#d63638' ), wp_kses_post( wc_price( $profit ) ), esc_html( number_format_i18n( $profit / $price * 100, 1 ) ) );\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Cost prices are never shown to customers, and the margin uses the current price (sale price when on sale) excluding tax."
        },
        {
            "id": "woo-rest-product-custom-meta",
            "version": 1,
            "title": "Expose Custom Product Fields in the WooCommerce REST API",
            "description": "Adds the product meta fields you list to WooCommerce REST API product responses as a tidy \"custom_fields\" object, and lets API clients update them.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "rest-api",
                "products",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rest_product_meta = array( '_wpide_subtitle', '_wpide_cost', '_wpide_unit_label' ); // Meta keys to expose.\n\nadd_filter( 'woocommerce_rest_prepare_product_object', function ( $response, $product ) use ( $wpide_rest_product_meta ) {\n    $fields = array();\n\n    foreach ( $wpide_rest_product_meta as $key ) {\n        $fields[ ltrim( $key, '_' ) ] = $product->get_meta( $key );\n    }\n\n    $response->data['custom_fields'] = $fields;\n\n    return $response;\n}, 10, 2 );\n\nadd_action( 'woocommerce_rest_insert_product_object', function ( $product, $request ) use ( $wpide_rest_product_meta ) {\n    $incoming = $request->get_param( 'custom_fields' );\n\n    if ( ! is_array( $incoming ) ) {\n        return;\n    }\n\n    foreach ( $wpide_rest_product_meta as $key ) {\n        $name = ltrim( $key, '_' );\n\n        if ( array_key_exists( $name, $incoming ) && is_scalar( $incoming[ $name ] ) ) {\n            $product->update_meta_data( $key, sanitize_text_field( (string) $incoming[ $name ] ) );\n        }\n    }\n\n    $product->save_meta_data();\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only the keys you list are exposed or writable, and API clients still need WooCommerce API keys with the right access."
        },
        {
            "id": "woo-rest-order-extra-data",
            "version": 1,
            "title": "Add Invoice, Tracking and Delivery Data to Order API Responses",
            "description": "Includes invoice numbers, tracking details, delivery dates and other order fields you list in WooCommerce REST API order responses, for accounting and shipping integrations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "rest-api",
                "orders",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rest_order_meta = array( // Response field => order meta key.\n    'invoice_number' => '_wpide_invoice_number',\n    'carrier'        => '_wpide_carrier',\n    'tracking'       => '_wpide_tracking',\n    'delivery_date'  => '_wpide_delivery_date',\n    'po_number'      => '_wpide_po_number',\n);\n\nadd_filter( 'woocommerce_rest_prepare_shop_order_object', function ( $response, $order ) use ( $wpide_rest_order_meta ) {\n    $extra = array();\n\n    foreach ( $wpide_rest_order_meta as $field => $key ) {\n        $extra[ $field ] = $order->get_meta( $key );\n    }\n\n    $response->data['wpide'] = $extra;\n\n    return $response;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Read-only; the default keys match other WPIDE snippets (invoice numbers, tracking, delivery date) and you can add your own."
        },
        {
            "id": "woo-webhook-longer-timeout",
            "version": 1,
            "title": "Longer Timeout for WooCommerce Webhooks",
            "description": "Gives the services that receive your WooCommerce webhooks more time to answer, which stops slow endpoints such as Zapier or ERPs from being logged as failed.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "webhooks",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_webhook_timeout = 20; // Seconds.\n\nadd_filter( 'woocommerce_webhook_http_args', function ( $args ) use ( $wpide_webhook_timeout ) {\n    $args['timeout'] = $wpide_webhook_timeout;\n\n    return $args;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "WooCommerce's default is 5 seconds, and webhooks are sent in the background so customers never wait."
        },
        {
            "id": "woo-webhook-failure-limit",
            "version": 1,
            "title": "Stop WooCommerce Disabling Webhooks After 5 Failures",
            "description": "Raises the number of failed deliveries before WooCommerce switches a webhook off, so a short outage at the receiving service does not silently stop your integrations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "webhooks",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_webhook_failures = 50; // Failures allowed before the webhook is disabled.\n\nadd_filter( 'woocommerce_max_webhook_delivery_failures', function () use ( $wpide_webhook_failures ) {\n    return $wpide_webhook_failures;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Failed deliveries are still logged under WooCommerce > Status > Logs."
        },
        {
            "id": "woo-webhook-status-changed-topic",
            "version": 1,
            "title": "\"Order Status Changed\" Webhook Topic",
            "description": "Adds an \"Order status changed\" topic to WooCommerce webhooks, so your integration is told about every status change instead of every order edit.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "webhooks",
                "orders",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_webhook_topic_hooks', function ( $topics ) {\n    $topics['order.status_changed'] = array( 'woocommerce_order_status_changed' );\n\n    return $topics;\n} );\n\nadd_filter( 'woocommerce_valid_webhook_events', function ( $events ) {\n    $events[] = 'status_changed';\n\n    return $events;\n} );\n\nadd_filter( 'woocommerce_webhook_topics', function ( $topics ) {\n    $topics['order.status_changed'] = 'Order status changed';\n\n    return $topics;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Choose \"Order status changed\" when creating a webhook under WooCommerce > Settings > Advanced > Webhooks; the payload is the full order."
        },
        {
            "id": "woo-store-api-rate-limit",
            "version": 1,
            "title": "Rate Limit the Store API Against Card Testing",
            "description": "Turns on WooCommerce's built-in rate limiting for the Store API used by the block cart and checkout, slowing down bots that test stolen cards.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "security",
                "checkout",
                "fraud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_store_api_rate_limit_options', function ( $options ) {\n    return array_merge( (array) $options, array(\n        'enabled'       => true,\n        'proxy_support' => false, // True behind Cloudflare or a load balancer.\n        'limit'         => 25,    // Requests allowed...\n        'seconds'       => 10,    // ...per this many seconds.\n    ) );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Allows 25 requests per 10 seconds per visitor; set 'proxy_support' to true only behind a proxy or CDN that passes the visitor IP."
        },
        {
            "id": "woo-remove-generator-tag",
            "version": 1,
            "title": "Remove the WooCommerce Version Meta Tag",
            "description": "Stops WooCommerce adding its version number to your page source, so bots scanning for outdated stores learn a little less.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "security",
                "head"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_filter( 'get_the_generator_html', 'wc_generator_tag', 10 );\n    remove_filter( 'get_the_generator_xhtml', 'wc_generator_tag', 10 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Purely cosmetic for security; keep WooCommerce updated as well."
        },
        {
            "id": "woo-remove-legacy-widgets",
            "version": 1,
            "title": "Remove Unused WooCommerce Widgets",
            "description": "Hides the classic WooCommerce widgets you do not use, such as price filter or recent reviews, from the widget screens to keep them tidy.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "widgets",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wc_widgets = array( // Widget classes to remove.\n    'WC_Widget_Recent_Reviews',\n    'WC_Widget_Recently_Viewed',\n    'WC_Widget_Top_Rated_Products',\n    'WC_Widget_Rating_Filter',\n    'WC_Widget_Layered_Nav_Filters',\n    'WC_Widget_Price_Filter',\n    'WC_Widget_Product_Tag_Cloud',\n);\n\nadd_action( 'widgets_init', function () use ( $wpide_wc_widgets ) {\n    foreach ( $wpide_wc_widgets as $widget ) {\n        if ( class_exists( $widget ) ) {\n            unregister_widget( $widget );\n        }\n    }\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Remove class names from the list for widgets you want to keep; blocks are not affected."
        },
        {
            "id": "woo-action-scheduler-retention",
            "version": 1,
            "title": "Keep Completed Background Tasks for 7 Days",
            "description": "Shortens how long WooCommerce's Action Scheduler keeps finished tasks from 31 days to 7, which keeps its database tables small on busy stores.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "performance",
                "database",
                "action-scheduler"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_as_retention_days = 7; // Days to keep completed actions.\n\nadd_filter( 'action_scheduler_retention_period', function () use ( $wpide_as_retention_days ) {\n    return $wpide_as_retention_days * DAY_IN_SECONDS;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only completed and cancelled actions are cleared, gradually by Action Scheduler itself, and pending tasks are never touched."
        },
        {
            "id": "woo-disable-order-attribution",
            "version": 1,
            "title": "Turn Off Order Attribution Tracking",
            "description": "Stops WooCommerce's order attribution script from setting tracking cookies and recording the visit source of each order, useful under strict cookie policies.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "privacy",
                "cookies",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wc_order_attribution_allow_tracking', '__return_false' );\n\nadd_action( 'wp_enqueue_scripts', function () {\n    wp_dequeue_script( 'wc-order-attribution' );\n    wp_dequeue_script( 'sourcebuster-js' );\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Origin details stop appearing on new orders; the same switch also lives under WooCommerce > Settings > Advanced > Features."
        },
        {
            "id": "woo-guest-cart-lifetime",
            "version": 1,
            "title": "Keep Guest Carts for a Week",
            "description": "Extends how long a guest's cart is remembered from two days to seven, so visitors who come back later still find their products waiting.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "sessions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cart_days = 7; // Days to keep guest sessions.\n\nadd_filter( 'wc_session_expiration', function () use ( $wpide_cart_days ) {\n    return $wpide_cart_days * DAY_IN_SECONDS;\n} );\n\nadd_filter( 'wc_session_expiring', function () use ( $wpide_cart_days ) {\n    return ( $wpide_cart_days * DAY_IN_SECONDS ) - HOUR_IN_SECONDS;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Longer sessions mean more rows in the woocommerce_sessions table; WooCommerce cleans expired ones daily."
        },
        {
            "id": "woo-native-country-selects",
            "version": 1,
            "title": "Use Plain Dropdowns Instead of Select2 at Checkout",
            "description": "Removes the Select2 search boxes WooCommerce puts on the country and state fields, using the browser's native dropdowns that are lighter and easier on phones.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "performance",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! function_exists( 'is_checkout' ) || ! ( is_checkout() || is_account_page() || is_cart() ) ) {\n        return;\n    }\n\n    wp_dequeue_style( 'select2' );\n    wp_dequeue_script( 'selectWoo' );\n    wp_dequeue_script( 'select2' );\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Country and state fields still update each other, and native dropdowns work better with screen readers and phone keyboards."
        },
        {
            "id": "woo-users-orders-columns",
            "version": 1,
            "title": "Orders and Total Spent Columns in the Users List",
            "description": "Adds \"Orders\" and \"Total spent\" columns to Users > All Users, so you can spot your best customers and accounts that never ordered.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "users",
                "customers",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_orders'] = 'Orders';\n    $columns['wpide_spent']  = 'Total spent';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $value, $column, $user_id ) {\n    if ( 'wpide_orders' === $column ) {\n        $count = wc_get_customer_order_count( $user_id );\n\n        return $count ? sprintf( '<a href=\"%1$s\">%2$d</a>', esc_url( admin_url( 'admin.php?page=wc-orders&_customer_user=' . $user_id ) ), $count ) : '0';\n    }\n\n    if ( 'wpide_spent' === $column ) {\n        return wp_kses_post( wc_price( wc_get_customer_total_spent( $user_id ) ) );\n    }\n\n    return $value;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's cached customer totals, which update after each order."
        },
        {
            "id": "woo-admin-bar-store-links",
            "version": 1,
            "title": "Store Shortcuts in the Admin Bar",
            "description": "Adds a \"Store\" menu to the admin toolbar with quick links to orders, products, coupons, customers and settings, available on the front end too.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin-bar",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! current_user_can( 'manage_woocommerce' ) || ! function_exists( 'wc_get_container' ) ) {\n        return;\n    }\n\n    $hpos  = class_exists( '\\Automattic\\WooCommerce\\Utilities\\OrderUtil' ) && \\Automattic\\WooCommerce\\Utilities\\OrderUtil::custom_orders_table_usage_is_enabled();\n    $links = array(\n        'orders'    => array( 'Orders', $hpos ? admin_url( 'admin.php?page=wc-orders' ) : admin_url( 'edit.php?post_type=shop_order' ) ),\n        'products'  => array( 'Products', admin_url( 'edit.php?post_type=product' ) ),\n        'add'       => array( 'Add product', admin_url( 'post-new.php?post_type=product' ) ),\n        'coupons'   => array( 'Coupons', admin_url( 'edit.php?post_type=shop_coupon' ) ),\n        'customers' => array( 'Customers', admin_url( 'users.php?role=customer' ) ),\n        'settings'  => array( 'Settings', admin_url( 'admin.php?page=wc-settings' ) ),\n    );\n\n    $bar->add_node( array( 'id' => 'wpide-store', 'title' => '<span class=\"ab-icon dashicons dashicons-cart\" style=\"margin-top:2px\"></span>Store', 'href' => $links['orders'][1] ) );\n\n    foreach ( $links as $id => $link ) {\n        $bar->add_node( array( 'parent' => 'wpide-store', 'id' => 'wpide-store-' . $id, 'title' => esc_html( $link[0] ), 'href' => esc_url( $link[1] ) ) );\n    }\n}, 80 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only shown to users who can manage WooCommerce; edit the $links array to add your own shortcuts."
        },
        {
            "id": "woo-highlight-low-stock-products",
            "version": 1,
            "title": "Highlight Low-Stock Products in the Products List",
            "description": "Tints rows in the admin Products list for products at or below your low stock threshold, and out-of-stock products in red, so restocking needs stand out.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "admin",
                "inventory",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'post_class', function ( $classes, $class, $post_id ) {\n    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;\n\n    if ( ! $screen || 'edit-product' !== $screen->id ) {\n        return $classes;\n    }\n\n    $product = wc_get_product( $post_id );\n\n    if ( ! $product ) {\n        return $classes;\n    }\n\n    if ( ! $product->is_in_stock() ) {\n        $classes[] = 'wpide-stock-out';\n    } elseif ( $product->managing_stock() && $product->get_stock_quantity() <= wc_get_low_stock_amount( $product ) ) {\n        $classes[] = 'wpide-stock-low';\n    }\n\n    return $classes;\n}, 10, 3 );\n\nadd_action( 'admin_head-edit.php', function () {\n    if ( 'product' === get_current_screen()->post_type ) {\n        echo '<style>.wp-list-table tr.wpide-stock-low{background:#fff8e5}.wp-list-table tr.wpide-stock-out{background:#fcf0f1}</style>';\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the low stock threshold from WooCommerce > Settings > Products > Inventory, or a product's own threshold when set."
        },
        {
            "id": "woo-hourly-scheduled-sales",
            "version": 1,
            "title": "Start and End Scheduled Sales on the Hour",
            "description": "Checks scheduled sale dates every hour instead of once a day at midnight, so sales that should start or end during the day do so close to on time.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "sale",
                "schedule",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_hourly_scheduled_sales' ) ) {\n        wp_schedule_event( time() + 300, 'hourly', 'wpide_hourly_scheduled_sales' );\n    }\n} );\n\nadd_action( 'wpide_hourly_scheduled_sales', function () {\n    if ( function_exists( 'wc_scheduled_sales' ) ) {\n        wc_scheduled_sales();\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "WooCommerce sale dates are whole days, so this mainly helps sites where the midnight check runs late."
        },
        {
            "id": "woo-gallery-image-as-featured",
            "version": 1,
            "title": "Use the First Gallery Image When No Featured Image Is Set",
            "description": "When a product is saved without a featured image but has gallery images, the first gallery image becomes its featured image, so it never shows a placeholder.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "products",
                "images",
                "data-quality"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_product_object_save', function ( $product ) {\n    if ( $product->get_image_id() || $product->is_type( 'variation' ) ) {\n        return;\n    }\n\n    $gallery = $product->get_gallery_image_ids();\n\n    if ( $gallery ) {\n        $product->set_image_id( (int) reset( $gallery ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs whenever a product is saved, whether in the admin, through the REST API or by an import, and leaves the gallery unchanged."
        },
        {
            "id": "woo-noindex-filtered-shop",
            "version": 1,
            "title": "Keep Filtered and Sorted Shop Pages Out of Search Engines",
            "description": "Adds noindex to shop pages that have sorting, price or attribute filters in the URL, so search engines index your real category pages instead of thousands of variations.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "seo",
                "robots",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( ! function_exists( 'is_woocommerce' ) || ! ( is_shop() || is_product_taxonomy() ) ) {\n        return $robots;\n    }\n\n    foreach ( array_keys( $_GET ) as $key ) { // phpcs:ignore WordPress.Security.NonceVerification\n        if ( in_array( $key, array( 'orderby', 'min_price', 'max_price', 'rating_filter', 'query_type' ), true ) || 0 === strpos( (string) $key, 'filter_' ) ) {\n            $robots['noindex'] = true;\n            $robots['follow']  = true;\n            unset( $robots['index'] );\n            break;\n        }\n    }\n\n    return $robots;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Links on those pages are still followed, and normal shop, category and paginated pages are not affected."
        },
        {
            "id": "woo-remove-dashboard-widgets",
            "version": 1,
            "title": "Remove WooCommerce Widgets From the Dashboard",
            "description": "Removes WooCommerce's \"Status\" and \"Recent reviews\" boxes from the WordPress dashboard, which also skips the queries they run on every dashboard visit.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "dashboard",
                "performance",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'woocommerce_dashboard_status', 'dashboard', 'normal' );\n    remove_meta_box( 'woocommerce_dashboard_recent_reviews', 'dashboard', 'normal' );\n    remove_meta_box( 'wc_admin_dashboard_setup', 'dashboard', 'normal' );\n}, 40 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Reports remain available under WooCommerce > Reports or Analytics."
        },
        {
            "id": "woo-minimum-items-per-order",
            "version": 1,
            "title": "Minimum Number of Items per Order",
            "description": "Requires at least a set number of items in the cart before checkout, for shops that cannot ship single low-cost items profitably.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "minimum",
                "quantity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_min_items = 3; // Minimum items in the cart.\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_min_items ) {\n    $count = WC()->cart->get_cart_contents_count();\n\n    if ( $count > 0 && $count < $wpide_min_items ) {\n        wc_add_notice( sprintf( 'Orders need at least %1$d items. Please add %2$d more to check out.', $wpide_min_items, $wpide_min_items - $count ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Counts the total quantity of all products; combine it with a minimum order amount if you need both."
        },
        {
            "id": "woo-category-max-items",
            "version": 1,
            "title": "Maximum Items per Order From a Category",
            "description": "Caps how many items from a category one order can contain, useful for limited drops, rationed products or age-restricted goods.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "limits",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_max = array(\n    'categories' => array( 'limited-edition' ), // Category slugs.\n    'maximum'    => 2,                          // Items allowed per order.\n);\n\nadd_action( 'woocommerce_check_cart_items', function () use ( $wpide_cat_max ) {\n    $count = 0;\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        if ( has_term( $wpide_cat_max['categories'], 'product_cat', $item['product_id'] ) ) {\n            $count += (int) $item['quantity'];\n        }\n    }\n\n    if ( $count > $wpide_cat_max['maximum'] ) {\n        wc_add_notice( sprintf( 'You can order up to %1$d limited edition items per order. Please remove %2$d.', $wpide_cat_max['maximum'], $count - $wpide_cat_max['maximum'] ), 'error' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "All products in the listed categories count together, and other products are not limited."
        },
        {
            "id": "woo-auto-apply-coupon",
            "version": 1,
            "title": "Apply a Coupon Automatically Above a Cart Total",
            "description": "Adds an existing coupon to the cart as soon as the subtotal reaches an amount, and removes it again if the cart drops below, so shoppers never need to type a code.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "discounts",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_auto_coupon = array(\n    'code'    => 'spend100', // Existing coupon code.\n    'minimum' => 100,        // Cart subtotal that triggers it.\n);\n\nadd_action( 'woocommerce_before_calculate_totals', function ( $cart ) use ( $wpide_auto_coupon ) {\n    static $running = false;\n\n    if ( $running || ( is_admin() && ! wp_doing_ajax() ) || ! $cart instanceof WC_Cart ) {\n        return;\n    }\n\n    $running  = true;\n    $code     = wc_format_coupon_code( $wpide_auto_coupon['code'] );\n    $subtotal = 0;\n\n    foreach ( $cart->get_cart() as $item ) {\n        $subtotal += (float) $item['data']->get_price() * (int) $item['quantity'];\n    }\n\n    if ( $subtotal >= $wpide_auto_coupon['minimum'] && ! $cart->has_discount( $code ) && wc_get_coupon_id_by_code( $code ) ) {\n        $cart->apply_coupon( $code );\n    } elseif ( $subtotal < $wpide_auto_coupon['minimum'] && $cart->has_discount( $code ) ) {\n        $cart->remove_coupon( $code );\n    }\n\n    $running = false;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Create the coupon in Marketing > Coupons first; its own usage limits and expiry still apply."
        },
        {
            "id": "woo-coupon-for-roles",
            "version": 1,
            "title": "Restrict Coupon Codes to Certain Roles",
            "description": "Makes chosen coupon codes work only for customers with specific roles, such as staff discount codes or trade-only promotions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "roles",
                "restrictions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_role_coupons = array( // Coupon code => allowed roles.\n    'staff20' => array( 'shop_manager', 'administrator', 'editor' ),\n    'trade10' => array( 'wholesale_customer' ),\n);\n\nadd_filter( 'woocommerce_coupon_is_valid', function ( $valid, $coupon ) use ( $wpide_role_coupons ) {\n    $code = wc_format_coupon_code( $coupon->get_code() );\n\n    foreach ( $wpide_role_coupons as $restricted => $roles ) {\n        if ( wc_format_coupon_code( $restricted ) !== $code ) {\n            continue;\n        }\n\n        if ( ! is_user_logged_in() || ! array_intersect( $roles, (array) wp_get_current_user()->roles ) ) {\n            throw new Exception( esc_html( 'Sorry, this coupon is not available for your account.' ), 100 );\n        }\n    }\n\n    return $valid;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Codes are matched case-insensitively, and guests can never use these coupons."
        },
        {
            "id": "woo-coupon-first-order-only",
            "version": 1,
            "title": "Coupon Codes for First Orders Only",
            "description": "Limits chosen coupon codes, such as a welcome discount, to customers who have never placed a paid order, checked by account and billing email.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "new-customers",
                "restrictions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_welcome_coupons = array( 'welcome10', 'firstorder' ); // Coupon codes.\n\nadd_filter( 'woocommerce_coupon_is_valid', function ( $valid, $coupon ) use ( $wpide_welcome_coupons ) {\n    if ( ! in_array( wc_format_coupon_code( $coupon->get_code() ), array_map( 'wc_format_coupon_code', $wpide_welcome_coupons ), true ) ) {\n        return $valid;\n    }\n\n    $email = is_user_logged_in() ? wp_get_current_user()->user_email : ( WC()->customer ? WC()->customer->get_billing_email() : '' );\n    $query = array( 'status' => wc_get_is_paid_statuses(), 'limit' => 1, 'return' => 'ids' );\n    $found = array();\n\n    if ( is_user_logged_in() ) {\n        $found = wc_get_orders( $query + array( 'customer_id' => get_current_user_id() ) );\n    }\n\n    if ( ! $found && is_email( $email ) ) {\n        $found = wc_get_orders( $query + array( 'billing_email' => $email ) );\n    }\n\n    if ( $found ) {\n        throw new Exception( esc_html( 'This welcome coupon is only valid on your first order.' ), 100 );\n    }\n\n    return $valid;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Guests are checked by the billing email entered at checkout, so the code may be accepted in the cart and refused at checkout."
        },
        {
            "id": "woo-shipping-phone-field",
            "version": 1,
            "title": "Phone Number Field for the Shipping Address",
            "description": "Adds a phone field to the classic checkout's shipping address, so couriers can reach the recipient when an order is sent to someone else.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "shipping",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_shipping_phone_required = false; // Make the recipient phone required.\n\nadd_filter( 'woocommerce_shipping_fields', function ( $fields ) use ( $wpide_shipping_phone_required ) {\n    if ( ! isset( $fields['shipping_phone'] ) ) {\n        $fields['shipping_phone'] = array(\n            'label'        => 'Recipient phone',\n            'type'         => 'tel',\n            'required'     => $wpide_shipping_phone_required,\n            'class'        => array( 'form-row-wide' ),\n            'validate'     => array( 'phone' ),\n            'autocomplete' => 'shipping tel',\n            'priority'     => 95,\n        );\n    }\n\n    return $fields;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's own shipping_phone order field, so it appears on the order screen and in exports automatically."
        },
        {
            "id": "woo-business-purchase-toggle",
            "version": 1,
            "title": "\"Buying for a Business?\" Checkbox at Checkout",
            "description": "Hides the Company field behind a \"Buying for a business?\" checkbox on the classic checkout, keeping the form short for consumers and requiring a company name when ticked.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "b2b",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_billing_fields', function ( $fields ) {\n    $fields['billing_is_business'] = array(\n        'type'     => 'checkbox',\n        'label'    => 'Buying for a business?',\n        'class'    => array( 'form-row-wide' ),\n        'priority' => 25,\n    );\n\n    return $fields;\n}, 30 );\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) {\n    if ( ! empty( $data['billing_is_business'] ) && '' === trim( (string) ( $data['billing_company'] ?? '' ) ) ) {\n        $errors->add( 'validation', 'Please enter your company name.' );\n    }\n}, 10, 2 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_checkout' ) || ! is_checkout() || is_wc_endpoint_url() ) {\n        return;\n    }\n    ?>\n    <script>\n    jQuery( function ( $ ) {\n        var box = $( '#billing_is_business' );\n        var rows = $( '#billing_company_field, #billing_vat_number_field' );\n        function toggle() { rows.toggle( box.is( ':checked' ) ); }\n        box.on( 'change', toggle );\n        toggle();\n    } );\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Also toggles the VAT number field from the \"EU VAT Number Field\" snippet, and the Company field must be enabled in the Customizer."
        },
        {
            "id": "woo-cart-remove-confirm",
            "version": 1,
            "title": "Ask Before Removing a Product From the Cart",
            "description": "Shows a quick \"Remove this item?\" confirmation when a shopper clicks the remove (×) link in the cart, preventing accidental removals on phones.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "cart",
                "javascript",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_woocommerce' ) || is_admin() ) {\n        return;\n    }\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var link = event.target.closest( 'a.remove[href*=\"remove_item\"], a.remove_from_cart_button' );\n        if ( link && ! window.confirm( <?php echo wp_json_encode( 'Remove this item from your cart?' ); ?> ) ) {\n            event.preventDefault();\n            event.stopImmediatePropagation();\n        }\n    }, true );\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works on the classic cart page and the mini cart widget."
        },
        {
            "id": "woo-quantity-dropdown",
            "version": 1,
            "title": "Quantity Dropdown Instead of a Number Box",
            "description": "Replaces the quantity number box with a simple dropdown (1, 2, 3...) on product and cart pages, which is faster to use on phones.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "quantity",
                "javascript",
                "mobile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_qty_options = 20; // Highest option when the product has no maximum.\n\nadd_action( 'wp_footer', function () use ( $wpide_qty_options ) {\n    if ( ! function_exists( 'is_product' ) || ! ( is_product() || is_cart() ) ) {\n        return;\n    }\n    ?>\n    <script>\n    ( function () {\n        var cap = <?php echo (int) $wpide_qty_options; ?>;\n        function convert( root ) {\n            root.querySelectorAll( 'input.qty[type=\"number\"]:not([data-wpide])' ).forEach( function ( input ) {\n                var min = parseFloat( input.min ) || 1, step = parseFloat( input.step ) || 1;\n                var max = parseFloat( input.max ) || 0;\n                if ( input.type === 'hidden' || ( max && max <= min ) ) { return; }\n                var limit = max ? Math.min( max, min + step * ( cap - 1 ) ) : min + step * ( cap - 1 );\n                var current = parseFloat( input.value ) || min;\n                var select = document.createElement( 'select' );\n                select.name = input.name;\n                select.id = input.id;\n                select.className = input.className;\n                select.setAttribute( 'aria-label', input.getAttribute( 'aria-label' ) || 'Quantity' );\n                for ( var q = min; q <= limit; q += step ) { select.add( new Option( q, q, false, q === current ) ); }\n                if ( current > limit ) { select.add( new Option( current, current, true, true ) ); }\n                input.setAttribute( 'data-wpide', '1' );\n                select.addEventListener( 'change', function () { select.dispatchEvent( new Event( 'input', { bubbles: true } ) ); } );\n                input.parentNode.replaceChild( select, input );\n            } );\n        }\n        convert( document );\n        if ( window.jQuery ) { jQuery( document.body ).on( 'updated_cart_totals', function () { convert( document ); } ); }\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Respects each product's minimum, maximum and step, offering up to $wpide_qty_options choices when there is no maximum."
        },
        {
            "id": "woo-stock-progress-bar",
            "version": 1,
            "title": "Stock Level Progress Bar on Product Pages",
            "description": "Shows a bar with how much of a product's stock is left, such as \"Hurry, only 4 left\", when stock is running low, to create honest urgency.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "stock",
                "urgency",
                "product-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_stock_bar = array(\n    'show_below' => 15, // Show the bar at or below this stock level.\n    'full_at'    => 30, // Stock level that counts as a full bar.\n);\n\nadd_action( 'woocommerce_single_product_summary', function () use ( $wpide_stock_bar ) {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! $product->managing_stock() || ! $product->is_in_stock() ) {\n        return;\n    }\n\n    $stock = (int) $product->get_stock_quantity();\n\n    if ( $stock < 1 || $stock > $wpide_stock_bar['show_below'] ) {\n        return;\n    }\n\n    $percent = max( 5, min( 100, round( $stock / $wpide_stock_bar['full_at'] * 100 ) ) );\n    printf(\n        '<div class=\"wpide-stock-bar\"><p>%1$s</p><div role=\"progressbar\" aria-valuemin=\"0\" aria-valuemax=\"%2$d\" aria-valuenow=\"%3$d\" aria-label=\"%4$s\" style=\"height:8px;background:#eee;border-radius:4px;overflow:hidden\"><span style=\"display:block;height:100%%;width:%5$d%%;background:%6$s;transition:width .6s ease\"></span></div></div><style>@media (prefers-reduced-motion:reduce){.wpide-stock-bar span{transition:none!important}}</style>',\n        esc_html( sprintf( _n( 'Hurry, only %d left in stock', 'Hurry, only %d left in stock', $stock ), $stock ) ),\n        (int) $wpide_stock_bar['full_at'],\n        (int) $stock,\n        esc_attr( 'Stock remaining' ),\n        (int) $percent,\n        esc_attr( $percent < 30 ? '#d63638' : '#dba617' )\n    );\n}, 26 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Shown only for stock-managed products at or below 'show_below' units, and the animation respects reduced-motion settings."
        },
        {
            "id": "woo-variation-buttons",
            "version": 1,
            "title": "Show Variation Options as Buttons",
            "description": "Turns the variation dropdowns on product pages (size, colour and so on) into clickable buttons, keeping WooCommerce's own logic for prices, stock and images.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "variations",
                "javascript",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_product' ) || ! is_product() ) {\n        return;\n    }\n    ?>\n    <style>\n    .wpide-var-buttons{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0}\n    .wpide-var-buttons button{padding:.45em .9em;border:1px solid #c3c4c7;background:#fff;color:inherit;border-radius:4px;cursor:pointer;font:inherit;line-height:1.2}\n    .wpide-var-buttons button[aria-pressed=\"true\"]{border-color:currentColor;box-shadow:inset 0 0 0 1px currentColor;font-weight:600}\n    .wpide-var-buttons button:disabled{opacity:.4;cursor:not-allowed;text-decoration:line-through}\n    .wpide-var-buttons button:focus-visible{outline:2px solid #2271b1;outline-offset:2px}\n    .variations select.wpide-hidden{position:absolute!important;width:1px!important;height:1px!important;overflow:hidden;clip:rect(0 0 0 0)}\n    </style>\n    <script>\n    jQuery( function ( $ ) {\n        $( 'form.variations_form' ).each( function () {\n            var form = $( this );\n            function build() {\n                form.find( '.variations select' ).each( function () {\n                    var select = $( this ), wrap = select.siblings( '.wpide-var-buttons' );\n                    if ( ! wrap.length ) {\n                        wrap = $( '<div class=\"wpide-var-buttons\" role=\"group\"></div>' ).attr( 'aria-label', $( 'label[for=\"' + this.id + '\"]' ).text() );\n                        select.addClass( 'wpide-hidden' ).attr( 'tabindex', '-1' ).after( wrap );\n                    }\n                    wrap.empty();\n                    select.find( 'option' ).each( function () {\n                        if ( ! this.value ) { return; }\n                        var option = this;\n                        $( '<button type=\"button\"></button>' ).text( option.text ).attr( 'aria-pressed', select.val() === option.value ? 'true' : 'false' ).prop( 'disabled', ! option.classList.contains( 'enabled' ) && option.disabled ).on( 'click', function () {\n                            select.val( select.val() === option.value ? '' : option.value ).trigger( 'change' );\n                        } ).appendTo( wrap );\n                    } );\n                } );\n            }\n            form.on( 'woocommerce_update_variation_values reset_data', function () { setTimeout( build, 0 ); } );\n            build();\n        } );\n    } );\n    </script>\n    <?php\n}, 30 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Unavailable combinations are greyed out, and the original dropdowns stay hidden in the page so other plugins keep working."
        },
        {
            "id": "woo-loop-category-label",
            "version": 1,
            "title": "Category Name Above Product Titles in the Shop",
            "description": "Prints the product's main category in small text above its title in shop and category listings, helping shoppers scan mixed results such as search pages.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "catalog",
                "categories",
                "shop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_shop_loop_item_title', function () {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || is_product_category() ) {\n        return;\n    }\n\n    $terms = get_the_terms( $product->get_id(), 'product_cat' );\n\n    if ( $terms && ! is_wp_error( $terms ) ) {\n        printf( '<span class=\"wpide-loop-cat\" style=\"display:block;font-size:.8em;text-transform:uppercase;letter-spacing:.04em;opacity:.7\">%s</span>', esc_html( $terms[0]->name ) );\n    }\n}, 15 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the product's first category and is hidden on category pages, where every product shares it."
        },
        {
            "id": "woo-two-columns-on-phones",
            "version": 1,
            "title": "Two Products per Row on Phones",
            "description": "Shows shop and category pages in two columns on phones instead of one long single column, so shoppers see more products without scrolling.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "css",
                "mobile",
                "catalog"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-width: 768px) {\n  .woocommerce ul.products[class*=\"columns-\"] li.product,\n  .woocommerce-page ul.products[class*=\"columns-\"] li.product {\n    width: 48%;\n    float: left;\n    clear: none;\n    margin: 0 4% 1.5em 0;\n  }\n  .woocommerce ul.products[class*=\"columns-\"] li.product:nth-child(2n),\n  .woocommerce-page ul.products[class*=\"columns-\"] li.product:nth-child(2n) {\n    margin-right: 0;\n  }\n  .woocommerce ul.products[class*=\"columns-\"] li.product:nth-child(2n+1),\n  .woocommerce-page ul.products[class*=\"columns-\"] li.product:nth-child(2n+1) {\n    clear: both;\n  }\n  .woocommerce ul.products li.product .button {\n    padding: .6em .8em;\n    font-size: .85em;\n  }\n}",
            "requires": [
                "woocommerce"
            ],
            "notes": "For classic WooCommerce templates; block themes set columns in the Product Collection block settings."
        },
        {
            "id": "woo-category-description-below",
            "version": 1,
            "title": "Move Category Descriptions Below the Products",
            "description": "Shows the product category description after the product grid instead of before it, so shoppers see products first while the text still helps SEO.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "categories",
                "seo",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp', function () {\n    if ( ! function_exists( 'is_product_taxonomy' ) || ! is_product_taxonomy() ) {\n        return;\n    }\n\n    remove_action( 'woocommerce_archive_description', 'woocommerce_taxonomy_archive_description', 10 );\n\n    add_action( 'woocommerce_after_shop_loop', function () {\n        if ( ! is_paged() ) {\n            echo '<div class=\"term-description wpide-term-description-below\">';\n            woocommerce_taxonomy_archive_description();\n            echo '</div>';\n        }\n    }, 30 );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "For classic templates; the description shows on the first page of each category only, as WooCommerce does by default."
        },
        {
            "id": "woo-exact-sku-search-redirect",
            "version": 1,
            "title": "Jump Straight to a Product When Searching Its SKU",
            "description": "Sends shoppers directly to the product page when what they type in the search box is exactly a product's SKU or code.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "search",
                "sku",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_search() || is_admin() ) {\n        return;\n    }\n\n    $term = trim( get_search_query( false ) );\n\n    if ( '' === $term || strlen( $term ) > 64 ) {\n        return;\n    }\n\n    $id      = wc_get_product_id_by_sku( $term );\n    $product = $id ? wc_get_product( $id ) : null;\n\n    if ( ! $product || ( ! $product->is_visible() && ! $product->is_type( 'variation' ) ) ) {\n        return;\n    }\n\n    $url = $product->get_permalink();\n\n    if ( $url ) {\n        wp_safe_redirect( $url );\n        exit;\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Variation SKUs open the parent product with that variation preselected."
        },
        {
            "id": "woo-price-currency-code",
            "version": 1,
            "title": "Show the Currency Code Next to Prices",
            "description": "Adds the three-letter currency code after prices on the front end, such as \"$25.00 USD\", so international shoppers know which dollar or crown you mean.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "currency",
                "international"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_price_format', function ( $format ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || false !== strpos( $format, 'wpide-currency-code' ) ) {\n        return $format;\n    }\n\n    return $format . '&nbsp;<span class=\"wpide-currency-code\">' . esc_html( get_woocommerce_currency() ) . '</span>';\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Admin screens are left unchanged; remove the symbol in WooCommerce settings if you prefer the code alone."
        },
        {
            "id": "woo-lowest-price-30-days",
            "version": 1,
            "title": "\"Lowest Price in the Last 30 Days\" for Sale Products",
            "description": "Records each product's price history and shows the lowest price of the 30 days before a sale under the sale price, as the EU Omnibus rules require.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "prices",
                "sale",
                "compliance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_omnibus_days = 30;\n\nadd_action( 'woocommerce_before_product_object_save', function ( $product ) {\n    $price = $product->get_price( 'edit' );\n\n    if ( '' === $price || null === $price ) {\n        return;\n    }\n\n    $history = (array) $product->get_meta( '_wpide_price_history' );\n    $today   = wp_date( 'Y-m-d' );\n    $history = array_filter( $history, function ( $date ) {\n        return strtotime( $date ) > strtotime( '-60 days' );\n    }, ARRAY_FILTER_USE_KEY );\n\n    if ( ! isset( $history[ $today ] ) || (float) $price < (float) $history[ $today ] ) {\n        $history[ $today ] = (float) $price;\n    }\n\n    ksort( $history );\n    $product->update_meta_data( '_wpide_price_history', $history );\n} );\n\nadd_filter( 'woocommerce_get_price_html', function ( $html, $product ) use ( $wpide_omnibus_days ) {\n    if ( ! $product->is_on_sale() || $product->is_type( 'variable' ) || ! is_product() ) {\n        return $html;\n    }\n\n    $history = (array) $product->get_meta( '_wpide_price_history' );\n    $from    = $product->get_date_on_sale_from() ? $product->get_date_on_sale_from()->getTimestamp() : time();\n    $prices  = array();\n\n    foreach ( $history as $date => $price ) {\n        $time = strtotime( $date );\n\n        if ( $time < $from && $time >= $from - $wpide_omnibus_days * DAY_IN_SECONDS ) {\n            $prices[] = (float) $price;\n        }\n    }\n\n    $prices[] = (float) $product->get_regular_price();\n    $lowest   = min( array_filter( $prices ) );\n\n    return $html . sprintf( '<br><small class=\"wpide-omnibus\">%s</small>', wp_kses_post( sprintf( 'Lowest price in the %1$d days before the sale: %2$s', $wpide_omnibus_days, wc_price( wc_get_price_to_display( $product, array( 'price' => $lowest ) ) ) ) ) );\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Price history starts when you activate the snippet and is pruned after 60 days, so the note appears for sales that start after that."
        },
        {
            "id": "woo-schema-shipping-returns",
            "version": 1,
            "title": "Shipping and Return Policy in Product Structured Data",
            "description": "Adds your shipping cost, delivery time and return policy to the product structured data WooCommerce outputs, which Google can show in shopping results.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "seo",
                "schema",
                "structured-data"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_schema_policy = array(\n    'country'        => 'GB',  // Two-letter country code you ship to.\n    'shipping_cost'  => '3.95',\n    'handling_days'  => array( 0, 1 ),\n    'transit_days'   => array( 1, 3 ),\n    'return_days'    => 30,\n    'free_returns'   => true,\n);\n\nadd_filter( 'woocommerce_structured_data_product', function ( $markup, $product ) use ( $wpide_schema_policy ) {\n    if ( empty( $markup['offers'] ) || ! $product->needs_shipping() ) {\n        return $markup;\n    }\n\n    $currency = get_woocommerce_currency();\n    $shipping = array(\n        '@type'               => 'OfferShippingDetails',\n        'shippingRate'        => array( '@type' => 'MonetaryAmount', 'value' => $wpide_schema_policy['shipping_cost'], 'currency' => $currency ),\n        'shippingDestination' => array( '@type' => 'DefinedRegion', 'addressCountry' => $wpide_schema_policy['country'] ),\n        'deliveryTime'        => array(\n            '@type'        => 'ShippingDeliveryTime',\n            'handlingTime' => array( '@type' => 'QuantitativeValue', 'minValue' => $wpide_schema_policy['handling_days'][0], 'maxValue' => $wpide_schema_policy['handling_days'][1], 'unitCode' => 'DAY' ),\n            'transitTime'  => array( '@type' => 'QuantitativeValue', 'minValue' => $wpide_schema_policy['transit_days'][0], 'maxValue' => $wpide_schema_policy['transit_days'][1], 'unitCode' => 'DAY' ),\n        ),\n    );\n    $returns  = array(\n        '@type'                => 'MerchantReturnPolicy',\n        'applicableCountry'    => $wpide_schema_policy['country'],\n        'returnPolicyCategory' => 'https://schema.org/MerchantReturnFiniteReturnWindow',\n        'merchantReturnDays'   => $wpide_schema_policy['return_days'],\n        'returnMethod'         => 'https://schema.org/ReturnByMail',\n        'returnFees'           => $wpide_schema_policy['free_returns'] ? 'https://schema.org/FreeReturn' : 'https://schema.org/ReturnFeesCustomerResponsibility',\n    );\n\n    foreach ( $markup['offers'] as $i => $offer ) {\n        $markup['offers'][ $i ]['shippingDetails']         = $shipping;\n        $markup['offers'][ $i ]['hasMerchantReturnPolicy'] = $returns;\n    }\n\n    return $markup;\n}, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Describe your main market in $wpide_schema_policy, and keep the values true to your real offer or Google may ignore them."
        },
        {
            "id": "woo-thank-you-page-per-product",
            "version": 1,
            "title": "Different Thank-You Page per Product",
            "description": "Sends customers to a specific page after checkout when their order contains a certain product, such as onboarding steps for a course or setup guide for a device.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "thank-you",
                "redirects",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_product_thanks = array( // Product ID => page ID.\n    123 => 456,\n    124 => 789,\n);\n\nadd_filter( 'woocommerce_get_checkout_order_received_url', function ( $url, $order ) use ( $wpide_product_thanks ) {\n    if ( ! $order instanceof WC_Order || $order->has_status( 'failed' ) ) {\n        return $url;\n    }\n\n    foreach ( $order->get_items() as $item ) {\n        $page = $wpide_product_thanks[ $item->get_product_id() ] ?? 0;\n\n        if ( $page && 'publish' === get_post_status( $page ) ) {\n            return add_query_arg( array( 'order_id' => $order->get_id(), 'key' => $order->get_order_key() ), get_permalink( $page ) );\n        }\n    }\n\n    return $url;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The first matching product wins, and the order key is passed along so your page can still show the order details."
        },
        {
            "id": "woo-daily-order-limit",
            "version": 1,
            "title": "Limit Orders per Customer per Day",
            "description": "Blocks a customer from placing more than a set number of orders in 24 hours, matched by billing email, to stop card testing and reseller bots.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "fraud",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_daily_orders = 3; // Orders allowed per email in 24 hours.\n\nadd_action( 'woocommerce_after_checkout_validation', function ( $data, $errors ) use ( $wpide_daily_orders ) {\n    $email = sanitize_email( (string) ( $data['billing_email'] ?? '' ) );\n\n    if ( ! is_email( $email ) || $errors->has_errors() ) {\n        return;\n    }\n\n    $recent = wc_get_orders( array(\n        'billing_email' => $email,\n        'date_created'  => '>' . ( time() - DAY_IN_SECONDS ),\n        'status'        => array( 'wc-pending', 'wc-processing', 'wc-on-hold', 'wc-completed' ),\n        'limit'         => $wpide_daily_orders,\n        'return'        => 'ids',\n    ) );\n\n    if ( count( $recent ) >= $wpide_daily_orders ) {\n        $errors->add( 'validation', 'You have reached the daily order limit. Please contact us if you need to place another order today.' );\n    }\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Failed and cancelled orders do not count, and orders shop managers create in the admin are not affected."
        },
        {
            "id": "woo-unpaid-order-reminder",
            "version": 1,
            "title": "Payment Reminder for Unpaid Orders",
            "description": "Re-sends the order details with a payment link to customers whose order is still pending payment a few hours after checkout, recovering failed or abandoned payments.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "orders",
                "email",
                "payments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_reminder_hours = 6; // Hours after checkout.\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_unpaid_order_reminders' ) ) {\n        wp_schedule_event( time() + 600, 'hourly', 'wpide_unpaid_order_reminders' );\n    }\n} );\n\nadd_action( 'wpide_unpaid_order_reminders', function () use ( $wpide_reminder_hours ) {\n    if ( ! function_exists( 'wc_get_orders' ) ) {\n        return;\n    }\n\n    $orders = wc_get_orders( array(\n        'status'       => 'pending',\n        'date_created' => ( time() - 3 * DAY_IN_SECONDS ) . '...' . ( time() - $wpide_reminder_hours * HOUR_IN_SECONDS ),\n        'limit'        => 20,\n        'meta_query'   => array( array( 'key' => '_wpide_payment_reminder', 'compare' => 'NOT EXISTS' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery\n    ) );\n\n    $emails = WC()->mailer()->get_emails();\n\n    foreach ( $orders as $order ) {\n        if ( ! $order->get_billing_email() || ! $order->needs_payment() ) {\n            continue;\n        }\n\n        $order->update_meta_data( '_wpide_payment_reminder', time() );\n        $order->save_meta_data();\n\n        if ( isset( $emails['WC_Email_Customer_Invoice'] ) ) {\n            $emails['WC_Email_Customer_Invoice']->trigger( $order->get_id(), $order );\n            $order->add_order_note( 'Payment reminder sent to the customer.' );\n        }\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses WooCommerce's \"Order details\" email, sends one reminder per order and handles 20 orders per hourly WP-Cron run."
        },
        {
            "id": "woo-hide-empty-downloads-tab",
            "version": 1,
            "title": "Hide the Downloads Tab When There Are No Downloads",
            "description": "Removes the Downloads item from the My Account menu for customers who have never bought a downloadable product.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "my-account",
                "downloads",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'woocommerce_account_menu_items', function ( $items ) {\n    if ( isset( $items['downloads'] ) && is_user_logged_in() && ! wc_get_customer_available_downloads( get_current_user_id() ) ) {\n        unset( $items['downloads'] );\n    }\n\n    return $items;\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The tab reappears automatically after a customer buys something downloadable."
        },
        {
            "id": "woo-login-redirect-back",
            "version": 1,
            "title": "Return Customers to Their Page After Logging In",
            "description": "After logging in on the My Account page, customers go back to the page they were browsing, such as a product or the cart, instead of the account dashboard.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "login",
                "redirects",
                "my-account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_login_form_end', function () {\n    $referer = wp_get_referer();\n\n    if ( ! $referer || false !== strpos( $referer, 'wp-login.php' ) || untrailingslashit( strtok( $referer, '?' ) ) === untrailingslashit( wc_get_page_permalink( 'myaccount' ) ) ) {\n        return;\n    }\n\n    if ( wp_validate_redirect( $referer, false ) ) {\n        printf( '<input type=\"hidden\" name=\"redirect\" value=\"%s\">', esc_url( $referer ) );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Only pages on your own site are used, and customers who came straight to My Account still land on their dashboard."
        },
        {
            "id": "woo-related-products-exclude",
            "version": 1,
            "title": "Exclude Products or Categories From Related Products",
            "description": "Stops chosen products or whole categories, such as gift cards, samples or spare parts, from appearing in the related products section.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "related-products",
                "catalog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_related_exclude = array(\n    'products'   => array(),                       // Product IDs.\n    'categories' => array( 'gift-cards', 'parts' ), // Category slugs.\n);\n\nadd_filter( 'woocommerce_related_products', function ( $ids ) use ( $wpide_related_exclude ) {\n    return array_values( array_filter( (array) $ids, function ( $id ) use ( $wpide_related_exclude ) {\n        if ( in_array( (int) $id, array_map( 'intval', $wpide_related_exclude['products'] ), true ) ) {\n            return false;\n        }\n\n        return ! ( $wpide_related_exclude['categories'] && has_term( $wpide_related_exclude['categories'], 'product_cat', $id ) );\n    } ) );\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "WooCommerce caches related products, so changes can take a little while to show everywhere."
        },
        {
            "id": "woo-close-reviews-for-category",
            "version": 1,
            "title": "Turn Off Reviews for a Product Category",
            "description": "Disables reviews on products in chosen categories, such as made-to-order or services, while every other product keeps its Reviews tab.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "reviews",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_no_review_cats = array( 'services', 'bespoke' ); // Category slugs.\n\nadd_filter( 'comments_open', function ( $open, $post_id ) use ( $wpide_no_review_cats ) {\n    return ( $open && 'product' === get_post_type( $post_id ) && has_term( $wpide_no_review_cats, 'product_cat', $post_id ) ) ? false : $open;\n}, 20, 2 );\n\nadd_filter( 'woocommerce_product_tabs', function ( $tabs ) use ( $wpide_no_review_cats ) {\n    global $product;\n\n    if ( $product instanceof WC_Product && has_term( $wpide_no_review_cats, 'product_cat', $product->get_id() ) ) {\n        unset( $tabs['reviews'] );\n    }\n\n    return $tabs;\n}, 98 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Existing reviews are kept in the database but hidden along with the tab."
        },
        {
            "id": "woo-ajax-add-to-cart-single",
            "version": 1,
            "title": "Add to Cart Without a Page Reload on Product Pages",
            "description": "Adds simple products to the cart in the background from the product page, updating the mini cart and showing a confirmation instead of reloading the page.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "ajax",
                "cart",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! function_exists( 'is_product' ) || ! is_product() ) {\n        return;\n    }\n    ?>\n    <script>\n    ( function () {\n        var form = document.querySelector( '.product-type-simple form.cart' );\n        if ( ! form || ! window.fetch ) { return; }\n        form.addEventListener( 'submit', function ( event ) {\n            var button = form.querySelector( '[name=\"add-to-cart\"]' );\n            if ( ! button ) { return; }\n            event.preventDefault();\n            var data = new FormData( form );\n            data.set( 'product_id', button.value );\n            data.delete( 'add-to-cart' );\n            button.classList.add( 'loading' );\n            button.disabled = true;\n            fetch( <?php echo wp_json_encode( WC_AJAX::get_endpoint( 'add_to_cart' ) ); ?>, { method: 'POST', body: data, credentials: 'same-origin' } )\n                .then( function ( response ) { return response.json(); } )\n                .then( function ( result ) {\n                    if ( ! result || result.error ) { form.submit(); return; }\n                    if ( window.jQuery ) { jQuery( document.body ).trigger( 'added_to_cart', [ result.fragments, result.cart_hash, jQuery( button ) ] ); }\n                    var note = form.querySelector( '.wpide-added' ) || form.appendChild( document.createElement( 'p' ) );\n                    note.className = 'wpide-added';\n                    note.setAttribute( 'role', 'status' );\n                    note.innerHTML = <?php echo wp_json_encode( esc_html( 'Added to your cart.' ) . ' <a href=\"' . esc_url( wc_get_cart_url() ) . '\">' . esc_html( 'View cart' ) . '</a>' ); ?>;\n                } )\n                .catch( function () { form.submit(); } )\n                .finally( function () { button.classList.remove( 'loading' ); button.disabled = false; } );\n        } );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Applies to simple products only, and the mini cart refreshes when WooCommerce's cart fragments script is loaded."
        },
        {
            "id": "woo-checkout-progress-steps",
            "version": 1,
            "title": "Cart, Checkout and Confirmation Progress Steps",
            "description": "Shows a simple three-step progress bar (Cart, Checkout, Done) at the top of the cart, checkout and order-received pages, so shoppers know how close they are.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "checkout",
                "cart",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_steps = array( 'Cart', 'Checkout', 'Done' );\n\n$wpide_steps_html = function ( $current ) use ( $wpide_steps ) {\n    return function () use ( $current, $wpide_steps ) {\n        echo '<ol class=\"wpide-steps\" style=\"display:flex;gap:.5em;list-style:none;margin:0 0 1.5em;padding:0;counter-reset:step\">';\n\n        foreach ( $wpide_steps as $i => $label ) {\n            $state = $i < $current ? 'done' : ( $i === $current ? 'current' : 'next' );\n            printf(\n                '<li %1$s style=\"flex:1;padding:.5em;border-bottom:3px solid %2$s;font-weight:%3$s;text-align:center\">%4$s. %5$s</li>',\n                $i === $current ? 'aria-current=\"step\"' : '',\n                esc_attr( 'next' === $state ? '#dcdcde' : '#2271b1' ),\n                'current' === $state ? '700' : '400',\n                (int) ( $i + 1 ),\n                esc_html( $label )\n            );\n        }\n\n        echo '</ol>';\n    };\n};\n\nadd_action( 'woocommerce_before_cart', $wpide_steps_html( 0 ), 1 );\nadd_action( 'woocommerce_before_checkout_form', $wpide_steps_html( 1 ), 1 );\nadd_action( 'woocommerce_before_thankyou', $wpide_steps_html( 2 ), 1 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Works with the classic cart and checkout; edit the step names at the top."
        },
        {
            "id": "woo-category-low-stock-threshold",
            "version": 1,
            "title": "Different Low Stock Threshold per Category",
            "description": "Sets its own low stock threshold for products in chosen categories, so fast sellers warn you earlier while slow lines do not flood your inbox.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "inventory",
                "stock",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cat_thresholds = array( // Category slug => low stock threshold.\n    'best-sellers' => 20,\n    'accessories'  => 5,\n);\n\n$wpide_cat_threshold = function ( $amount, $product ) use ( $wpide_cat_thresholds ) {\n    if ( '' !== $product->get_low_stock_amount( 'edit' ) && null !== $product->get_low_stock_amount( 'edit' ) ) {\n        return $amount;\n    }\n\n    $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id();\n\n    foreach ( $wpide_cat_thresholds as $slug => $threshold ) {\n        if ( has_term( $slug, 'product_cat', $id ) ) {\n            return $threshold;\n        }\n    }\n\n    return $amount;\n};\n\nadd_filter( 'woocommerce_product_get_low_stock_amount', $wpide_cat_threshold, 10, 2 );\nadd_filter( 'woocommerce_product_variation_get_low_stock_amount', $wpide_cat_threshold, 10, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "A threshold set on an individual product still wins, and the change affects low stock emails and reports."
        },
        {
            "id": "woo-gateway-for-roles",
            "version": 1,
            "title": "Payment Methods Only for Certain Roles",
            "description": "Shows chosen payment methods, such as \"Pay by invoice\" or cheque, only to trusted roles like trade customers, and hides them from everyone else.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "payments",
                "roles",
                "b2b"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_role_gateways = array( // Gateway ID => roles that may use it.\n    'cheque' => array( 'wholesale_customer' ),\n    'cod'    => array( 'wholesale_customer', 'customer' ),\n);\n\nadd_filter( 'woocommerce_available_payment_gateways', function ( $gateways ) use ( $wpide_role_gateways ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return $gateways;\n    }\n\n    $roles = is_user_logged_in() ? (array) wp_get_current_user()->roles : array();\n\n    foreach ( $wpide_role_gateways as $id => $allowed ) {\n        if ( isset( $gateways[ $id ] ) && ! array_intersect( $allowed, $roles ) ) {\n            unset( $gateways[ $id ] );\n        }\n    }\n\n    return $gateways;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use gateway IDs from WooCommerce > Settings > Payments (bacs, cheque, cod); guests never see these methods."
        },
        {
            "id": "woo-hide-gateway-for-category",
            "version": 1,
            "title": "Hide a Payment Method for Certain Products",
            "description": "Removes a payment method, for example cash on delivery or buy-now-pay-later, when the cart contains products from categories it should not be used for.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "payments",
                "categories",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gateway_cat_rules = array( // Gateway ID => category slugs that hide it.\n    'cod' => array( 'gift-cards', 'digital' ),\n);\n\nadd_filter( 'woocommerce_available_payment_gateways', function ( $gateways ) use ( $wpide_gateway_cat_rules ) {\n    if ( ( is_admin() && ! wp_doing_ajax() ) || ! WC()->cart ) {\n        return $gateways;\n    }\n\n    foreach ( WC()->cart->get_cart() as $item ) {\n        foreach ( $wpide_gateway_cat_rules as $gateway => $categories ) {\n            if ( isset( $gateways[ $gateway ] ) && has_term( $categories, 'product_cat', $item['product_id'] ) ) {\n                unset( $gateways[ $gateway ] );\n            }\n        }\n    }\n\n    return $gateways;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The payment method comes back as soon as those products leave the cart."
        },
        {
            "id": "woo-generate-coupon-codes",
            "version": 1,
            "title": "Generate a Batch of Unique Coupon Codes",
            "description": "Creates a set of single-use coupon codes in one go, for printed flyers, influencer campaigns or event giveaways, and lists them so you can copy them.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "marketing",
                "bulk"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "$wpide_coupon_batch = array(\n    'count'   => 25,        // Coupons to create (max 500 per run).\n    'prefix'  => 'FLYER-',\n    'type'    => 'percent', // percent, fixed_cart or fixed_product.\n    'amount'  => 15,\n    'expires' => '+90 days',\n);\n\nif ( ! class_exists( 'WC_Coupon' ) || ( ! current_user_can( 'manage_woocommerce' ) && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) ) {\n    return;\n}\n\n$wpide_codes = array();\n\nfor ( $i = 0; $i < min( 500, (int) $wpide_coupon_batch['count'] ); $i++ ) {\n    do {\n        $code = $wpide_coupon_batch['prefix'] . strtoupper( wp_generate_password( 8, false, false ) );\n    } while ( wc_get_coupon_id_by_code( $code ) );\n\n    $coupon = new WC_Coupon();\n    $coupon->set_code( $code );\n    $coupon->set_discount_type( $wpide_coupon_batch['type'] );\n    $coupon->set_amount( $wpide_coupon_batch['amount'] );\n    $coupon->set_usage_limit( 1 );\n    $coupon->set_date_expires( strtotime( $wpide_coupon_batch['expires'] ) );\n    $coupon->set_description( 'Generated batch ' . wp_date( 'Y-m-d' ) );\n    $coupon->save();\n\n    $wpide_codes[] = $code;\n}\n\necho esc_html( implode( \"\\n\", $wpide_codes ) );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs only when you press Run in the editor or use WP-CLI, so set the amount, type and expiry first."
        },
        {
            "id": "woo-trash-expired-coupons",
            "version": 1,
            "title": "Move Long-Expired Coupons to the Trash",
            "description": "Cleans up the coupon list by moving coupons that expired more than 90 days ago to the trash, where you can still restore them.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "coupons",
                "cleanup",
                "maintenance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "$wpide_expired_days = 90; // Only coupons expired at least this long ago.\n\nif ( ! function_exists( 'WC' ) || ( ! current_user_can( 'manage_woocommerce' ) && ! ( defined( 'WP_CLI' ) && WP_CLI ) ) ) {\n    return;\n}\n\n$wpide_expired = get_posts( array(\n    'post_type'      => 'shop_coupon',\n    'post_status'    => 'publish',\n    'posts_per_page' => 500,\n    'fields'         => 'ids',\n    'no_found_rows'  => true,\n    'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery\n        array(\n            'key'     => 'date_expires',\n            'value'   => array( 1, time() - $wpide_expired_days * DAY_IN_SECONDS ),\n            'compare' => 'BETWEEN',\n            'type'    => 'NUMERIC',\n        ),\n    ),\n) );\n\nforeach ( $wpide_expired as $id ) {\n    wp_trash_post( $id );\n}\n\necho esc_html( sprintf( '%d expired coupon(s) moved to the trash.', count( $wpide_expired ) ) );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Runs only when you press Run in the editor or use WP-CLI, handles up to 500 coupons per run and never deletes permanently."
        },
        {
            "id": "woo-product-question-link",
            "version": 1,
            "title": "\"Ask a Question About This Product\" Link",
            "description": "Adds a link under the Add to Cart button that opens an email to your shop with the product name and link already filled in, an easy way to capture pre-sale questions.",
            "category": "woocommerce",
            "tags": [
                "woocommerce",
                "product-page",
                "enquiries",
                "contact"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_question_email = 'hello@example.com'; // Address that receives questions.\n\nadd_action( 'woocommerce_after_add_to_cart_form', function () use ( $wpide_question_email ) {\n    global $product;\n\n    if ( ! $product instanceof WC_Product || ! is_email( $wpide_question_email ) ) {\n        return;\n    }\n\n    $subject = rawurlencode( 'Question about ' . $product->get_name() );\n    $body    = rawurlencode( \"Hi,\\n\\nI have a question about \" . $product->get_name() . ' (' . $product->get_permalink() . \"):\\n\\n\" );\n\n    printf(\n        '<p class=\"wpide-product-question\"><a href=\"mailto:%1$s?subject=%2$s&body=%3$s\">%4$s</a></p>',\n        esc_attr( $wpide_question_email ),\n        esc_attr( $subject ),\n        esc_attr( $body ),\n        esc_html( 'Have a question about this product?' )\n    );\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Set the address in $wpide_question_email; the visitor's own email app sends the message, so no form data passes through your site."
        },
        {
            "id": "edd-free-download-labels",
            "version": 1,
            "title": "\"Free\" Label and Button for Free Downloads",
            "description": "Shows \"Free\" instead of 0.00 for free downloads and changes their purchase button to \"Download for free\", so visitors see at a glance they pay nothing.",
            "category": "edd",
            "tags": [
                "edd",
                "free-downloads",
                "buttons",
                "prices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_free = array(\n    'price'  => 'Free',\n    'button' => 'Download for free',\n);\n\nadd_filter( 'edd_download_price_after_html', function ( $formatted, $download_id, $price ) use ( $wpide_edd_free ) {\n    return ( 0.0 === (float) $price && function_exists( 'edd_has_variable_prices' ) && ! edd_has_variable_prices( $download_id ) ) ? '<span class=\"edd_price wpide-edd-free\">' . esc_html( $wpide_edd_free['price'] ) . '</span>' : $formatted;\n}, 10, 3 );\n\nadd_filter( 'edd_purchase_link_args', function ( $args ) use ( $wpide_edd_free ) {\n    if ( ! empty( $args['download_id'] ) && function_exists( 'edd_is_free_download' ) && edd_is_free_download( $args['download_id'] ) ) {\n        $args['text'] = $wpide_edd_free['button'];\n    }\n\n    return $args;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Edit the two texts at the top; paid downloads are not affected."
        },
        {
            "id": "edd-require-last-name",
            "version": 1,
            "title": "Require the Last Name at EDD Checkout",
            "description": "Makes the last name field required on the Easy Digital Downloads checkout, for invoices and licences that need a full name.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "fields",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_purchase_form_required_fields', function ( $fields ) {\n    $fields['edd_last'] = array(\n        'error_id'      => 'invalid_last_name',\n        'error_message' => 'Please enter your last name.',\n    );\n\n    return $fields;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "The field is already on the form; this only makes it mandatory."
        },
        {
            "id": "edd-checkout-phone-field",
            "version": 1,
            "title": "Phone Number Field at EDD Checkout",
            "description": "Adds a phone number field to the Easy Digital Downloads checkout, saves it with the order, shows it on the order screen and provides a {checkout_phone} email tag.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "fields",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_phone_required = true; // Require a phone number.\n\nadd_action( 'edd_purchase_form_user_info_fields', function () use ( $wpide_edd_phone_required ) {\n    ?>\n    <p id=\"edd-phone-wrap\">\n        <label class=\"edd-label\" for=\"edd-phone\"><?php echo esc_html( 'Phone number' ); ?><?php echo $wpide_edd_phone_required ? ' <span class=\"edd-required-indicator\">*</span>' : ''; ?></label>\n        <input class=\"edd-input\" type=\"tel\" name=\"edd_phone\" id=\"edd-phone\" autocomplete=\"tel\" <?php echo $wpide_edd_phone_required ? 'required' : ''; ?>>\n    </p>\n    <?php\n} );\n\nif ( $wpide_edd_phone_required ) {\n    add_filter( 'edd_purchase_form_required_fields', function ( $fields ) {\n        $fields['edd_phone'] = array(\n            'error_id'      => 'invalid_phone',\n            'error_message' => 'Please enter your phone number.',\n        );\n\n        return $fields;\n    } );\n}\n\nadd_action( 'edd_built_order', function ( $order_id ) {\n    // phpcs:ignore WordPress.Security.NonceVerification -- EDD verifies the purchase form nonce.\n    $phone = isset( $_POST['edd_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['edd_phone'] ) ) : '';\n\n    if ( '' !== $phone && function_exists( 'edd_add_order_meta' ) ) {\n        edd_add_order_meta( $order_id, 'wpide_phone', $phone, true );\n    }\n} );\n\nadd_action( 'edd_view_order_details_billing_after', function ( $order_id ) {\n    $phone = function_exists( 'edd_get_order_meta' ) ? edd_get_order_meta( $order_id, 'wpide_phone', true ) : '';\n\n    if ( $phone ) {\n        printf( '<div class=\"edd-order-phone\" style=\"padding:12px\"><strong>%1$s</strong> <a href=\"tel:%2$s\">%3$s</a></div>', esc_html( 'Phone:' ), esc_attr( preg_replace( '/[^\\d+]/', '', $phone ) ), esc_html( $phone ) );\n    }\n} );\n\nadd_action( 'edd_add_email_tags', function () {\n    edd_add_email_tag( 'checkout_phone', 'The phone number entered at checkout', function ( $order_id ) {\n        return esc_html( (string) edd_get_order_meta( $order_id, 'wpide_phone', true ) );\n    }, 'Phone' );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Set $wpide_edd_phone_required to false to make the field optional, and use {checkout_phone} in Downloads > Emails templates."
        },
        {
            "id": "edd-block-disposable-emails",
            "version": 1,
            "title": "Block Disposable Emails at EDD Checkout",
            "description": "Rejects purchases made with throwaway email addresses on the Easy Digital Downloads checkout, so download links and receipts reach a real inbox.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "email",
                "fraud"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_disposable = array( 'mailinator.com', 'guerrillamail.com', '10minutemail.com', 'tempmail.com', 'yopmail.com', 'trashmail.com', 'sharklasers.com', 'getnada.com', 'temp-mail.org' );\n\nadd_action( 'edd_checkout_error_checks', function ( $valid_data, $post ) use ( $wpide_edd_disposable ) {\n    $email = strtolower( (string) ( $post['edd_email'] ?? '' ) );\n\n    if ( is_user_logged_in() && '' === $email ) {\n        $email = strtolower( wp_get_current_user()->user_email );\n    }\n\n    $domain = false !== strpos( $email, '@' ) ? substr( strrchr( $email, '@' ), 1 ) : '';\n\n    foreach ( $wpide_edd_disposable as $blocked ) {\n        if ( $domain && ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) ) {\n            edd_set_error( 'wpide_disposable_email', 'Please use a permanent email address so we can send your downloads.' );\n            return;\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "Add domains to the list as you spot them; subdomains of listed domains are blocked too."
        },
        {
            "id": "edd-minimum-cart-total",
            "version": 1,
            "title": "Minimum Cart Total for EDD Checkout",
            "description": "Requires the Easy Digital Downloads cart to reach a minimum amount before a purchase can be completed, and tells the customer how much more to add.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "minimum",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_minimum = 10; // Minimum cart subtotal.\n\nadd_action( 'edd_checkout_error_checks', function () use ( $wpide_edd_minimum ) {\n    $subtotal = (float) edd_get_cart_subtotal();\n\n    if ( $subtotal > 0 && $subtotal < $wpide_edd_minimum ) {\n        edd_set_error( 'wpide_minimum_total', sprintf( 'The minimum order is %1$s. Please add %2$s more to your cart.', html_entity_decode( edd_currency_filter( edd_format_amount( $wpide_edd_minimum ) ) ), html_entity_decode( edd_currency_filter( edd_format_amount( $wpide_edd_minimum - $subtotal ) ) ) ) );\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "The minimum is compared with the subtotal before discounts, and free-only carts are not affected."
        },
        {
            "id": "edd-single-item-cart",
            "version": 1,
            "title": "Allow Only One Download in the EDD Cart",
            "description": "Empties the Easy Digital Downloads cart before a new item is added, so each purchase contains a single download, which suits licence or membership sales.",
            "category": "edd",
            "tags": [
                "edd",
                "cart",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edd_pre_add_to_cart', function ( $download_id ) {\n    $contents = edd_get_cart_contents();\n\n    if ( ! $contents ) {\n        return;\n    }\n\n    foreach ( $contents as $item ) {\n        if ( (int) $item['id'] !== (int) $download_id ) {\n            edd_empty_cart();\n            return;\n        }\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Adding the same download again simply keeps it in the cart."
        },
        {
            "id": "edd-checkout-guarantee-box",
            "version": 1,
            "title": "Money-Back Guarantee Box Below the EDD Checkout Cart",
            "description": "Adds a reassuring money-back guarantee box below the cart on the Easy Digital Downloads checkout, right where buyers decide.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "trust",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "edd_after_checkout_cart",
            "insert_number": 1,
            "code": "<div class=\"wpide-edd-guarantee\" style=\"display:flex;gap:12px;align-items:flex-start;margin:1.5em 0;padding:1em 1.25em;border:1px solid #c3e6cb;background:#f3fbf5;border-radius:6px\">\n  <span aria-hidden=\"true\" style=\"font-size:1.6em;line-height:1\">&#10003;</span>\n  <div>\n    <strong>14-day money-back guarantee</strong><br>\n    If it is not right for you, email us within 14 days for a full refund. No questions asked.\n  </div>\n</div>",
            "requires": [
                "edd"
            ],
            "notes": "Edit the wording and number of days to match your refund policy."
        },
        {
            "id": "edd-hide-discount-field",
            "version": 1,
            "title": "Hide the Discount Code Field at EDD Checkout",
            "description": "Removes the \"Have a discount code?\" link from the Easy Digital Downloads checkout, so customers without a code do not leave to hunt for one.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "discounts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'edd_checkout_form_top', 'edd_discount_field', -1 );\n}, 20 );",
            "requires": [
                "edd"
            ],
            "notes": "For the shortcode-based checkout; discount links (?discount=CODE) still apply codes automatically."
        },
        {
            "id": "edd-auto-apply-discount",
            "version": 1,
            "title": "Apply an EDD Discount Code Automatically",
            "description": "Applies a discount code to every Easy Digital Downloads checkout automatically, ideal for a site-wide sale where buyers should not need to type a code.",
            "category": "edd",
            "tags": [
                "edd",
                "discounts",
                "checkout",
                "sale"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_auto_code = 'SUMMER20'; // Existing discount code.\n\nadd_action( 'template_redirect', function () use ( $wpide_edd_auto_code ) {\n    if ( ! function_exists( 'edd_is_checkout' ) || ! edd_is_checkout() || ! edd_get_cart_contents() || edd_cart_has_discounts() ) {\n        return;\n    }\n\n    if ( edd_is_discount_valid( $wpide_edd_auto_code, '', false ) ) {\n        edd_set_cart_discount( $wpide_edd_auto_code );\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Create the code in Downloads > Discounts first (its dates and limits still apply) and remove the snippet when the sale ends."
        },
        {
            "id": "edd-bulk-discount",
            "version": 1,
            "title": "Bulk Discount for Several EDD Downloads",
            "description": "Takes a percentage off the Easy Digital Downloads cart when it contains a set number of downloads or more, shown as a discount line at checkout.",
            "category": "edd",
            "tags": [
                "edd",
                "discounts",
                "cart",
                "fees"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_bulk = array(\n    'items'   => 3,  // Minimum downloads in the cart.\n    'percent' => 15, // Percent off the subtotal.\n);\n\n$wpide_edd_bulk_apply = function () use ( $wpide_edd_bulk ) {\n    if ( ! function_exists( 'EDD' ) || ! EDD()->fees ) {\n        return;\n    }\n\n    EDD()->fees->remove_fee( 'wpide_bulk_discount' );\n\n    $count = count( (array) edd_get_cart_contents() );\n\n    if ( $count >= $wpide_edd_bulk['items'] ) {\n        EDD()->fees->add_fee( array(\n            'amount' => -round( (float) edd_get_cart_subtotal() * $wpide_edd_bulk['percent'] / 100, edd_currency_decimal_filter() ),\n            'label'  => sprintf( 'Bulk discount (%d%%)', $wpide_edd_bulk['percent'] ),\n            'id'     => 'wpide_bulk_discount',\n            'no_tax' => false,\n        ) );\n    }\n};\n\nadd_action( 'edd_post_add_to_cart', $wpide_edd_bulk_apply );\nadd_action( 'edd_post_remove_from_cart', $wpide_edd_bulk_apply );\nadd_action( 'template_redirect', function () use ( $wpide_edd_bulk_apply ) {\n    if ( function_exists( 'edd_is_checkout' ) && edd_is_checkout() ) {\n        $wpide_edd_bulk_apply();\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Uses EDD cart fees and recalculates whenever items are added or removed."
        },
        {
            "id": "edd-small-order-fee",
            "version": 1,
            "title": "Small Order Fee for Low-Value EDD Carts",
            "description": "Adds a small processing fee to Easy Digital Downloads orders below an amount, covering payment costs on very cheap purchases.",
            "category": "edd",
            "tags": [
                "edd",
                "fees",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_small_fee = array(\n    'below'  => 5,    // Orders under this subtotal...\n    'amount' => 0.50, // ...pay this fee.\n    'label'  => 'Processing fee',\n);\n\n$wpide_edd_small_fee_apply = function () use ( $wpide_edd_small_fee ) {\n    if ( ! function_exists( 'EDD' ) || ! EDD()->fees ) {\n        return;\n    }\n\n    EDD()->fees->remove_fee( 'wpide_small_order_fee' );\n    $subtotal = (float) edd_get_cart_subtotal();\n\n    if ( $subtotal > 0 && $subtotal < $wpide_edd_small_fee['below'] ) {\n        EDD()->fees->add_fee( array( 'amount' => $wpide_edd_small_fee['amount'], 'label' => $wpide_edd_small_fee['label'], 'id' => 'wpide_small_order_fee' ) );\n    }\n};\n\nadd_action( 'edd_post_add_to_cart', $wpide_edd_small_fee_apply );\nadd_action( 'edd_post_remove_from_cart', $wpide_edd_small_fee_apply );\nadd_action( 'template_redirect', function () use ( $wpide_edd_small_fee_apply ) {\n    if ( function_exists( 'edd_is_checkout' ) && edd_is_checkout() ) {\n        $wpide_edd_small_fee_apply();\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Free carts never get the fee, which shows as its own line at checkout and on the receipt."
        },
        {
            "id": "edd-success-page-per-download",
            "version": 1,
            "title": "Different Success Page per EDD Download",
            "description": "Sends buyers of specific downloads to their own thank-you page after purchase, for example with setup instructions or an onboarding video.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "redirects",
                "thank-you"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_success_pages = array( // Download ID => page ID.\n    101 => 202,\n);\n\nadd_filter( 'edd_success_page_redirect', function ( $redirect, $gateway, $query_string ) use ( $wpide_edd_success_pages ) {\n    $session = function_exists( 'edd_get_purchase_session' ) ? edd_get_purchase_session() : false;\n\n    foreach ( (array) ( $session['downloads'] ?? array() ) as $download ) {\n        $page = $wpide_edd_success_pages[ (int) ( $download['id'] ?? 0 ) ] ?? 0;\n\n        if ( $page && 'publish' === get_post_status( $page ) ) {\n            return get_permalink( $page ) . $query_string;\n        }\n    }\n\n    return $redirect;\n}, 10, 3 );",
            "requires": [
                "edd"
            ],
            "notes": "The first matching download wins; add the [edd_receipt] shortcode to those pages to keep showing the receipt."
        },
        {
            "id": "edd-receipt-thank-you-message",
            "version": 1,
            "title": "Custom Message on the EDD Purchase Receipt Page",
            "description": "Adds your own thank-you text, next steps or support details below the receipt that buyers see after an Easy Digital Downloads purchase.",
            "category": "edd",
            "tags": [
                "edd",
                "receipt",
                "thank-you",
                "support"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_receipt_message = '<h3>What happens next?</h3><p>Your files are ready to download above and we have emailed you a copy of this receipt. Need help? Visit our <a href=\"/support/\">support page</a>.</p>';\n\nadd_action( 'edd_order_receipt_after_table', function () use ( $wpide_edd_receipt_message ) {\n    echo '<div class=\"wpide-edd-receipt-message\">' . wp_kses_post( do_shortcode( $wpide_edd_receipt_message ) ) . '</div>';\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Shortcodes and basic HTML are allowed in the message."
        },
        {
            "id": "edd-receipt-email-extra",
            "version": 1,
            "title": "Extra Text at the End of EDD Purchase Receipt Emails",
            "description": "Appends a paragraph, such as getting-started tips or a support link, to every Easy Digital Downloads purchase receipt email without editing the template.",
            "category": "edd",
            "tags": [
                "edd",
                "email",
                "receipt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_receipt_extra = '<p><strong>Getting started:</strong> read our quick-start guide at https://example.com/docs/ or reply to this email if you have any questions.</p>';\n\nadd_filter( 'edd_purchase_receipt', function ( $message ) use ( $wpide_edd_receipt_extra ) {\n    return $message . \"\\n\" . wp_kses_post( $wpide_edd_receipt_extra );\n}, 20 );",
            "requires": [
                "edd"
            ],
            "notes": "Email tags such as {name} work in the text; edit the receipt itself under Downloads > Emails."
        },
        {
            "id": "edd-bcc-receipts",
            "version": 1,
            "title": "Blind Copy EDD Receipts to the Shop",
            "description": "Sends you a hidden copy of every purchase receipt that Easy Digital Downloads emails to customers, so you can check exactly what they received.",
            "category": "edd",
            "tags": [
                "edd",
                "email",
                "bcc",
                "receipts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_bcc = 'receipts@example.com'; // Copy recipient.\n\nadd_filter( 'edd_receipt_headers', function ( $headers ) use ( $wpide_edd_bcc ) {\n    if ( is_email( $wpide_edd_bcc ) ) {\n        $headers = rtrim( (string) $headers ) . \"\\r\\nBcc: \" . $wpide_edd_bcc . \"\\r\\n\";\n    }\n\n    return $headers;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Use a separate mailbox, as busy stores will receive one copy per sale."
        },
        {
            "id": "edd-admin-sale-subject",
            "version": 1,
            "title": "Order Total and Buyer in EDD Sale Notification Subjects",
            "description": "Changes the subject of the \"New sale\" email you receive to include the amount and the buyer's name, so you can see each sale from your inbox.",
            "category": "edd",
            "tags": [
                "edd",
                "email",
                "notifications",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_admin_sale_notification_subject', function ( $subject, $order_id ) {\n    $order = function_exists( 'edd_get_order' ) ? edd_get_order( $order_id ) : false;\n\n    if ( ! $order ) {\n        return $subject;\n    }\n\n    $customer = edd_get_customer( $order->customer_id );\n    $name     = $customer && $customer->name ? $customer->name : $order->email;\n\n    return sprintf( 'New sale: %1$s from %2$s (#%3$s)', html_entity_decode( edd_currency_filter( edd_format_amount( $order->total ), $order->currency ) ), $name, $order->get_number() );\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "Change the wording in the sprintf line; only the admin sale notification is affected."
        },
        {
            "id": "edd-change-downloads-slug",
            "version": 1,
            "title": "Change the EDD Downloads URL Base",
            "description": "Replaces /downloads/ in your product URLs with a word you choose, such as /plugins/ or /templates/, without defining constants in wp-config.php.",
            "category": "edd",
            "tags": [
                "edd",
                "permalinks",
                "urls",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_slug = 'plugins'; // New URL base.\n\nadd_filter( 'edd_download_post_type_args', function ( $args ) use ( $wpide_edd_slug ) {\n    $args['rewrite'] = array_merge( (array) ( $args['rewrite'] ?? array() ), array( 'slug' => sanitize_title( $wpide_edd_slug ), 'with_front' => false ) );\n\n    if ( ! empty( $args['has_archive'] ) ) {\n        $args['has_archive'] = sanitize_title( $wpide_edd_slug );\n    }\n\n    return $args;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Visit Settings > Permalinks and click Save after activating, and set up redirects from the old URLs if the site is already live."
        },
        {
            "id": "edd-disable-downloads-archive",
            "version": 1,
            "title": "Turn Off the EDD Downloads Archive Page",
            "description": "Removes the automatic /downloads/ archive page, for stores that list products on a page they designed themselves with the [downloads] shortcode or blocks.",
            "category": "edd",
            "tags": [
                "edd",
                "archive",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_download_post_type_args', function ( $args ) {\n    $args['has_archive'] = false;\n\n    return $args;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Save Settings > Permalinks afterwards; single download pages keep working."
        },
        {
            "id": "edd-rename-download-labels",
            "version": 1,
            "title": "Rename \"Downloads\" to Products, Plugins or Courses",
            "description": "Changes the word \"Download\" throughout Easy Digital Downloads, in the admin menu, buttons and messages, to a word that fits what you sell.",
            "category": "edd",
            "tags": [
                "edd",
                "labels",
                "admin",
                "text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_labels = array(\n    'singular' => 'Product',\n    'plural'   => 'Products',\n);\n\nadd_filter( 'edd_default_downloads_name', function ( $labels ) use ( $wpide_edd_labels ) {\n    return array_merge( (array) $labels, $wpide_edd_labels );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Set the singular and plural forms; URLs stay the same unless you also use the URL base snippet."
        },
        {
            "id": "edd-exclude-downloads-from-search",
            "version": 1,
            "title": "Keep EDD Downloads Out of Site Search",
            "description": "Removes Easy Digital Downloads products from the normal WordPress search results, for sites where downloads are only sold through dedicated pages.",
            "category": "edd",
            "tags": [
                "edd",
                "search",
                "visibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_download_post_type_args', function ( $args ) {\n    $args['exclude_from_search'] = true;\n\n    return $args;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Download pages stay public and indexable by search engines."
        },
        {
            "id": "edd-users-spent-column",
            "version": 1,
            "title": "EDD Purchases and Lifetime Value in the Users List",
            "description": "Adds \"Purchases\" and \"Lifetime value\" columns to Users > All Users from Easy Digital Downloads customer records, so you can spot your best buyers.",
            "category": "edd",
            "tags": [
                "edd",
                "users",
                "customers",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_edd_purchases'] = 'Purchases';\n    $columns['wpide_edd_value']     = 'Lifetime value';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $value, $column, $user_id ) {\n    if ( 'wpide_edd_purchases' !== $column && 'wpide_edd_value' !== $column ) {\n        return $value;\n    }\n\n    $customer = function_exists( 'edd_get_customer_by' ) ? edd_get_customer_by( 'user_id', $user_id ) : false;\n\n    if ( ! $customer ) {\n        return '–';\n    }\n\n    if ( 'wpide_edd_purchases' === $column ) {\n        return sprintf( '<a href=\"%1$s\">%2$d</a>', esc_url( admin_url( 'edit.php?post_type=download&page=edd-customers&view=overview&id=' . $customer->id ) ), (int) $customer->purchase_count );\n    }\n\n    return esc_html( html_entity_decode( edd_currency_filter( edd_format_amount( $customer->purchase_value ) ) ) );\n}, 10, 3 );",
            "requires": [
                "edd"
            ],
            "notes": "Users without an EDD customer record show a dash."
        },
        {
            "id": "edd-dashboard-month-earnings",
            "version": 1,
            "title": "EDD Earnings This Month vs Last Month Widget",
            "description": "Adds a dashboard widget with this month's Easy Digital Downloads earnings and order count next to last month's totals, using EDD's own stats engine.",
            "category": "edd",
            "tags": [
                "edd",
                "dashboard",
                "reports",
                "earnings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! class_exists( '\\EDD\\Stats' ) || ! current_user_can( 'view_shop_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_edd_month', 'Earnings this month', function () {\n        $data = get_transient( 'wpide_edd_month_earnings' );\n\n        if ( false === $data ) {\n            $stats = new \\EDD\\Stats();\n            $data  = array(\n                'this_earnings' => (float) $stats->get_order_earnings( array( 'range' => 'this_month' ) ),\n                'this_orders'   => (int) $stats->get_order_count( array( 'range' => 'this_month' ) ),\n                'last_earnings' => (float) $stats->get_order_earnings( array( 'range' => 'last_month' ) ),\n                'last_orders'   => (int) $stats->get_order_count( array( 'range' => 'last_month' ) ),\n            );\n            set_transient( 'wpide_edd_month_earnings', $data, 30 * MINUTE_IN_SECONDS );\n        }\n\n        $money = function ( $amount ) {\n            return esc_html( html_entity_decode( edd_currency_filter( edd_format_amount( $amount ) ) ) );\n        };\n\n        printf(\n            '<p style=\"font-size:24px;margin:.2em 0\"><strong>%1$s</strong></p><p>%2$s</p><p>%3$s</p>',\n            $money( $data['this_earnings'] ), // phpcs:ignore WordPress.Security.EscapeOutput -- Escaped in $money.\n            esc_html( sprintf( '%d orders this month', $data['this_orders'] ) ),\n            esc_html( sprintf( 'Last month: %1$s from %2$d orders.', html_entity_decode( edd_currency_filter( edd_format_amount( $data['last_earnings'] ) ) ), $data['last_orders'] ) )\n        );\n    } );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Only users who can view shop reports see it, and the figures are cached for 30 minutes."
        },
        {
            "id": "edd-top-downloads-widget",
            "version": 1,
            "title": "Best-Selling Downloads Dashboard Widget",
            "description": "Lists your five best-selling Easy Digital Downloads products of all time on the dashboard, with their sales count and a link to edit each one.",
            "category": "edd",
            "tags": [
                "edd",
                "dashboard",
                "reports",
                "downloads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! function_exists( 'edd_get_download_sales_stats' ) || ! current_user_can( 'view_shop_reports' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_edd_top', 'Best-selling downloads', function () {\n        $ids = get_posts( array(\n            'post_type'      => 'download',\n            'post_status'    => 'publish',\n            'posts_per_page' => 5,\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n            'meta_key'       => '_edd_download_sales', // phpcs:ignore WordPress.DB.SlowDBQuery\n            'orderby'        => 'meta_value_num',\n            'order'          => 'DESC',\n        ) );\n\n        if ( ! $ids ) {\n            echo '<p>' . esc_html( 'No sales yet.' ) . '</p>';\n            return;\n        }\n\n        echo '<ol>';\n\n        foreach ( $ids as $id ) {\n            printf( '<li><a href=\"%1$s\">%2$s</a> &ndash; %3$s</li>', esc_url( get_edit_post_link( $id ) ), esc_html( get_the_title( $id ) ), esc_html( sprintf( '%d sales', edd_get_download_sales_stats( $id ) ) ) );\n        }\n\n        echo '</ol>';\n    } );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Uses the sales count EDD stores on each download, so the widget is fast even on large stores."
        },
        {
            "id": "edd-admin-bar-today-earnings",
            "version": 1,
            "title": "Today's EDD Earnings in the Admin Bar",
            "description": "Shows today's Easy Digital Downloads earnings in the admin toolbar, linking to the reports screen, so you can check sales from any page.",
            "category": "edd",
            "tags": [
                "edd",
                "admin-bar",
                "earnings",
                "reports"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! class_exists( '\\EDD\\Stats' ) || ! current_user_can( 'view_shop_reports' ) ) {\n        return;\n    }\n\n    $earnings = get_transient( 'wpide_edd_today_earnings' );\n\n    if ( false === $earnings ) {\n        $stats    = new \\EDD\\Stats();\n        $earnings = (float) $stats->get_order_earnings( array( 'range' => 'today' ) );\n        set_transient( 'wpide_edd_today_earnings', $earnings, 10 * MINUTE_IN_SECONDS );\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-edd-today',\n        'title' => esc_html( 'Today: ' . html_entity_decode( edd_currency_filter( edd_format_amount( $earnings ) ) ) ),\n        'href'  => esc_url( admin_url( 'edit.php?post_type=download&page=edd-reports' ) ),\n    ) );\n}, 90 );",
            "requires": [
                "edd"
            ],
            "notes": "Only users who can view shop reports see it, and the figure is cached for 10 minutes."
        },
        {
            "id": "edd-sales-count-badge",
            "version": 1,
            "title": "\"Sold X Times\" Note Under EDD Purchase Buttons",
            "description": "Shows how many times a download has been bought under its purchase button, as social proof, once it passes a minimum number of sales.",
            "category": "edd",
            "tags": [
                "edd",
                "social-proof",
                "sales",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_min_sales = 10; // Show the note from this many sales.\n\nadd_action( 'edd_purchase_link_end', function ( $download_id ) use ( $wpide_edd_min_sales ) {\n    $sales = (int) edd_get_download_sales_stats( $download_id );\n\n    if ( $sales >= $wpide_edd_min_sales ) {\n        printf( '<p class=\"wpide-edd-sales\" style=\"margin:.5em 0 0;font-size:.9em;opacity:.8\">%s</p>', esc_html( sprintf( 'Bought %s times', number_format_i18n( $sales ) ) ) );\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Hidden until a download reaches $wpide_edd_min_sales sales, so new products do not show low numbers."
        },
        {
            "id": "edd-cart-link-shortcode",
            "version": 1,
            "title": "EDD Cart Link With Item Count Shortcode",
            "description": "Adds a [wpide_edd_cart] shortcode that prints a \"Cart (2)\" link to the Easy Digital Downloads checkout, for headers, menus or widgets.",
            "category": "edd",
            "tags": [
                "edd",
                "cart",
                "shortcode",
                "header"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_edd_cart', function ( $atts ) {\n    if ( ! function_exists( 'edd_get_cart_quantity' ) ) {\n        return '';\n    }\n\n    $atts  = shortcode_atts( array( 'label' => 'Cart' ), $atts, 'wpide_edd_cart' );\n    $count = (int) edd_get_cart_quantity();\n\n    return sprintf(\n        '<a class=\"wpide-edd-cart-link\" href=\"%1$s\">%2$s <span class=\"edd-cart-quantity\">(%3$d)</span><span class=\"screen-reader-text\"> %4$s</span></a>',\n        esc_url( edd_get_checkout_uri() ),\n        esc_html( $atts['label'] ),\n        $count,\n        esc_html( sprintf( _n( '%d item', '%d items', $count ), $count ) )\n    );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Use [wpide_edd_cart label=\"Basket\"] to change the text; full-page caches may need to skip pages that show it."
        },
        {
            "id": "edd-empty-checkout-redirect",
            "version": 1,
            "title": "Redirect an Empty EDD Checkout to Your Products",
            "description": "Sends visitors who land on the Easy Digital Downloads checkout with an empty cart to your products page instead of showing \"Your cart is empty\".",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "cart",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_shop_url = ''; // Empty uses the downloads archive, or the home page if there is none.\n\nadd_action( 'template_redirect', function () use ( $wpide_edd_shop_url ) {\n    if ( ! function_exists( 'edd_is_checkout' ) || ! edd_is_checkout() || edd_get_cart_contents() || isset( $_GET['payment-confirmation'] ) || isset( $_GET['edd-listener'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $url = $wpide_edd_shop_url ? $wpide_edd_shop_url : get_post_type_archive_link( 'download' );\n\n    wp_safe_redirect( $url ? $url : home_url( '/' ) );\n    exit;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Set $wpide_edd_shop_url to your products page; receipts and payment confirmation pages are not affected."
        },
        {
            "id": "edd-empty-cart-button",
            "version": 1,
            "title": "\"Empty Cart\" Button on the EDD Checkout",
            "description": "Adds an \"Empty cart\" button below the Easy Digital Downloads checkout cart so buyers can start again in one click.",
            "category": "edd",
            "tags": [
                "edd",
                "cart",
                "checkout",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edd_cart_footer_buttons', function () {\n    printf(\n        '<a class=\"edd-submit button wpide-edd-empty-cart\" href=\"%1$s\" onclick=\"return confirm(%2$s)\">%3$s</a>',\n        esc_url( wp_nonce_url( add_query_arg( 'edd_action', 'wpide_empty_cart', edd_get_checkout_uri() ), 'wpide_edd_empty_cart' ) ),\n        esc_attr( wp_json_encode( 'Remove all items from your cart?' ) ),\n        esc_html( 'Empty cart' )\n    );\n} );\n\nadd_action( 'edd_wpide_empty_cart', function () {\n    if ( isset( $_GET['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wpide_edd_empty_cart' ) ) {\n        edd_empty_cart();\n    }\n\n    wp_safe_redirect( edd_get_checkout_uri() );\n    exit;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Asks for confirmation first and uses a security token, so the link cannot be triggered from other sites."
        },
        {
            "id": "edd-continue-shopping-link",
            "version": 1,
            "title": "\"Continue Shopping\" Link on the EDD Checkout",
            "description": "Adds a \"Continue shopping\" link below the Easy Digital Downloads checkout cart, taking buyers back to your products to add more.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "cart",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_continue_url = ''; // Empty uses the downloads archive.\n\nadd_action( 'edd_cart_footer_buttons', function () use ( $wpide_edd_continue_url ) {\n    $url = $wpide_edd_continue_url ? $wpide_edd_continue_url : get_post_type_archive_link( 'download' );\n\n    printf( '<a class=\"wpide-edd-continue\" href=\"%1$s\" style=\"margin-right:1em\">&larr; %2$s</a>', esc_url( $url ? $url : home_url( '/' ) ), esc_html( 'Continue shopping' ) );\n}, 5 );",
            "requires": [
                "edd"
            ],
            "notes": "Set $wpide_edd_continue_url to your products page; by default it uses the downloads archive or home page."
        },
        {
            "id": "edd-checkout-text-changes",
            "version": 1,
            "title": "Change EDD Checkout Texts Without a Translation Plugin",
            "description": "Replaces Easy Digital Downloads wording such as \"Personal Info\" or \"Purchase\" with your own, using a simple list of old and new texts.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "text",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_texts = array( // Original text => replacement.\n    'Personal Info'    => 'Your details',\n    'Email Address'    => 'Email',\n    'Purchase Total:'  => 'Total to pay:',\n    'Have a discount code?' => 'Got a promo code?',\n);\n\nadd_filter( 'gettext_easy-digital-downloads', function ( $translation, $text ) use ( $wpide_edd_texts ) {\n    return $wpide_edd_texts[ $text ] ?? $translation;\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "The original text must match exactly, including capitals, and only Easy Digital Downloads texts are affected."
        },
        {
            "id": "edd-remove-auto-purchase-button",
            "version": 1,
            "title": "Remove the Automatic EDD Purchase Button From Downloads",
            "description": "Stops Easy Digital Downloads adding a purchase button below every download's content, for pages where you place the button yourself with a block or shortcode.",
            "category": "edd",
            "tags": [
                "edd",
                "buttons",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'edd_after_download_content', 'edd_append_purchase_link' );\n}, 20 );",
            "requires": [
                "edd"
            ],
            "notes": "Place the button with [purchase_link] or the EDD Buy Button block wherever you want it."
        },
        {
            "id": "edd-related-downloads",
            "version": 1,
            "title": "Related Downloads Below Each Download",
            "description": "Lists up to four other downloads from the same category below each download's content, helping buyers discover more of your products.",
            "category": "edd",
            "tags": [
                "edd",
                "related",
                "cross-sell",
                "downloads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_related_count = 4; // Downloads to show.\n\nadd_action( 'edd_after_download_content', function ( $download_id ) use ( $wpide_edd_related_count ) {\n    $ids = get_transient( 'wpide_edd_related_' . $download_id );\n\n    if ( false === $ids ) {\n        $terms = wp_get_post_terms( $download_id, 'download_category', array( 'fields' => 'ids' ) );\n        $ids   = array();\n\n        if ( $terms && ! is_wp_error( $terms ) ) {\n            $ids = get_posts( array(\n                'post_type'      => 'download',\n                'post_status'    => 'publish',\n                'posts_per_page' => $wpide_edd_related_count,\n                'post__not_in'   => array( $download_id ),\n                'fields'         => 'ids',\n                'no_found_rows'  => true,\n                'orderby'        => 'rand',\n                'tax_query'      => array( array( 'taxonomy' => 'download_category', 'terms' => $terms ) ), // phpcs:ignore WordPress.DB.SlowDBQuery\n            ) );\n        }\n\n        set_transient( 'wpide_edd_related_' . $download_id, $ids, $ids ? 12 * HOUR_IN_SECONDS : HOUR_IN_SECONDS );\n    }\n\n    if ( ! $ids ) {\n        return;\n    }\n\n    echo '<section class=\"wpide-edd-related\"><h3>' . esc_html( 'You may also like' ) . '</h3><ul style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:1em;list-style:none;padding:0\">';\n\n    foreach ( $ids as $id ) {\n        printf(\n            '<li><a href=\"%1$s\">%2$s<span style=\"display:block;margin-top:.4em\">%3$s</span></a>%4$s</li>',\n            esc_url( get_permalink( $id ) ),\n            get_the_post_thumbnail( $id, 'medium', array( 'style' => 'width:100%;height:auto' ) ),\n            esc_html( get_the_title( $id ) ),\n            wp_kses_post( edd_price( $id, false ) )\n        );\n    }\n\n    echo '</ul></section>';\n}, 20 );\n\nadd_action( 'save_post_download', function ( $post_id ) {\n    delete_transient( 'wpide_edd_related_' . $post_id );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Results are cached for 12 hours per download, and downloads without a category show nothing."
        },
        {
            "id": "edd-download-last-updated",
            "version": 1,
            "title": "\"Last Updated\" Date and Version on Downloads",
            "description": "Shows when a download was last updated and an optional version number above its content, reassuring buyers that the product is maintained.",
            "category": "edd",
            "tags": [
                "edd",
                "downloads",
                "versions",
                "trust"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_download', function () {\n    add_meta_box( 'wpide-edd-version', 'Version', function ( $post ) {\n        wp_nonce_field( 'wpide_edd_version', 'wpide_edd_version_nonce' );\n        printf( '<label class=\"screen-reader-text\" for=\"wpide-edd-version-field\">%1$s</label><input type=\"text\" id=\"wpide-edd-version-field\" name=\"wpide_edd_version\" value=\"%2$s\" placeholder=\"1.0.0\" style=\"width:100%%\">', esc_html( 'Version' ), esc_attr( get_post_meta( $post->ID, '_wpide_edd_version', true ) ) );\n    }, 'download', 'side' );\n} );\n\nadd_action( 'save_post_download', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_edd_version_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_edd_version_nonce'] ), 'wpide_edd_version' ) || ! current_user_can( 'edit_post', $post_id ) || wp_is_post_autosave( $post_id ) ) {\n        return;\n    }\n\n    update_post_meta( $post_id, '_wpide_edd_version', sanitize_text_field( wp_unslash( $_POST['wpide_edd_version'] ?? '' ) ) );\n} );\n\nadd_action( 'edd_before_download_content', function ( $download_id ) {\n    $version = get_post_meta( $download_id, '_wpide_edd_version', true );\n    $parts   = array( sprintf( 'Last updated: %s', get_the_modified_date( '', $download_id ) ) );\n\n    if ( $version ) {\n        $parts[] = sprintf( 'Version %s', $version );\n    }\n\n    printf( '<p class=\"wpide-edd-updated\" style=\"font-size:.9em;opacity:.8\">%s</p>', esc_html( implode( ' · ', $parts ) ) );\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Enter a version in the \"Version\" box on the download edit screen; the date comes from the last time you saved the download."
        },
        {
            "id": "edd-prevent-repeat-purchase",
            "version": 1,
            "title": "Stop Customers Buying the Same Download Twice",
            "description": "Replaces the purchase button with a \"You already own this\" link for logged-in customers who bought a download before, and blocks repeat purchases at checkout.",
            "category": "edd",
            "tags": [
                "edd",
                "purchases",
                "customers",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_purchase_download_form', function ( $form, $args ) {\n    $download_id = (int) ( $args['download_id'] ?? 0 );\n\n    if ( ! $download_id || ! is_user_logged_in() || ! edd_has_user_purchased( get_current_user_id(), $download_id ) ) {\n        return $form;\n    }\n\n    $history = edd_get_option( 'purchase_history_page' );\n\n    return sprintf(\n        '<p class=\"wpide-edd-owned\">%1$s%2$s</p>',\n        esc_html( 'You already own this.' ),\n        $history ? ' <a href=\"' . esc_url( get_permalink( $history ) ) . '\">' . esc_html( 'Go to your downloads' ) . '</a>' : ''\n    );\n}, 10, 2 );\n\nadd_action( 'edd_checkout_error_checks', function () {\n    if ( ! is_user_logged_in() ) {\n        return;\n    }\n\n    foreach ( (array) edd_get_cart_contents() as $item ) {\n        if ( edd_has_user_purchased( get_current_user_id(), $item['id'] ) ) {\n            edd_set_error( 'wpide_already_purchased', sprintf( 'You already own %s. Please remove it from your cart.', get_the_title( $item['id'] ) ) );\n            return;\n        }\n    }\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Works for logged-in customers, and downloads with variable prices are checked as a whole."
        },
        {
            "id": "edd-confirm-email-field",
            "version": 1,
            "title": "\"Confirm Email\" Field at EDD Checkout",
            "description": "Asks guest buyers to type their email address twice on the Easy Digital Downloads checkout, preventing typos that would stop their download links arriving.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "email",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edd_purchase_form_after_email', function () {\n    if ( is_user_logged_in() ) {\n        return;\n    }\n    ?>\n    <p id=\"edd-email-confirm-wrap\">\n        <label class=\"edd-label\" for=\"edd-email-confirm\"><?php echo esc_html( 'Confirm email address' ); ?> <span class=\"edd-required-indicator\">*</span></label>\n        <input class=\"edd-input required\" type=\"email\" name=\"edd_email_confirm\" id=\"edd-email-confirm\" autocomplete=\"email\" required>\n    </p>\n    <?php\n} );\n\nadd_action( 'edd_checkout_error_checks', function ( $valid_data, $post ) {\n    if ( is_user_logged_in() || ! isset( $post['edd_email'] ) ) {\n        return;\n    }\n\n    if ( strtolower( trim( (string) $post['edd_email'] ) ) !== strtolower( trim( (string) ( $post['edd_email_confirm'] ?? '' ) ) ) ) {\n        edd_set_error( 'wpide_email_mismatch', 'The email addresses do not match. Please check them.' );\n    }\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "Logged-in customers do not see the extra field."
        },
        {
            "id": "edd-discount-banner-above-button",
            "version": 1,
            "title": "Promo Code Banner Above EDD Purchase Buttons",
            "description": "Shows a small promotional banner with a discount code above every Easy Digital Downloads purchase button during a sale.",
            "category": "edd",
            "tags": [
                "edd",
                "discounts",
                "promotion",
                "banner"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "edd_purchase_link_top",
            "insert_number": 1,
            "code": "<p class=\"wpide-edd-promo\" style=\"margin:0 0 .75em;padding:.5em .75em;border-left:4px solid #d63638;background:#fcf0f1;font-size:.95em\">\n  <strong>Black Friday:</strong> use code <code>BF30</code> at checkout for 30% off.\n</p>",
            "requires": [
                "edd"
            ],
            "notes": "Create the code in Downloads > Discounts and deactivate this snippet when the sale ends."
        },
        {
            "id": "edd-instant-download-note",
            "version": 1,
            "title": "\"Instant Download\" Note Under EDD Purchase Buttons",
            "description": "Adds a short reassurance line, such as \"Instant download · Secure payment · Lifetime updates\", under every Easy Digital Downloads purchase button.",
            "category": "edd",
            "tags": [
                "edd",
                "trust",
                "conversion",
                "buttons"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "edd_purchase_link_end",
            "insert_number": 1,
            "code": "<p class=\"wpide-edd-assurance\" style=\"margin:.6em 0 0;font-size:.85em;opacity:.8\">\n  Instant download &middot; Secure payment &middot; Free updates for one year\n</p>",
            "requires": [
                "edd"
            ],
            "notes": "Edit the three points to match what you offer."
        },
        {
            "id": "edd-checkout-help-note",
            "version": 1,
            "title": "Help Note Above the EDD Checkout Cart",
            "description": "Shows a friendly \"Questions before you buy?\" note with your contact details above the cart on the Easy Digital Downloads checkout.",
            "category": "edd",
            "tags": [
                "edd",
                "checkout",
                "support",
                "trust"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "edd_before_checkout_cart",
            "insert_number": 1,
            "code": "<div class=\"wpide-edd-help\" style=\"margin:0 0 1em;padding:.75em 1em;background:#f6f7f7;border-radius:6px\">\n  Questions before you buy? Email <a href=\"mailto:hello@example.com\">hello@example.com</a> and we will reply within one business day.\n</div>",
            "requires": [
                "edd"
            ],
            "notes": "Replace the email address and response time with your own."
        },
        {
            "id": "edd-styles-only-where-needed",
            "version": 1,
            "title": "Load EDD Styles Only on Store Pages",
            "description": "Stops the Easy Digital Downloads stylesheet loading on blog posts and other pages without store content, trimming a request from most page views.",
            "category": "edd",
            "tags": [
                "edd",
                "performance",
                "css",
                "assets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_style_pages = array(); // Extra page IDs that need EDD styles.\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_edd_style_pages ) {\n    if ( ! function_exists( 'edd_is_checkout' ) ) {\n        return;\n    }\n\n    if ( is_singular( 'download' ) || is_post_type_archive( 'download' ) || is_tax( array( 'download_category', 'download_tag' ) ) || edd_is_checkout() || edd_is_success_page() || is_page( $wpide_edd_style_pages ) ) {\n        return;\n    }\n\n    $post = get_post();\n\n    if ( $post && ( has_shortcode( $post->post_content, 'downloads' ) || has_shortcode( $post->post_content, 'purchase_link' ) || has_shortcode( $post->post_content, 'download_cart' ) || has_shortcode( $post->post_content, 'purchase_history' ) || false !== strpos( $post->post_content, '<!-- wp:edd/' ) ) ) {\n        return;\n    }\n\n    wp_dequeue_style( 'edd-styles' );\n}, 100 );",
            "requires": [
                "edd"
            ],
            "notes": "Pages with EDD shortcodes or blocks keep the styles; add page IDs to $wpide_edd_style_pages if a widget needs them elsewhere."
        },
        {
            "id": "edd-orders-items-column",
            "version": 1,
            "title": "Purchased Downloads Column in the EDD Orders List",
            "description": "Adds a column to Downloads > Orders listing what each order contains, so you can see what was sold without opening every order.",
            "category": "edd",
            "tags": [
                "edd",
                "admin",
                "orders",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'edd_payments_table_columns', function ( $columns ) {\n    $new = array();\n\n    foreach ( $columns as $key => $label ) {\n        $new[ $key ] = $label;\n\n        if ( 'customer' === $key || 'email' === $key ) {\n            $new['wpide_items'] = 'Items';\n        }\n    }\n\n    return isset( $new['wpide_items'] ) ? $new : $new + array( 'wpide_items' => 'Items' );\n} );\n\nadd_filter( 'edd_payments_table_column', function ( $value, $order_id, $column ) {\n    if ( 'wpide_items' !== $column || ! function_exists( 'edd_get_order_items' ) ) {\n        return $value;\n    }\n\n    $items = edd_get_order_items( array( 'order_id' => $order_id, 'number' => 6 ) );\n    $names = array();\n\n    foreach ( array_slice( $items, 0, 5 ) as $item ) {\n        $names[] = esc_html( $item->product_name );\n    }\n\n    if ( count( $items ) > 5 ) {\n        $names[] = esc_html( 'and more' );\n    }\n\n    return implode( '<br>', $names );\n}, 10, 3 );",
            "requires": [
                "edd"
            ],
            "notes": "Shows up to five items per order, then \"and N more\"."
        },
        {
            "id": "edd-hide-gateway-above-amount",
            "version": 1,
            "title": "Hide an EDD Payment Method Above an Amount",
            "description": "Removes a payment method, such as a manual or offline gateway, from the Easy Digital Downloads checkout when the cart total is above your limit.",
            "category": "edd",
            "tags": [
                "edd",
                "payments",
                "gateways",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_gateway_limit = array(\n    'gateway' => 'manual', // Gateway ID.\n    'max'     => 200,      // Hide above this cart total.\n);\n\nadd_filter( 'edd_enabled_payment_gateways', function ( $gateways ) use ( $wpide_edd_gateway_limit ) {\n    if ( is_admin() && ! wp_doing_ajax() ) {\n        return $gateways;\n    }\n\n    if ( isset( $gateways[ $wpide_edd_gateway_limit['gateway'] ] ) && count( $gateways ) > 1 && function_exists( 'edd_get_cart_total' ) && (float) edd_get_cart_total() > $wpide_edd_gateway_limit['max'] ) {\n        unset( $gateways[ $wpide_edd_gateway_limit['gateway'] ] );\n    }\n\n    return $gateways;\n} );",
            "requires": [
                "edd"
            ],
            "notes": "Use the gateway ID (manual, paypal_commerce, stripe); the method is never removed if it is the only one left."
        },
        {
            "id": "edd-sale-notice-by-category",
            "version": 1,
            "title": "Notify Extra People of EDD Sales by Category",
            "description": "Adds extra recipients to the Easy Digital Downloads sale notification when an order contains downloads from a category, such as a partner or course author.",
            "category": "edd",
            "tags": [
                "edd",
                "email",
                "notifications",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_category_emails = array( // Download category slug => email address.\n    'themes'  => 'designer@example.com',\n    'courses' => 'teacher@example.com',\n);\n\nadd_filter( 'edd_admin_notice_emails', function ( $emails, $email = null ) use ( $wpide_edd_category_emails ) {\n    $order_id = ( $email instanceof \\EDD\\Orders\\Order ) ? (int) $email->id : 0; // EDD passes the order for sale notifications.\n\n    if ( ! $order_id || ! function_exists( 'edd_get_order_items' ) ) {\n        return $emails;\n    }\n\n    foreach ( edd_get_order_items( array( 'order_id' => $order_id, 'number' => 100 ) ) as $item ) {\n        foreach ( $wpide_edd_category_emails as $slug => $address ) {\n            if ( is_email( $address ) && has_term( $slug, 'download_category', $item->product_id ) ) {\n                $emails[] = $address;\n            }\n        }\n    }\n\n    return array_values( array_unique( (array) $emails ) );\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "The usual sale notification recipients still get every email."
        },
        {
            "id": "edd-add-custom-currency",
            "version": 1,
            "title": "Add a Custom Currency to Easy Digital Downloads",
            "description": "Adds a currency that Easy Digital Downloads does not list by default, with its own symbol, so you can choose it in Downloads > Settings > General.",
            "category": "edd",
            "tags": [
                "edd",
                "currency",
                "international",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_edd_currency = array(\n    'code'   => 'GHS',            // ISO 4217 code.\n    'name'   => 'Ghanaian Cedi',\n    'symbol' => 'GH₵',\n);\n\nadd_filter( 'edd_currencies', function ( $currencies ) use ( $wpide_edd_currency ) {\n    $currencies[ $wpide_edd_currency['code'] ] = sprintf( '%1$s (%2$s)', $wpide_edd_currency['name'], $wpide_edd_currency['symbol'] );\n\n    return $currencies;\n} );\n\nadd_filter( 'edd_currency_symbol', function ( $symbol, $code ) use ( $wpide_edd_currency ) {\n    return $code === $wpide_edd_currency['code'] ? $wpide_edd_currency['symbol'] : $symbol;\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "Use the official three-letter ISO code, and check that your payment gateway supports the currency."
        },
        {
            "id": "edd-hide-zero-decimals",
            "version": 1,
            "title": "Hide \".00\" on Whole EDD Prices",
            "description": "Shows whole prices such as 49 instead of 49.00 throughout Easy Digital Downloads, while prices like 49.50 keep their decimals.",
            "category": "edd",
            "tags": [
                "edd",
                "prices",
                "formatting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_format_amount_decimals', function ( $decimals, $amount ) {\n    return ( is_numeric( $amount ) && (float) $amount === floor( (float) $amount ) ) ? 0 : $decimals;\n}, 10, 2 );",
            "requires": [
                "edd"
            ],
            "notes": "Only the display changes; amounts are stored and charged exactly as before."
        },
        {
            "id": "comment-moderator-role",
            "version": 1,
            "title": "Comment Moderator Role",
            "description": "Adds a \"Comment Moderator\" role that can approve, edit and delete comments without being able to publish or edit other people's posts.",
            "category": "users",
            "tags": [
                "roles",
                "comments",
                "capabilities"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role ID and the name shown on the Users screen.\n$wpide_cmod_role = 'wpide_comment_moderator';\n$wpide_cmod_name = 'Comment Moderator';\n\n$wpide_cmod_add = function ( $roles ) use ( $wpide_cmod_role, $wpide_cmod_name ) {\n    if ( ! $roles instanceof WP_Roles || isset( $roles->role_objects[ $wpide_cmod_role ] ) ) {\n        return;\n    }\n\n    // edit_posts is needed to open the Comments screen; it only allows drafts of their own.\n    $caps = array( 'read' => true, 'edit_posts' => true, 'moderate_comments' => true );\n\n    $roles->roles[ $wpide_cmod_role ]        = array( 'name' => $wpide_cmod_name, 'capabilities' => $caps );\n    $roles->role_objects[ $wpide_cmod_role ] = new WP_Role( $wpide_cmod_role, $caps );\n    $roles->role_names[ $wpide_cmod_role ]   = $wpide_cmod_name;\n};\n\n$wpide_cmod_add( wp_roles() );\nadd_action( 'wp_roles_init', $wpide_cmod_add );\n\n// Moderators may edit any comment, without needing edit rights on the post it belongs to.\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id ) use ( $wpide_cmod_role ) {\n    if ( 'edit_comment' !== $cap ) {\n        return $caps;\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( $user && in_array( $wpide_cmod_role, (array) $user->roles, true ) ) {\n        return array( 'moderate_comments' );\n    }\n\n    return $caps;\n}, 10, 3 );\n\n// Keep the Posts and Tools menus out of their way.\nadd_action( 'admin_menu', function () use ( $wpide_cmod_role ) {\n    if ( in_array( $wpide_cmod_role, (array) wp_get_current_user()->roles, true ) ) {\n        remove_menu_page( 'edit.php' );\n        remove_menu_page( 'tools.php' );\n    }\n}, 99 );",
            "requires": [],
            "notes": "The role is registered in memory only; users given this role lose it (and fall back to no role) if the snippet is turned off."
        },
        {
            "id": "rename-role-display-names",
            "version": 1,
            "title": "Rename Roles Without Changing Them",
            "description": "Changes the names roles show on the Users screen and in role menus, for example \"Subscriber\" to \"Member\", without touching capabilities or the database.",
            "category": "users",
            "tags": [
                "roles",
                "labels",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role ID => new name.\n$wpide_role_labels = array(\n    'subscriber'  => 'Member',\n    'contributor' => 'Guest Writer',\n);\n\n$wpide_role_rename = function ( $roles ) use ( $wpide_role_labels ) {\n    if ( ! $roles instanceof WP_Roles ) {\n        return;\n    }\n\n    foreach ( $wpide_role_labels as $role => $label ) {\n        if ( isset( $roles->roles[ $role ] ) ) {\n            $roles->roles[ $role ]['name'] = $label;\n            $roles->role_names[ $role ]    = $label;\n        }\n    }\n};\n\n$wpide_role_rename( wp_roles() );\nadd_action( 'wp_roles_init', $wpide_role_rename );",
            "requires": [],
            "notes": "Only the label changes; role IDs such as subscriber stay the same, so plugins keep working."
        },
        {
            "id": "hide-unused-roles-from-menus",
            "version": 1,
            "title": "Hide Unused Roles From Role Menus",
            "description": "Removes roles you never use, such as Author or Contributor, from the role drop-downs when adding or editing users, so nobody picks them by mistake.",
            "category": "users",
            "tags": [
                "roles",
                "users",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Roles to hide from the role menus.\n$wpide_hidden_roles = array( 'author', 'contributor' );\n\nadd_filter( 'editable_roles', function ( $roles ) use ( $wpide_hidden_roles ) {\n    // Keep the role of the user being edited, otherwise saving would change it.\n    $editing = isset( $_GET['user_id'] ) ? get_userdata( absint( $_GET['user_id'] ) ) : null;\n    $keep    = $editing ? (array) $editing->roles : array();\n\n    foreach ( $wpide_hidden_roles as $role ) {\n        if ( ! in_array( $role, $keep, true ) ) {\n            unset( $roles[ $role ] );\n        }\n    }\n\n    return $roles;\n} );",
            "requires": [],
            "notes": "A user who already has a hidden role keeps seeing it on their own edit screen, so saving the profile never changes their role."
        },
        {
            "id": "additional-roles-on-profile",
            "version": 1,
            "title": "Give Users More Than One Role",
            "description": "Adds \"Additional roles\" checkboxes to the user edit screen so an administrator can give someone a second role, for example Editor plus Shop Manager.",
            "category": "users",
            "tags": [
                "roles",
                "users",
                "capabilities"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'edit_user_profile', function ( $user ) {\n    if ( ! current_user_can( 'promote_user', $user->ID ) ) {\n        return;\n    }\n\n    $roles   = get_editable_roles();\n    $primary = reset( $user->roles );\n    unset( $roles['administrator'], $roles[ $primary ] );\n\n    if ( ! $roles ) {\n        return;\n    }\n\n    wp_nonce_field( 'wpide_extra_roles_' . $user->ID, 'wpide_extra_roles_nonce' );\n    echo '<h2>Additional roles</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\">Also give this user</th><td><fieldset><legend class=\"screen-reader-text\">Additional roles</legend>';\n\n    foreach ( $roles as $role => $details ) {\n        printf(\n            '<label><input type=\"checkbox\" name=\"wpide_extra_roles[]\" value=\"%1$s\" %2$s> %3$s</label><br>',\n            esc_attr( $role ),\n            checked( in_array( $role, (array) $user->roles, true ), true, false ),\n            esc_html( translate_user_role( $details['name'] ) )\n        );\n    }\n\n    echo '<p class=\"description\">The main role is chosen in the Role menu above.</p></fieldset></td></tr></table>';\n} );\n\n// profile_update runs after WordPress has saved the main role.\nadd_action( 'profile_update', function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_extra_roles_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_extra_roles_nonce'] ), 'wpide_extra_roles_' . $user_id ) ) {\n        return;\n    }\n\n    if ( ! current_user_can( 'promote_user', $user_id ) ) {\n        return;\n    }\n\n    $user     = get_userdata( $user_id );\n    $roles    = get_editable_roles();\n    $primary  = reset( $user->roles );\n    $selected = isset( $_POST['wpide_extra_roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['wpide_extra_roles'] ) ) : array();\n    unset( $roles['administrator'], $roles[ $primary ] );\n\n    foreach ( array_keys( $roles ) as $role ) {\n        $has = in_array( $role, (array) $user->roles, true );\n\n        if ( in_array( $role, $selected, true ) && ! $has ) {\n            $user->add_role( $role );\n        } elseif ( ! in_array( $role, $selected, true ) && $has ) {\n            $user->remove_role( $role );\n        }\n    }\n} );",
            "requires": [],
            "notes": "Only roles the current user may assign are offered, and the Administrator role is never offered as an extra role."
        },
        {
            "id": "user-capabilities-panel",
            "version": 1,
            "title": "Show a User's Capabilities on Their Profile",
            "description": "Adds a collapsible list of everything a user is allowed to do to the user edit screen, which helps when checking what a role or plugin has granted.",
            "category": "users",
            "tags": [
                "capabilities",
                "roles",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_caps_panel = function ( $user ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $caps = array_keys( array_filter( (array) $user->allcaps ) );\n    $caps = array_diff( $caps, array_keys( wp_roles()->roles ) );\n    sort( $caps );\n\n    echo '<h2>Capabilities</h2><details><summary>' . esc_html( sprintf( '%d capabilities from the roles %s', count( $caps ), implode( ', ', $user->roles ) ) ) . '</summary>';\n    echo '<ul style=\"columns:3 14em;margin-top:1em\">';\n\n    foreach ( $caps as $cap ) {\n        echo '<li><code>' . esc_html( $cap ) . '</code></li>';\n    }\n\n    echo '</ul></details>';\n};\n\nadd_action( 'show_user_profile', $wpide_caps_panel );\nadd_action( 'edit_user_profile', $wpide_caps_panel );",
            "requires": [],
            "notes": "Visible to administrators only; it is read-only and changes nothing."
        },
        {
            "id": "editors-view-users-list",
            "version": 1,
            "title": "Let Editors See the Users List (Read-Only)",
            "description": "Lets editors open the Users screen to look people up, without being able to add, edit, promote or delete anyone.",
            "category": "users",
            "tags": [
                "editors",
                "users",
                "capabilities"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that may view the Users list.\n$wpide_list_users_roles = array( 'editor' );\n\nadd_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) use ( $wpide_list_users_roles ) {\n    if ( in_array( 'list_users', $caps, true ) && array_intersect( $wpide_list_users_roles, (array) $user->roles ) ) {\n        $allcaps['list_users'] = true;\n    }\n\n    return $allcaps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Grants only the list_users capability, in memory; turning the snippet off removes it."
        },
        {
            "id": "extra-capabilities-for-specific-users",
            "version": 1,
            "title": "Give Extra Capabilities to Specific Users",
            "description": "Grants chosen capabilities to individual user accounts, for example letting one editor manage plugins, without creating a new role.",
            "category": "users",
            "tags": [
                "capabilities",
                "users",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User ID => capabilities to add.\n$wpide_user_extra_caps = array(\n    2 => array( 'edit_theme_options', 'list_users' ),\n);\n\nadd_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) use ( $wpide_user_extra_caps ) {\n    if ( empty( $wpide_user_extra_caps[ $user->ID ] ) ) {\n        return $allcaps;\n    }\n\n    foreach ( $wpide_user_extra_caps[ $user->ID ] as $cap ) {\n        $allcaps[ $cap ] = true;\n    }\n\n    return $allcaps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Use user IDs, which never change. The capabilities are granted in memory and disappear when the snippet is off."
        },
        {
            "id": "search-users-by-full-name",
            "version": 1,
            "title": "Search Users by First and Last Name",
            "description": "Makes the search box on the Users screen also find people by their first name, last name or full name, not just username and email.",
            "category": "users",
            "tags": [
                "users",
                "search",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'users_list_table_query_args', function ( $args ) {\n    $term = isset( $args['search'] ) ? trim( $args['search'], '* ' ) : '';\n\n    if ( '' === $term ) {\n        return $args;\n    }\n\n    $parts = preg_split( '/\\s+/', $term );\n\n    if ( count( $parts ) > 1 ) {\n        $meta = array(\n            'relation' => 'AND',\n            array( 'key' => 'first_name', 'value' => $parts[0], 'compare' => 'LIKE' ),\n            array( 'key' => 'last_name', 'value' => end( $parts ), 'compare' => 'LIKE' ),\n        );\n    } else {\n        $meta = array(\n            'relation' => 'OR',\n            array( 'key' => 'first_name', 'value' => $term, 'compare' => 'LIKE' ),\n            array( 'key' => 'last_name', 'value' => $term, 'compare' => 'LIKE' ),\n        );\n    }\n\n    $by_name = get_users( array( 'fields' => 'ID', 'number' => 200, 'meta_query' => $meta, 'count_total' => false ) );\n\n    if ( ! $by_name ) {\n        return $args;\n    }\n\n    $by_core = get_users( array( 'fields' => 'ID', 'number' => 200, 'search' => $args['search'], 'count_total' => false ) );\n\n    $args['include'] = array_map( 'intval', array_unique( array_merge( $by_core, $by_name ) ) );\n    unset( $args['search'] );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Matches up to 200 users by name per search, which is plenty for a search box."
        },
        {
            "id": "recent-signups-users-view",
            "version": 1,
            "title": "\"New Sign-Ups\" View on the Users Screen",
            "description": "Adds a \"New this week\" link next to All / Administrator / Subscriber on the Users screen that lists only people who registered in the last 7 days.",
            "category": "users",
            "tags": [
                "users",
                "registration",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// How many days count as \"new\".\n$wpide_recent_days = 7;\n\nadd_filter( 'views_users', function ( $views ) use ( $wpide_recent_days ) {\n    $query = new WP_User_Query( array(\n        'fields'      => 'ID',\n        'number'      => 1,\n        'count_total' => true,\n        'date_query'  => array( array( 'after' => $wpide_recent_days . ' days ago', 'inclusive' => true ) ),\n    ) );\n\n    $current = ! empty( $_GET['wpide_recent'] );\n\n    $views['wpide_recent'] = sprintf(\n        '<a href=\"%1$s\"%2$s>New this week <span class=\"count\">(%3$s)</span></a>',\n        esc_url( add_query_arg( 'wpide_recent', 1, admin_url( 'users.php' ) ) ),\n        $current ? ' class=\"current\" aria-current=\"page\"' : '',\n        number_format_i18n( $query->get_total() )\n    );\n\n    return $views;\n} );\n\nadd_action( 'pre_get_users', function ( $query ) use ( $wpide_recent_days ) {\n    global $pagenow;\n\n    if ( 'users.php' !== $pagenow || empty( $_GET['wpide_recent'] ) || $query->get( 'fields' ) === 'ID' ) {\n        return;\n    }\n\n    $query->set( 'date_query', array( array( 'after' => $wpide_recent_days . ' days ago', 'inclusive' => true ) ) );\n} );",
            "requires": [],
            "notes": "Change $wpide_recent_days to show a different period."
        },
        {
            "id": "require-full-name-in-profile",
            "version": 1,
            "title": "Require a First and Last Name in Profiles",
            "description": "Stops users from saving their profile (or an administrator from saving a user) without a first and last name.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'user_profile_update_errors', function ( $errors, $update ) {\n    if ( ! isset( $_POST['first_name'], $_POST['last_name'] ) ) {\n        return;\n    }\n\n    if ( '' === trim( sanitize_text_field( wp_unslash( $_POST['first_name'] ) ) ) ) {\n        $errors->add( 'wpide_first_name', '<strong>Error:</strong> Please enter a first name.', array( 'form-field' => 'first_name' ) );\n    }\n\n    if ( '' === trim( sanitize_text_field( wp_unslash( $_POST['last_name'] ) ) ) ) {\n        $errors->add( 'wpide_last_name', '<strong>Error:</strong> Please enter a last name.', array( 'form-field' => 'last_name' ) );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Applies to the profile screens in wp-admin; existing users are only asked when they next save their profile."
        },
        {
            "id": "notify-admin-profile-changes",
            "version": 1,
            "title": "Email the Admin When Users Change Their Profile",
            "description": "Sends the site admin a short email listing what changed when a member updates their own name, email, website or bio.",
            "category": "users",
            "tags": [
                "users",
                "profile",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Fields to watch: user field or meta key => label.\n$wpide_watch_fields = array(\n    'user_email'   => 'Email',\n    'display_name' => 'Display name',\n    'user_url'     => 'Website',\n    'first_name'   => 'First name',\n    'last_name'    => 'Last name',\n    'description'  => 'Biographical info',\n);\n\n$wpide_profile_before = array();\n\n// Capture the old values before the update is written.\nadd_filter( 'wp_pre_insert_user_data', function ( $data, $update, $user_id ) use ( $wpide_watch_fields, &$wpide_profile_before ) {\n    if ( $update && $user_id && get_current_user_id() === (int) $user_id && ! current_user_can( 'edit_users' ) ) {\n        $user = get_userdata( $user_id );\n\n        foreach ( array_keys( $wpide_watch_fields ) as $field ) {\n            $wpide_profile_before[ $user_id ][ $field ] = (string) $user->get( $field );\n        }\n    }\n\n    return $data;\n}, 10, 3 );\n\nadd_action( 'profile_update', function ( $user_id ) use ( $wpide_watch_fields, &$wpide_profile_before ) {\n    if ( empty( $wpide_profile_before[ $user_id ] ) ) {\n        return;\n    }\n\n    clean_user_cache( $user_id );\n    $user    = get_userdata( $user_id );\n    $changes = array();\n\n    foreach ( $wpide_watch_fields as $field => $label ) {\n        $old = $wpide_profile_before[ $user_id ][ $field ];\n        $new = (string) $user->get( $field );\n\n        if ( $old !== $new ) {\n            $changes[] = sprintf( \"%s: \\\"%s\\\" -> \\\"%s\\\"\", $label, $old, $new );\n        }\n    }\n\n    unset( $wpide_profile_before[ $user_id ] );\n\n    if ( $changes ) {\n        wp_mail(\n            get_option( 'admin_email' ),\n            sprintf( '[%s] %s updated their profile', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $user->user_login ),\n            implode( \"\\n\", $changes ) . \"\\n\\n\" . admin_url( 'user-edit.php?user_id=' . $user_id )\n        );\n    }\n}, 20 );",
            "requires": [],
            "notes": "Only changes users make to their own account are reported; edits by administrators are ignored."
        },
        {
            "id": "suspend-user-accounts",
            "version": 1,
            "title": "Suspend User Accounts",
            "description": "Adds a \"Suspend this account\" checkbox to the user edit screen that logs the person out everywhere and blocks them from logging in until it is unticked.",
            "category": "users",
            "tags": [
                "users",
                "security",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edit_user_profile', function ( $user ) {\n    if ( ! current_user_can( 'edit_user', $user->ID ) || user_can( $user, 'manage_options' ) ) {\n        return;\n    }\n\n    wp_nonce_field( 'wpide_suspend_' . $user->ID, 'wpide_suspend_nonce' );\n    printf(\n        '<h2>Account status</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\">Suspend</th><td><label><input type=\"checkbox\" name=\"wpide_suspended\" value=\"1\" %s> Suspend this account (logs the user out and blocks logging in)</label></td></tr></table>',\n        checked( (bool) get_user_meta( $user->ID, 'wpide_suspended', true ), true, false )\n    );\n} );\n\nadd_action( 'edit_user_profile_update', function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_suspend_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_suspend_nonce'] ), 'wpide_suspend_' . $user_id ) ) {\n        return;\n    }\n\n    if ( ! current_user_can( 'edit_user', $user_id ) || user_can( $user_id, 'manage_options' ) ) {\n        return;\n    }\n\n    if ( ! empty( $_POST['wpide_suspended'] ) ) {\n        update_user_meta( $user_id, 'wpide_suspended', 1 );\n        WP_Session_Tokens::get_instance( $user_id )->destroy_all();\n    } else {\n        delete_user_meta( $user_id, 'wpide_suspended' );\n    }\n} );\n\nadd_filter( 'authenticate', function ( $user ) {\n    if ( $user instanceof WP_User && get_user_meta( $user->ID, 'wpide_suspended', true ) ) {\n        return new WP_Error( 'wpide_suspended', '<strong>Error:</strong> This account has been suspended. Please contact the site owner.' );\n    }\n\n    return $user;\n}, 60 );\n\nadd_filter( 'user_row_actions', function ( $actions, $user ) {\n    if ( get_user_meta( $user->ID, 'wpide_suspended', true ) ) {\n        $actions = array( 'wpide_suspended' => '<strong style=\"color:#b32d2e\">Suspended</strong>' ) + $actions;\n    }\n\n    return $actions;\n}, 10, 2 );",
            "requires": [],
            "notes": "Administrators cannot be suspended, and the Users list shows \"Suspended\" under blocked accounts."
        },
        {
            "id": "account-expiry-date",
            "version": 1,
            "title": "Expiry Dates for User Accounts",
            "description": "Lets administrators set an expiry date on any account; after that date the user is logged out and can no longer log in, which suits trial or temporary access.",
            "category": "memberships",
            "tags": [
                "users",
                "expiry",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edit_user_profile', function ( $user ) {\n    if ( ! current_user_can( 'edit_user', $user->ID ) ) {\n        return;\n    }\n\n    wp_nonce_field( 'wpide_expiry_' . $user->ID, 'wpide_expiry_nonce' );\n    printf(\n        '<h2>Access</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\"><label for=\"wpide-expires\">Account expires on</label></th><td><input type=\"date\" id=\"wpide-expires\" name=\"wpide_expires\" value=\"%s\"><p class=\"description\">Leave empty for no expiry.</p></td></tr></table>',\n        esc_attr( get_user_meta( $user->ID, 'wpide_expires', true ) )\n    );\n} );\n\nadd_action( 'edit_user_profile_update', function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_expiry_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_expiry_nonce'] ), 'wpide_expiry_' . $user_id ) || ! current_user_can( 'edit_user', $user_id ) ) {\n        return;\n    }\n\n    $date = isset( $_POST['wpide_expires'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_expires'] ) ) : '';\n\n    if ( preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $date ) ) {\n        update_user_meta( $user_id, 'wpide_expires', $date );\n    } else {\n        delete_user_meta( $user_id, 'wpide_expires' );\n    }\n} );\n\n$wpide_is_expired = function ( $user_id ) {\n    $date = get_user_meta( $user_id, 'wpide_expires', true );\n\n    return $date && wp_date( 'Y-m-d' ) > $date;\n};\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_is_expired ) {\n    if ( $user instanceof WP_User && $wpide_is_expired( $user->ID ) ) {\n        return new WP_Error( 'wpide_expired', '<strong>Error:</strong> Your access has expired. Please contact us to renew it.' );\n    }\n\n    return $user;\n}, 60 );\n\n// Log out people who are already signed in when their date passes.\nadd_action( 'init', function () use ( $wpide_is_expired ) {\n    $user_id = get_current_user_id();\n\n    if ( $user_id && ! user_can( $user_id, 'manage_options' ) && $wpide_is_expired( $user_id ) ) {\n        wp_logout();\n    }\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_expires'] = 'Expires';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) use ( $wpide_is_expired ) {\n    if ( 'wpide_expires' !== $column ) {\n        return $output;\n    }\n\n    $date = get_user_meta( $user_id, 'wpide_expires', true );\n\n    if ( ! $date ) {\n        return '&mdash;';\n    }\n\n    return $wpide_is_expired( $user_id ) ? '<span style=\"color:#b32d2e\">Expired ' . esc_html( $date ) . '</span>' : esc_html( $date );\n}, 10, 3 );",
            "requires": [],
            "notes": "Leave the date empty for accounts that never expire. Dates use the site's time zone."
        },
        {
            "id": "choose-password-on-registration",
            "version": 1,
            "title": "Let New Users Choose Their Password When Registering",
            "description": "Adds password and confirm-password fields to the WordPress registration form, so new members pick their own password instead of waiting for a \"set your password\" email.",
            "category": "users",
            "tags": [
                "registration",
                "password",
                "signup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum password length.\n$wpide_reg_min_length = 10;\n// Log the new member in right after registering.\n$wpide_reg_auto_login = false;\n\nadd_action( 'register_form', function () use ( $wpide_reg_min_length ) {\n    ?>\n    <p>\n        <label for=\"wpide_pass1\">Password</label>\n        <input type=\"password\" name=\"wpide_pass1\" id=\"wpide_pass1\" class=\"input\" autocomplete=\"new-password\" minlength=\"<?php echo (int) $wpide_reg_min_length; ?>\" required>\n    </p>\n    <p>\n        <label for=\"wpide_pass2\">Confirm password</label>\n        <input type=\"password\" name=\"wpide_pass2\" id=\"wpide_pass2\" class=\"input\" autocomplete=\"new-password\" required>\n    </p>\n    <?php\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) use ( $wpide_reg_min_length ) {\n    if ( ! isset( $_POST['wpide_pass1'] ) ) {\n        return $errors;\n    }\n\n    $pass1 = (string) wp_unslash( $_POST['wpide_pass1'] );\n    $pass2 = isset( $_POST['wpide_pass2'] ) ? (string) wp_unslash( $_POST['wpide_pass2'] ) : '';\n\n    if ( strlen( $pass1 ) < $wpide_reg_min_length ) {\n        $errors->add( 'wpide_pass_short', sprintf( '<strong>Error:</strong> Your password must be at least %d characters long.', $wpide_reg_min_length ) );\n    } elseif ( $pass1 !== $pass2 ) {\n        $errors->add( 'wpide_pass_mismatch', '<strong>Error:</strong> The two passwords do not match.' );\n    }\n\n    return $errors;\n} );\n\n// register_new_user fires once the account exists and errors have passed.\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_reg_auto_login ) {\n    if ( empty( $_POST['wpide_pass1'] ) ) {\n        return;\n    }\n\n    wp_set_password( (string) wp_unslash( $_POST['wpide_pass1'] ), $user_id );\n\n    // They know their password, so only tell the admin about the new account.\n    remove_action( 'register_new_user', 'wp_send_new_user_notifications' );\n    wp_new_user_notification( $user_id, null, 'admin' );\n\n    if ( $wpide_reg_auto_login ) {\n        wp_set_current_user( $user_id );\n        wp_set_auth_cookie( $user_id );\n    }\n}, 5 );\n\nadd_filter( 'registration_redirect', function ( $redirect ) use ( $wpide_reg_auto_login ) {\n    if ( empty( $_POST['wpide_pass1'] ) ) {\n        return $redirect;\n    }\n\n    return $wpide_reg_auto_login ? home_url( '/' ) : add_query_arg( 'wpide_registered', 1, wp_login_url() );\n} );\n\nadd_filter( 'login_message', function ( $message ) {\n    if ( isset( $_GET['wpide_registered'] ) ) {\n        $message .= '<p class=\"message\">Registration complete. You can log in with your new password now.</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "Set $wpide_reg_auto_login to true to log new members in straight away. Only the wp-login.php registration form is changed."
        },
        {
            "id": "registration-invite-codes",
            "version": 1,
            "title": "Require an Invite Code to Register",
            "description": "Adds an \"Invite code\" field to the registration form and only lets people sign up with one of your codes, each with an optional limit on how many times it can be used.",
            "category": "users",
            "tags": [
                "registration",
                "invite",
                "signup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Code => maximum number of sign-ups (0 = unlimited).\n$wpide_invite_codes = array(\n    'WELCOME2026' => 0,\n    'VIP-TEAM'    => 25,\n);\n\n$wpide_find_code = function ( $entered ) use ( $wpide_invite_codes ) {\n    foreach ( $wpide_invite_codes as $code => $max ) {\n        if ( hash_equals( strtoupper( $code ), strtoupper( $entered ) ) ) {\n            return $code;\n        }\n    }\n\n    return '';\n};\n\nadd_action( 'register_form', function () {\n    $value = isset( $_POST['wpide_invite'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_invite'] ) ) : '';\n    printf( '<p><label for=\"wpide_invite\">Invite code</label><input type=\"text\" name=\"wpide_invite\" id=\"wpide_invite\" class=\"input\" value=\"%s\" autocomplete=\"off\" required></p>', esc_attr( $value ) );\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) use ( $wpide_invite_codes, $wpide_find_code ) {\n    $entered = isset( $_POST['wpide_invite'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['wpide_invite'] ) ) ) : '';\n    $code    = '' !== $entered ? $wpide_find_code( $entered ) : '';\n\n    if ( ! $code ) {\n        $errors->add( 'wpide_invite', '<strong>Error:</strong> Please enter a valid invite code.' );\n        return $errors;\n    }\n\n    $uses = (array) get_option( 'wpide_invite_code_uses', array() );\n\n    if ( $wpide_invite_codes[ $code ] > 0 && ( $uses[ $code ] ?? 0 ) >= $wpide_invite_codes[ $code ] ) {\n        $errors->add( 'wpide_invite_used', '<strong>Error:</strong> This invite code has been used up.' );\n    }\n\n    return $errors;\n} );\n\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_find_code ) {\n    $code = isset( $_POST['wpide_invite'] ) ? $wpide_find_code( trim( sanitize_text_field( wp_unslash( $_POST['wpide_invite'] ) ) ) ) : '';\n\n    if ( $code ) {\n        $uses          = (array) get_option( 'wpide_invite_code_uses', array() );\n        $uses[ $code ] = ( $uses[ $code ] ?? 0 ) + 1;\n        update_option( 'wpide_invite_code_uses', $uses, false );\n        update_user_meta( $user_id, 'wpide_invite_code', $code );\n    }\n} );",
            "requires": [],
            "notes": "Codes are not case-sensitive. Use 0 for unlimited uses; usage counts are kept in the wpide_invite_code_uses option."
        },
        {
            "id": "registration-limit-per-ip",
            "version": 1,
            "title": "Limit Sign-Ups per IP Address",
            "description": "Allows only a few new accounts per IP address per day, which stops bots and people from creating piles of accounts.",
            "category": "users",
            "tags": [
                "registration",
                "spam",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Accounts allowed per IP address in the window below.\n$wpide_reg_ip_limit  = 3;\n$wpide_reg_ip_window = DAY_IN_SECONDS;\n\n$wpide_reg_ip_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    return 'wpide_reg_ip_' . md5( $ip );\n};\n\nadd_filter( 'registration_errors', function ( $errors ) use ( $wpide_reg_ip_limit, $wpide_reg_ip_key ) {\n    if ( (int) get_transient( $wpide_reg_ip_key() ) >= $wpide_reg_ip_limit ) {\n        $errors->add( 'wpide_reg_ip', '<strong>Error:</strong> Too many accounts have been created from your network today. Please try again tomorrow.' );\n    }\n\n    return $errors;\n}, 5 );\n\nadd_action( 'register_new_user', function () use ( $wpide_reg_ip_window, $wpide_reg_ip_key ) {\n    $key = $wpide_reg_ip_key();\n    set_transient( $key, (int) get_transient( $key ) + 1, $wpide_reg_ip_window );\n} );",
            "requires": [],
            "notes": "Behind a proxy or CDN make sure REMOTE_ADDR holds the visitor's real IP, or everyone shares one limit."
        },
        {
            "id": "registration-account-type",
            "version": 1,
            "title": "Let New Users Pick an Account Type",
            "description": "Adds an \"I am signing up as\" choice to the registration form that gives the new account one of the roles you allow, for example Reader or Writer.",
            "category": "users",
            "tags": [
                "registration",
                "roles",
                "signup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role ID => label shown on the form. The first one is the default.\n$wpide_signup_roles = array(\n    'subscriber'  => 'Reader',\n    'contributor' => 'Writer',\n);\n\n$wpide_safe_roles = function () use ( $wpide_signup_roles ) {\n    $danger = array( 'manage_options', 'edit_users', 'promote_users', 'activate_plugins', 'edit_others_posts', 'unfiltered_html' );\n    $safe   = array();\n\n    foreach ( $wpide_signup_roles as $role => $label ) {\n        $object = get_role( $role );\n\n        if ( $object && ! array_intersect( $danger, array_keys( array_filter( $object->capabilities ) ) ) ) {\n            $safe[ $role ] = $label;\n        }\n    }\n\n    return $safe;\n};\n\nadd_action( 'register_form', function () use ( $wpide_safe_roles ) {\n    $roles   = $wpide_safe_roles();\n    $current = isset( $_POST['wpide_signup_role'] ) ? sanitize_key( $_POST['wpide_signup_role'] ) : key( $roles );\n\n    if ( count( $roles ) < 2 ) {\n        return;\n    }\n\n    echo '<fieldset style=\"margin-bottom:16px\"><legend>I am signing up as</legend>';\n\n    foreach ( $roles as $role => $label ) {\n        printf( '<label style=\"display:block\"><input type=\"radio\" name=\"wpide_signup_role\" value=\"%1$s\" %2$s> %3$s</label>', esc_attr( $role ), checked( $current, $role, false ), esc_html( $label ) );\n    }\n\n    echo '</fieldset>';\n} );\n\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_safe_roles ) {\n    $role = isset( $_POST['wpide_signup_role'] ) ? sanitize_key( $_POST['wpide_signup_role'] ) : '';\n\n    if ( $role && array_key_exists( $role, $wpide_safe_roles() ) ) {\n        ( new WP_User( $user_id ) )->set_role( $role );\n    }\n} );",
            "requires": [],
            "notes": "Roles that can manage options, users, plugins or other people's posts are refused even if you list them, so the form can never hand out admin rights."
        },
        {
            "id": "registration-referral-source",
            "version": 1,
            "title": "\"How Did You Hear About Us?\" on Registration",
            "description": "Asks new members how they found your site when they register and shows the answer in a column on the Users screen.",
            "category": "users",
            "tags": [
                "registration",
                "marketing",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Value => label.\n$wpide_referral_options = array(\n    'search'  => 'Search engine',\n    'social'  => 'Social media',\n    'friend'  => 'A friend or colleague',\n    'podcast' => 'Podcast or video',\n    'other'   => 'Somewhere else',\n);\n\nadd_action( 'register_form', function () use ( $wpide_referral_options ) {\n    echo '<p><label for=\"wpide_referral\">How did you hear about us?</label><select name=\"wpide_referral\" id=\"wpide_referral\" class=\"input\"><option value=\"\">Choose one (optional)</option>';\n\n    foreach ( $wpide_referral_options as $value => $label ) {\n        printf( '<option value=\"%1$s\">%2$s</option>', esc_attr( $value ), esc_html( $label ) );\n    }\n\n    echo '</select></p>';\n} );\n\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_referral_options ) {\n    $value = isset( $_POST['wpide_referral'] ) ? sanitize_key( $_POST['wpide_referral'] ) : '';\n\n    if ( isset( $wpide_referral_options[ $value ] ) ) {\n        update_user_meta( $user_id, 'wpide_referral', $value );\n    }\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_referral'] = 'Found us via';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) use ( $wpide_referral_options ) {\n    if ( 'wpide_referral' === $column ) {\n        $value  = get_user_meta( $user_id, 'wpide_referral', true );\n        $output = isset( $wpide_referral_options[ $value ] ) ? esc_html( $wpide_referral_options[ $value ] ) : '&mdash;';\n    }\n\n    return $output;\n}, 10, 3 );",
            "requires": [],
            "notes": "Edit $wpide_referral_options to change the choices; answers are stored in the wpide_referral user meta."
        },
        {
            "id": "role-by-email-domain",
            "version": 1,
            "title": "Assign a Role Based on the Email Domain",
            "description": "Gives people who register with certain email domains a different role automatically, for example staff at your company become Contributors.",
            "category": "users",
            "tags": [
                "registration",
                "roles",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Email domain => role.\n$wpide_domain_roles = array(\n    'example.com' => 'contributor',\n);\n\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_domain_roles ) {\n    $user   = get_userdata( $user_id );\n    $domain = $user ? strtolower( substr( strrchr( $user->user_email, '@' ), 1 ) ) : '';\n\n    if ( isset( $wpide_domain_roles[ $domain ] ) && get_role( $wpide_domain_roles[ $domain ] ) && ! get_role( $wpide_domain_roles[ $domain ] )->has_cap( 'promote_users' ) ) {\n        $user->set_role( $wpide_domain_roles[ $domain ] );\n    }\n} );",
            "requires": [],
            "notes": "Only applies to self-registration, never to accounts created by an administrator. Avoid roles that can publish or manage other users."
        },
        {
            "id": "username-rules-on-registration",
            "version": 1,
            "title": "Username Rules for New Registrations",
            "description": "Requires new usernames to be a sensible length, use only letters, numbers, dots, dashes and underscores, and not be just a number.",
            "category": "users",
            "tags": [
                "registration",
                "usernames",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_username_min = 4;\n$wpide_username_max = 30;\n\nadd_filter( 'registration_errors', function ( $errors, $login ) use ( $wpide_username_min, $wpide_username_max ) {\n    $length = strlen( $login );\n\n    if ( $length < $wpide_username_min || $length > $wpide_username_max ) {\n        $errors->add( 'wpide_username_length', sprintf( '<strong>Error:</strong> Usernames must be between %d and %d characters.', $wpide_username_min, $wpide_username_max ) );\n    }\n\n    if ( ! preg_match( '/^[A-Za-z0-9._-]+$/', $login ) ) {\n        $errors->add( 'wpide_username_chars', '<strong>Error:</strong> Usernames may only use letters, numbers, dots, dashes and underscores.' );\n    } elseif ( ctype_digit( $login ) ) {\n        $errors->add( 'wpide_username_digits', '<strong>Error:</strong> Usernames cannot be only numbers.' );\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [],
            "notes": "Existing usernames are not affected. Adjust the length limits at the top."
        },
        {
            "id": "email-verification-for-new-users",
            "version": 1,
            "title": "Verify Email Addresses of New Users",
            "description": "Sends new members a confirmation link and blocks them from logging in until they click it, which keeps fake and mistyped email addresses out.",
            "category": "users",
            "tags": [
                "registration",
                "email",
                "verification"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Hours a verification link stays valid.\n$wpide_verify_hours = 48;\n\n$wpide_send_verify = function ( $user_id ) {\n    $user  = get_userdata( $user_id );\n    $token = wp_generate_password( 32, false );\n\n    update_user_meta( $user_id, 'wpide_email_verify', array( 'hash' => wp_hash( $token ), 'time' => time() ) );\n\n    $link = add_query_arg( array( 'wpide_verify' => $user_id, 'key' => $token ), wp_login_url() );\n\n    wp_mail(\n        $user->user_email,\n        sprintf( '[%s] Please confirm your email address', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf( \"Hi %s,\\n\\nPlease confirm your email address to activate your account:\\n%s\\n\\nIf you did not sign up, you can ignore this email.\", $user->display_name, $link )\n    );\n};\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_send_verify ) {\n    // Skip accounts made by administrators, WP-CLI or scheduled tasks.\n    if ( current_user_can( 'create_users' ) || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {\n        return;\n    }\n\n    $wpide_send_verify( $user_id );\n}, 20 );\n\nadd_filter( 'authenticate', function ( $user ) {\n    if ( $user instanceof WP_User && get_user_meta( $user->ID, 'wpide_email_verify', true ) ) {\n        return new WP_Error( 'wpide_unverified', '<strong>Please confirm your email address.</strong> We sent you a link when you signed up.' );\n    }\n\n    return $user;\n}, 60 );\n\nadd_action( 'login_init', function () use ( $wpide_verify_hours, $wpide_send_verify ) {\n    if ( empty( $_GET['wpide_verify'] ) || empty( $_GET['key'] ) ) {\n        return;\n    }\n\n    $user_id = absint( $_GET['wpide_verify'] );\n    $data    = get_user_meta( $user_id, 'wpide_email_verify', true );\n    $key     = sanitize_text_field( wp_unslash( $_GET['key'] ) );\n\n    if ( ! is_array( $data ) ) {\n        wp_safe_redirect( add_query_arg( 'wpide_verified', 'already', wp_login_url() ) );\n        exit;\n    }\n\n    if ( time() - (int) $data['time'] > $wpide_verify_hours * HOUR_IN_SECONDS ) {\n        $wpide_send_verify( $user_id );\n        wp_safe_redirect( add_query_arg( 'wpide_verified', 'expired', wp_login_url() ) );\n        exit;\n    }\n\n    if ( hash_equals( $data['hash'], wp_hash( $key ) ) ) {\n        delete_user_meta( $user_id, 'wpide_email_verify' );\n        wp_safe_redirect( add_query_arg( 'wpide_verified', 'yes', wp_login_url() ) );\n        exit;\n    }\n} );\n\nadd_filter( 'login_message', function ( $message ) {\n    $state    = isset( $_GET['wpide_verified'] ) ? sanitize_key( $_GET['wpide_verified'] ) : '';\n    $messages = array(\n        'yes'     => 'Thanks, your email address is confirmed. You can log in now.',\n        'already' => 'This link was already used. You can log in.',\n        'expired' => 'That link had expired, so we have emailed you a new one.',\n    );\n\n    if ( isset( $messages[ $state ] ) ) {\n        $message .= '<p class=\"message\">' . esc_html( $messages[ $state ] ) . '</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "Most useful when members choose their own password at sign-up; accounts created by administrators and users who existed before activation are treated as verified."
        },
        {
            "id": "frontend-registration-form",
            "version": 1,
            "title": "Front-End Registration Form Shortcode",
            "description": "Adds a [wpide_register] shortcode that shows a sign-up form on any page, styled by your theme, using WordPress's normal registration process and emails.",
            "category": "users",
            "tags": [
                "registration",
                "shortcode",
                "form"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_register_errors = null;\n\nadd_action( 'template_redirect', function () use ( &$wpide_register_errors ) {\n    if ( empty( $_POST['wpide_register_nonce'] ) || is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_register_nonce'] ), 'wpide_register' ) ) {\n        $wpide_register_errors = new WP_Error( 'nonce', 'The form expired. Please try again.' );\n        return;\n    }\n\n    if ( ! get_option( 'users_can_register' ) ) {\n        $wpide_register_errors = new WP_Error( 'closed', 'Registration is currently closed.' );\n        return;\n    }\n\n    $login = isset( $_POST['wpide_user_login'] ) ? sanitize_user( wp_unslash( $_POST['wpide_user_login'] ) ) : '';\n    $email = isset( $_POST['wpide_user_email'] ) ? sanitize_email( wp_unslash( $_POST['wpide_user_email'] ) ) : '';\n    $result = register_new_user( $login, $email );\n\n    if ( is_wp_error( $result ) ) {\n        $wpide_register_errors = $result;\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 'registered', 1, remove_query_arg( 'registered' ) ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_register', function () use ( &$wpide_register_errors ) {\n    if ( is_user_logged_in() ) {\n        return '<p class=\"wpide-register-note\">You already have an account.</p>';\n    }\n\n    if ( ! get_option( 'users_can_register' ) ) {\n        return '<p class=\"wpide-register-note\">Registration is currently closed.</p>';\n    }\n\n    if ( isset( $_GET['registered'] ) ) {\n        return '<p class=\"wpide-register-success\" role=\"status\">Thanks for signing up! Check your email for a link to set your password.</p>';\n    }\n\n    $login = isset( $_POST['wpide_user_login'] ) ? sanitize_user( wp_unslash( $_POST['wpide_user_login'] ) ) : '';\n    $email = isset( $_POST['wpide_user_email'] ) ? sanitize_email( wp_unslash( $_POST['wpide_user_email'] ) ) : '';\n\n    ob_start();\n\n    if ( is_wp_error( $wpide_register_errors ) ) {\n        echo '<div class=\"wpide-register-errors\" role=\"alert\">';\n        foreach ( $wpide_register_errors->get_error_messages() as $message ) {\n            echo '<p>' . wp_kses( $message, array( 'strong' => array() ) ) . '</p>';\n        }\n        echo '</div>';\n    }\n    ?>\n    <form class=\"wpide-register-form\" method=\"post\">\n        <p><label for=\"wpide-user-login\">Username</label><br>\n        <input type=\"text\" id=\"wpide-user-login\" name=\"wpide_user_login\" value=\"<?php echo esc_attr( $login ); ?>\" autocomplete=\"username\" required></p>\n        <p><label for=\"wpide-user-email\">Email</label><br>\n        <input type=\"email\" id=\"wpide-user-email\" name=\"wpide_user_email\" value=\"<?php echo esc_attr( $email ); ?>\" autocomplete=\"email\" required></p>\n        <?php do_action( 'register_form' ); ?>\n        <?php wp_nonce_field( 'wpide_register', 'wpide_register_nonce' ); ?>\n        <p><button type=\"submit\">Create account</button></p>\n    </form>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [],
            "notes": "\"Anyone can register\" must be enabled in Settings > General. Other registration snippets (invite codes, account types, verification) also apply because it uses the core registration function."
        },
        {
            "id": "registration-closed-custom-message",
            "version": 1,
            "title": "Friendly Message When Registration Is Closed",
            "description": "Replaces the bare \"User registration is currently not allowed\" screen with a friendly message and a link back to your site or a waiting-list page.",
            "category": "users",
            "tags": [
                "registration",
                "login",
                "message"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Optional page to send people to instead.\n$wpide_waitlist_url = '';\n\nadd_action( 'login_form_register', function () use ( $wpide_waitlist_url ) {\n    if ( get_option( 'users_can_register' ) || ! function_exists( 'login_header' ) ) {\n        return;\n    }\n\n    if ( $wpide_waitlist_url ) {\n        wp_safe_redirect( $wpide_waitlist_url );\n        exit;\n    }\n\n    login_header( 'Registration closed' );\n    echo '<div class=\"message\"><p>We are not taking new sign-ups right now. Please check back soon.</p></div>';\n    echo '<p id=\"backtoblog\"><a href=\"' . esc_url( home_url( '/' ) ) . '\">&larr; Back to ' . esc_html( get_bloginfo( 'name' ) ) . '</a></p>';\n    login_footer();\n    exit;\n} );",
            "requires": [],
            "notes": "Set $wpide_waitlist_url to a page where people can leave their email, or leave it empty to link to the homepage."
        },
        {
            "id": "frontend-edit-profile-form",
            "version": 1,
            "title": "Front-End Profile Editing Form",
            "description": "Adds a [wpide_edit_profile] shortcode where logged-in members can update their name, display name, email, website and bio without visiting wp-admin.",
            "category": "users",
            "tags": [
                "profile",
                "shortcode",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_profile_errors = null;\n\nadd_action( 'template_redirect', function () use ( &$wpide_profile_errors ) {\n    if ( empty( $_POST['wpide_profile_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_profile_nonce'] ), 'wpide_edit_profile' ) ) {\n        $wpide_profile_errors = new WP_Error( 'nonce', 'The form expired. Please try again.' );\n        return;\n    }\n\n    $user  = wp_get_current_user();\n    $email = isset( $_POST['wpide_email'] ) ? sanitize_email( wp_unslash( $_POST['wpide_email'] ) ) : '';\n    $errors = new WP_Error();\n\n    if ( ! is_email( $email ) ) {\n        $errors->add( 'email', 'Please enter a valid email address.' );\n    } elseif ( strtolower( $email ) !== strtolower( $user->user_email ) && email_exists( $email ) ) {\n        $errors->add( 'email', 'That email address is already used by another account.' );\n    }\n\n    if ( $errors->has_errors() ) {\n        $wpide_profile_errors = $errors;\n        return;\n    }\n\n    $first   = isset( $_POST['wpide_first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_first_name'] ) ) : '';\n    $last    = isset( $_POST['wpide_last_name'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_last_name'] ) ) : '';\n    $display = isset( $_POST['wpide_display_name'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_display_name'] ) ) : '';\n    $allowed = array_filter( array_unique( array( $user->user_login, $user->nickname, $first, $last, trim( \"$first $last\" ), trim( \"$last $first\" ) ) ) );\n\n    $result = wp_update_user( array(\n        'ID'           => $user->ID,\n        'first_name'   => $first,\n        'last_name'    => $last,\n        'display_name' => in_array( $display, $allowed, true ) ? $display : $user->display_name,\n        'user_email'   => $email,\n        'user_url'     => isset( $_POST['wpide_url'] ) ? esc_url_raw( wp_unslash( $_POST['wpide_url'] ) ) : '',\n        'description'  => isset( $_POST['wpide_description'] ) ? sanitize_textarea_field( wp_unslash( $_POST['wpide_description'] ) ) : '',\n    ) );\n\n    if ( is_wp_error( $result ) ) {\n        $wpide_profile_errors = $result;\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 'profile-updated', 1 ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_edit_profile', function () use ( &$wpide_profile_errors ) {\n    if ( ! is_user_logged_in() ) {\n        return '<p>Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to edit your profile.</p>';\n    }\n\n    $user    = wp_get_current_user();\n    $options = array_filter( array_unique( array( $user->user_login, $user->nickname, $user->first_name, $user->last_name, trim( $user->first_name . ' ' . $user->last_name ) ) ) );\n\n    ob_start();\n\n    if ( isset( $_GET['profile-updated'] ) ) {\n        echo '<p class=\"wpide-profile-notice\" role=\"status\">Your profile has been updated.</p>';\n    }\n\n    if ( is_wp_error( $wpide_profile_errors ) ) {\n        echo '<div class=\"wpide-profile-errors\" role=\"alert\"><p>' . implode( '</p><p>', array_map( 'esc_html', $wpide_profile_errors->get_error_messages() ) ) . '</p></div>';\n    }\n    ?>\n    <form class=\"wpide-profile-form\" method=\"post\">\n        <p><label for=\"wpide-first-name\">First name</label><br><input type=\"text\" id=\"wpide-first-name\" name=\"wpide_first_name\" value=\"<?php echo esc_attr( $user->first_name ); ?>\" autocomplete=\"given-name\"></p>\n        <p><label for=\"wpide-last-name\">Last name</label><br><input type=\"text\" id=\"wpide-last-name\" name=\"wpide_last_name\" value=\"<?php echo esc_attr( $user->last_name ); ?>\" autocomplete=\"family-name\"></p>\n        <p><label for=\"wpide-display-name\">Show my name as</label><br><select id=\"wpide-display-name\" name=\"wpide_display_name\">\n            <?php foreach ( $options as $option ) : ?>\n                <option value=\"<?php echo esc_attr( $option ); ?>\" <?php selected( $user->display_name, $option ); ?>><?php echo esc_html( $option ); ?></option>\n            <?php endforeach; ?>\n        </select></p>\n        <p><label for=\"wpide-email\">Email</label><br><input type=\"email\" id=\"wpide-email\" name=\"wpide_email\" value=\"<?php echo esc_attr( $user->user_email ); ?>\" autocomplete=\"email\" required></p>\n        <p><label for=\"wpide-url\">Website</label><br><input type=\"url\" id=\"wpide-url\" name=\"wpide_url\" value=\"<?php echo esc_attr( $user->user_url ); ?>\" autocomplete=\"url\"></p>\n        <p><label for=\"wpide-description\">About you</label><br><textarea id=\"wpide-description\" name=\"wpide_description\" rows=\"4\"><?php echo esc_textarea( $user->description ); ?></textarea></p>\n        <?php wp_nonce_field( 'wpide_edit_profile', 'wpide_profile_nonce' ); ?>\n        <p><button type=\"submit\">Save profile</button></p>\n    </form>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [],
            "notes": "Saving is protected by a nonce and only ever changes the logged-in user's own account. WordPress emails the old address when the email changes."
        },
        {
            "id": "frontend-change-password-form",
            "version": 1,
            "title": "Front-End Change Password Form",
            "description": "Adds a [wpide_change_password] shortcode where members change their password by entering the current one and the new one twice, and stay logged in afterwards.",
            "category": "users",
            "tags": [
                "password",
                "shortcode",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum length for new passwords.\n$wpide_new_pass_min = 10;\n$wpide_pass_error   = '';\n\nadd_action( 'template_redirect', function () use ( $wpide_new_pass_min, &$wpide_pass_error ) {\n    if ( empty( $_POST['wpide_pass_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_pass_nonce'] ), 'wpide_change_password' ) ) {\n        $wpide_pass_error = 'The form expired. Please try again.';\n        return;\n    }\n\n    $user    = wp_get_current_user();\n    $current = isset( $_POST['wpide_current_pass'] ) ? (string) wp_unslash( $_POST['wpide_current_pass'] ) : '';\n    $new     = isset( $_POST['wpide_new_pass'] ) ? (string) wp_unslash( $_POST['wpide_new_pass'] ) : '';\n    $confirm = isset( $_POST['wpide_confirm_pass'] ) ? (string) wp_unslash( $_POST['wpide_confirm_pass'] ) : '';\n\n    if ( ! wp_check_password( $current, $user->user_pass, $user->ID ) ) {\n        $wpide_pass_error = 'Your current password is not correct.';\n    } elseif ( strlen( $new ) < $wpide_new_pass_min ) {\n        $wpide_pass_error = sprintf( 'The new password must be at least %d characters.', $wpide_new_pass_min );\n    } elseif ( $new !== $confirm ) {\n        $wpide_pass_error = 'The new passwords do not match.';\n    }\n\n    if ( $wpide_pass_error ) {\n        return;\n    }\n\n    wp_set_password( $new, $user->ID );\n    // Setting the password ends every session, so start a fresh one here.\n    wp_set_current_user( $user->ID );\n    wp_set_auth_cookie( $user->ID );\n\n    wp_safe_redirect( add_query_arg( 'password-changed', 1 ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_change_password', function () use ( $wpide_new_pass_min, &$wpide_pass_error ) {\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    ob_start();\n\n    if ( isset( $_GET['password-changed'] ) ) {\n        echo '<p class=\"wpide-pass-notice\" role=\"status\">Your password has been changed.</p>';\n    } elseif ( $wpide_pass_error ) {\n        echo '<p class=\"wpide-pass-error\" role=\"alert\">' . esc_html( $wpide_pass_error ) . '</p>';\n    }\n    ?>\n    <form class=\"wpide-change-password\" method=\"post\">\n        <p><label for=\"wpide-current-pass\">Current password</label><br><input type=\"password\" id=\"wpide-current-pass\" name=\"wpide_current_pass\" autocomplete=\"current-password\" required></p>\n        <p><label for=\"wpide-new-pass\">New password</label><br><input type=\"password\" id=\"wpide-new-pass\" name=\"wpide_new_pass\" autocomplete=\"new-password\" minlength=\"<?php echo (int) $wpide_new_pass_min; ?>\" required></p>\n        <p><label for=\"wpide-confirm-pass\">Confirm new password</label><br><input type=\"password\" id=\"wpide-confirm-pass\" name=\"wpide_confirm_pass\" autocomplete=\"new-password\" required></p>\n        <?php wp_nonce_field( 'wpide_change_password', 'wpide_pass_nonce' ); ?>\n        <p><button type=\"submit\">Change password</button></p>\n    </form>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [],
            "notes": "Other devices are logged out when the password changes, which is what WordPress does on the profile screen too."
        },
        {
            "id": "account-deletion-request-button",
            "version": 1,
            "title": "\"Delete My Account\" Request Button",
            "description": "Adds a [wpide_delete_account] shortcode with a button that files an erasure request in WordPress's privacy tools; the member confirms by email and you process it under Tools > Erase Personal Data.",
            "category": "users",
            "tags": [
                "privacy",
                "account",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_erase_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_erase_nonce'] ), 'wpide_erase_request' ) ) {\n        wp_die( 'The form expired. Please go back and try again.' );\n    }\n\n    $user       = wp_get_current_user();\n    $request_id = wp_create_user_request( $user->user_email, 'remove_personal_data' );\n\n    if ( is_wp_error( $request_id ) ) {\n        $state = 'duplicate_request' === $request_id->get_error_code() ? 'pending' : 'error';\n    } else {\n        wp_send_user_request( $request_id );\n        $state = 'sent';\n    }\n\n    wp_safe_redirect( add_query_arg( 'erase-request', $state ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_delete_account', function () {\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $messages = array(\n        'sent'    => 'Check your email: we sent a link to confirm the deletion request.',\n        'pending' => 'You already have a deletion request in progress. Check your email for the confirmation link.',\n        'error'   => 'Something went wrong. Please contact us to delete your account.',\n    );\n    $state = isset( $_GET['erase-request'] ) ? sanitize_key( $_GET['erase-request'] ) : '';\n\n    if ( isset( $messages[ $state ] ) ) {\n        return '<p class=\"wpide-erase-notice\" role=\"status\">' . esc_html( $messages[ $state ] ) . '</p>';\n    }\n\n    return '<form method=\"post\" class=\"wpide-erase-form\" onsubmit=\"return confirm(\\'Request deletion of your account and personal data?\\');\">'\n        . wp_nonce_field( 'wpide_erase_request', 'wpide_erase_nonce', true, false )\n        . '<p>Want us to delete your account and personal data? We will email you to confirm.</p>'\n        . '<p><button type=\"submit\">Request account deletion</button></p></form>';\n} );",
            "requires": [],
            "notes": "Nothing is deleted automatically; it uses the core personal-data erasure workflow so you stay in control."
        },
        {
            "id": "personal-data-export-button",
            "version": 1,
            "title": "\"Download My Data\" Request Button",
            "description": "Adds a [wpide_export_data] shortcode with a button that asks WordPress's privacy tools to prepare a copy of the member's personal data, confirmed by email.",
            "category": "users",
            "tags": [
                "privacy",
                "account",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_export_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_export_nonce'] ), 'wpide_export_request' ) ) {\n        wp_die( 'The form expired. Please go back and try again.' );\n    }\n\n    $request_id = wp_create_user_request( wp_get_current_user()->user_email, 'export_personal_data' );\n\n    if ( is_wp_error( $request_id ) ) {\n        $state = 'duplicate_request' === $request_id->get_error_code() ? 'pending' : 'error';\n    } else {\n        wp_send_user_request( $request_id );\n        $state = 'sent';\n    }\n\n    wp_safe_redirect( add_query_arg( 'export-request', $state ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_export_data', function () {\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $messages = array(\n        'sent'    => 'Check your email to confirm the request. We will send your data once it is ready.',\n        'pending' => 'You already asked for a copy of your data. Check your email for the confirmation link.',\n        'error'   => 'Something went wrong. Please contact us for a copy of your data.',\n    );\n    $state = isset( $_GET['export-request'] ) ? sanitize_key( $_GET['export-request'] ) : '';\n\n    if ( isset( $messages[ $state ] ) ) {\n        return '<p class=\"wpide-export-notice\" role=\"status\">' . esc_html( $messages[ $state ] ) . '</p>';\n    }\n\n    return '<form method=\"post\" class=\"wpide-export-form\">'\n        . wp_nonce_field( 'wpide_export_request', 'wpide_export_nonce', true, false )\n        . '<p><button type=\"submit\">Request a copy of my data</button></p></form>';\n} );",
            "requires": [],
            "notes": "You send the export from Tools > Export Personal Data, or let WordPress email it once you approve the request."
        },
        {
            "id": "member-account-dashboard",
            "version": 1,
            "title": "Member Dashboard Shortcode",
            "description": "Adds a [wpide_account] shortcode that greets the logged-in member and shows their avatar, membership details, recent posts and comments, and account links.",
            "category": "users",
            "tags": [
                "account",
                "dashboard",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label => URL for the links box. Use full URLs or paths like /account/edit/.\n$wpide_account_links = array(\n    'Edit profile' => admin_url( 'profile.php' ),\n);\n\nadd_shortcode( 'wpide_account', function () use ( $wpide_account_links ) {\n    if ( ! is_user_logged_in() ) {\n        return '<p class=\"wpide-account-login\">Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to see your account.</p>';\n    }\n\n    $user  = wp_get_current_user();\n    $roles = array_map( function ( $role ) {\n        return isset( wp_roles()->role_names[ $role ] ) ? translate_user_role( wp_roles()->role_names[ $role ] ) : $role;\n    }, $user->roles );\n\n    $html  = '<div class=\"wpide-account\">';\n    $html .= '<div class=\"wpide-account__head\" style=\"display:flex;gap:1em;align-items:center;flex-wrap:wrap\">' . get_avatar( $user->ID, 72, '', '' );\n    $html .= '<div><h2 style=\"margin:0\">' . esc_html( sprintf( 'Hello, %s', $user->first_name ? $user->first_name : $user->display_name ) ) . '</h2>';\n    $html .= '<p style=\"margin:0\">' . esc_html( sprintf( 'Member since %s', wp_date( get_option( 'date_format' ), strtotime( $user->user_registered . ' UTC' ) ) ) ) . ( $roles ? ' &middot; ' . esc_html( implode( ', ', $roles ) ) : '' ) . '</p></div></div>';\n\n    if ( current_user_can( 'edit_posts' ) ) {\n        $posts = get_posts( array( 'author' => $user->ID, 'post_status' => array( 'publish', 'draft', 'pending' ), 'numberposts' => 5, 'no_found_rows' => true ) );\n\n        if ( $posts ) {\n            $html .= '<h3>Your recent posts</h3><ul>';\n            foreach ( $posts as $post ) {\n                $html .= '<li><a href=\"' . esc_url( 'publish' === $post->post_status ? get_permalink( $post ) : get_edit_post_link( $post ) ) . '\">' . esc_html( get_the_title( $post ) ) . '</a> <small>(' . esc_html( get_post_status_object( $post->post_status )->label ) . ')</small></li>';\n            }\n            $html .= '</ul>';\n        }\n    }\n\n    $comments = get_comments( array( 'user_id' => $user->ID, 'number' => 5, 'status' => 'approve', 'no_found_rows' => true ) );\n\n    if ( $comments ) {\n        $html .= '<h3>Your recent comments</h3><ul>';\n        foreach ( $comments as $comment ) {\n            $html .= '<li><a href=\"' . esc_url( get_comment_link( $comment ) ) . '\">' . esc_html( wp_trim_words( $comment->comment_content, 12 ) ) . '</a></li>';\n        }\n        $html .= '</ul>';\n    }\n\n    $html .= '<h3>Your account</h3><ul class=\"wpide-account__links\">';\n    foreach ( $wpide_account_links as $label => $url ) {\n        $html .= '<li><a href=\"' . esc_url( $url ) . '\">' . esc_html( $label ) . '</a></li>';\n    }\n    $html .= '<li><a href=\"' . esc_url( wp_logout_url( home_url( '/' ) ) ) . '\">Log out</a></li></ul></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Set $wpide_account_links to your own pages (for example a page holding [wpide_edit_profile]); visitors who are not logged in get a login link."
        },
        {
            "id": "member-directory-shortcode",
            "version": 1,
            "title": "Member Directory Shortcode",
            "description": "Adds a [wpide_members] shortcode that lists members with their avatar and display name in a responsive, paginated grid, shown only to logged-in visitors by default.",
            "category": "users",
            "tags": [
                "members",
                "directory",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_members', function ( $atts ) {\n    $atts = shortcode_atts( array( 'role' => '', 'per_page' => 24, 'public' => 'no' ), $atts, 'wpide_members' );\n\n    if ( 'yes' !== $atts['public'] && ! is_user_logged_in() ) {\n        return '<p>Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to see the member directory.</p>';\n    }\n\n    $per_page = max( 1, min( 100, (int) $atts['per_page'] ) );\n    $page     = isset( $_GET['members-page'] ) ? max( 1, absint( $_GET['members-page'] ) ) : 1;\n    $args     = array(\n        'number'  => $per_page,\n        'paged'   => $page,\n        'orderby' => 'display_name',\n        'order'   => 'ASC',\n        'fields'  => array( 'ID', 'display_name' ),\n    );\n\n    if ( $atts['role'] ) {\n        $args['role__in'] = array_map( 'sanitize_key', explode( ',', $atts['role'] ) );\n    }\n\n    $query = new WP_User_Query( $args );\n    $users = $query->get_results();\n\n    if ( ! $users ) {\n        return '<p>No members found.</p>';\n    }\n\n    $html = '<ul class=\"wpide-members\" style=\"list-style:none;padding:0;display:grid;gap:1.25em;grid-template-columns:repeat(auto-fill,minmax(140px,1fr))\">';\n\n    foreach ( $users as $user ) {\n        $html .= '<li style=\"text-align:center\">' . get_avatar( $user->ID, 96, '', '', array( 'loading' => 'lazy' ) ) . '<br>' . esc_html( $user->display_name ) . '</li>';\n    }\n\n    $html .= '</ul>';\n    $pages = (int) ceil( $query->get_total() / $per_page );\n\n    if ( $pages > 1 ) {\n        $html .= '<nav class=\"wpide-members-pages\" aria-label=\"Member directory pages\">' . paginate_links( array(\n            'base'    => add_query_arg( 'members-page', '%#%' ),\n            'format'  => '',\n            'current' => $page,\n            'total'   => $pages,\n        ) ) . '</nav>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Attributes: role=\"subscriber\" per_page=\"24\" public=\"yes\" (show it to visitors too). Only display names and avatars are shown, never emails."
        },
        {
            "id": "member-count-shortcode",
            "version": 1,
            "title": "Member Count Shortcode",
            "description": "Adds a [wpide_member_count] shortcode that shows how many members your site has, optionally for one role, cached so it stays fast on big sites.",
            "category": "users",
            "tags": [
                "members",
                "shortcode",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_member_count', function ( $atts ) {\n    $atts   = shortcode_atts( array( 'role' => '' ), $atts, 'wpide_member_count' );\n    $counts = get_transient( 'wpide_member_counts' );\n\n    if ( false === $counts ) {\n        $counts = count_users();\n        set_transient( 'wpide_member_counts', $counts, 12 * HOUR_IN_SECONDS );\n    }\n\n    $role  = sanitize_key( $atts['role'] );\n    $total = $role ? ( $counts['avail_roles'][ $role ] ?? 0 ) : $counts['total_users'];\n\n    return esc_html( number_format_i18n( $total ) );\n} );\n\n$wpide_member_count_flush = function () {\n    delete_transient( 'wpide_member_counts' );\n};\nadd_action( 'user_register', $wpide_member_count_flush );\nadd_action( 'deleted_user', $wpide_member_count_flush );\nadd_action( 'set_user_role', $wpide_member_count_flush );",
            "requires": [],
            "notes": "Use [wpide_member_count role=\"subscriber\"]. The count refreshes when someone registers or is deleted, and at least twice a day."
        },
        {
            "id": "member-since-shortcode",
            "version": 1,
            "title": "\"Member Since\" Shortcode",
            "description": "Adds a [wpide_member_since] shortcode that shows the date the logged-in member joined, or how long ago, for welcome messages and account pages.",
            "category": "users",
            "tags": [
                "members",
                "shortcode",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_member_since', function ( $atts ) {\n    $atts = shortcode_atts( array( 'format' => '' ), $atts, 'wpide_member_since' );\n\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $joined = strtotime( wp_get_current_user()->user_registered . ' UTC' );\n\n    if ( 'relative' === $atts['format'] ) {\n        return esc_html( human_time_diff( $joined ) );\n    }\n\n    return esc_html( wp_date( $atts['format'] ? $atts['format'] : get_option( 'date_format' ), $joined ) );\n} );",
            "requires": [],
            "notes": "Use [wpide_member_since format=\"relative\"] for \"3 months\", or any PHP date format such as format=\"F Y\"."
        },
        {
            "id": "personal-greeting-shortcode",
            "version": 1,
            "title": "Personal Greeting Shortcode",
            "description": "Adds a [wpide_hello] shortcode that greets logged-in members by first name (\"Hi, Sam\") and visitors with a fallback you choose.",
            "category": "users",
            "tags": [
                "members",
                "shortcode",
                "personalization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_hello', function ( $atts ) {\n    $atts = shortcode_atts( array( 'greeting' => 'Hi', 'guest' => 'there' ), $atts, 'wpide_hello' );\n    $name = $atts['guest'];\n\n    if ( is_user_logged_in() ) {\n        $user = wp_get_current_user();\n        $name = $user->first_name ? $user->first_name : $user->display_name;\n    }\n\n    return esc_html( sprintf( '%s, %s', $atts['greeting'], $name ) );\n} );",
            "requires": [],
            "notes": "Example: [wpide_hello greeting=\"Welcome back\" guest=\"friend\"]. Pages with personal greetings should not be served from a full-page cache to logged-in users."
        },
        {
            "id": "content-for-specific-users-shortcode",
            "version": 1,
            "title": "Content for Specific Users Shortcode",
            "description": "Adds a [wpide_for_users] shortcode that shows its content only to the user accounts you list, handy for private notes to one client on a shared page.",
            "category": "memberships",
            "tags": [
                "shortcode",
                "users",
                "private"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_for_users', function ( $atts, $content = '' ) {\n    $atts    = shortcode_atts( array( 'ids' => '' ), $atts, 'wpide_for_users' );\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id ) {\n        return '';\n    }\n\n    $ids = array_filter( array_map( 'absint', explode( ',', $atts['ids'] ) ) );\n\n    if ( in_array( $user_id, $ids, true ) || current_user_can( 'manage_options' ) ) {\n        return do_shortcode( $content );\n    }\n\n    return '';\n} );",
            "requires": [],
            "notes": "Example: [wpide_for_users ids=\"12,45\"]Your files are ready.[/wpide_for_users]. Administrators always see the content."
        },
        {
            "id": "menu-items-by-login-and-role",
            "version": 1,
            "title": "Show Menu Items by Login Status or Role",
            "description": "Lets you hide individual menu items from guests, members or certain roles by giving them a CSS class such as wpide-logged-in, wpide-logged-out or wpide-role-editor.",
            "category": "memberships",
            "tags": [
                "menus",
                "members",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_nav_menu_objects', function ( $items ) {\n    $logged_in = is_user_logged_in();\n    $roles     = $logged_in ? (array) wp_get_current_user()->roles : array();\n    $removed   = array();\n\n    foreach ( $items as $key => $item ) {\n        $classes = (array) $item->classes;\n        $hide    = in_array( (int) $item->menu_item_parent, $removed, true );\n\n        if ( in_array( 'wpide-logged-in', $classes, true ) && ! $logged_in ) {\n            $hide = true;\n        }\n\n        if ( in_array( 'wpide-logged-out', $classes, true ) && $logged_in ) {\n            $hide = true;\n        }\n\n        $role_classes = preg_grep( '/^wpide-role-/', $classes );\n\n        if ( $role_classes ) {\n            $wanted = array_map( function ( $class ) {\n                return substr( $class, 11 );\n            }, $role_classes );\n\n            if ( ! array_intersect( $wanted, $roles ) && ! current_user_can( 'manage_options' ) ) {\n                $hide = true;\n            }\n        }\n\n        if ( $hide ) {\n            $removed[] = (int) $item->ID;\n            unset( $items[ $key ] );\n        }\n    }\n\n    return $items;\n}, 10 );",
            "requires": [],
            "notes": "Turn on \"CSS Classes\" under Screen Options in Appearance > Menus to add the classes. Child items of a hidden item are hidden too."
        },
        {
            "id": "log-out-other-devices-button",
            "version": 1,
            "title": "\"Log Out Other Devices\" Button for Members",
            "description": "Adds a [wpide_logout_others] shortcode with a button that signs the member out of every other browser and device, keeping the current one logged in.",
            "category": "login",
            "tags": [
                "sessions",
                "security",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_logout_others_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_logout_others_nonce'] ), 'wpide_logout_others' ) ) {\n        wp_die( 'The form expired. Please go back and try again.' );\n    }\n\n    WP_Session_Tokens::get_instance( get_current_user_id() )->destroy_others( wp_get_session_token() );\n\n    wp_safe_redirect( add_query_arg( 'sessions-ended', 1 ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_logout_others', function () {\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    if ( isset( $_GET['sessions-ended'] ) ) {\n        return '<p class=\"wpide-sessions-notice\" role=\"status\">You have been logged out everywhere else.</p>';\n    }\n\n    $others = max( 0, count( WP_Session_Tokens::get_instance( get_current_user_id() )->get_all() ) - 1 );\n\n    if ( ! $others ) {\n        return '<p class=\"wpide-sessions-notice\">You are not logged in anywhere else.</p>';\n    }\n\n    return '<form method=\"post\" class=\"wpide-logout-others\">'\n        . wp_nonce_field( 'wpide_logout_others', 'wpide_logout_others_nonce', true, false )\n        . '<p>' . esc_html( sprintf( _n( 'You are logged in on %d other device or browser.', 'You are logged in on %d other devices or browsers.', $others ), $others ) ) . '</p>'\n        . '<p><button type=\"submit\">Log out everywhere else</button></p></form>';\n} );",
            "requires": [],
            "notes": "Shows how many other sessions are active, so members can spot logins they do not recognise."
        },
        {
            "id": "login-history-on-profile",
            "version": 1,
            "title": "Recent Login History on the Profile Screen",
            "description": "Records the last 10 logins for each user (time, IP address and browser) and lists them on their profile screen so they can spot logins they do not recognise.",
            "category": "login",
            "tags": [
                "login",
                "security",
                "history"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How many logins to keep per user.\n$wpide_login_history_size = 10;\n\nadd_action( 'wp_login', function ( $login, $user ) use ( $wpide_login_history_size ) {\n    $history   = (array) get_user_meta( $user->ID, 'wpide_login_history', true );\n    $history   = array_filter( $history, 'is_array' );\n    array_unshift( $history, array(\n        'time' => time(),\n        'ip'   => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n        'ua'   => isset( $_SERVER['HTTP_USER_AGENT'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ), 0, 200 ) : '',\n    ) );\n\n    update_user_meta( $user->ID, 'wpide_login_history', array_slice( $history, 0, $wpide_login_history_size ) );\n}, 10, 2 );\n\n$wpide_login_history_table = function ( $user ) {\n    if ( get_current_user_id() !== $user->ID && ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    $history = array_filter( (array) get_user_meta( $user->ID, 'wpide_login_history', true ), 'is_array' );\n\n    echo '<h2>Recent logins</h2>';\n\n    if ( ! $history ) {\n        echo '<p>No logins recorded yet.</p>';\n        return;\n    }\n\n    echo '<table class=\"widefat striped\" style=\"max-width:900px\"><thead><tr><th scope=\"col\">When</th><th scope=\"col\">IP address</th><th scope=\"col\">Browser</th></tr></thead><tbody>';\n\n    foreach ( $history as $row ) {\n        printf(\n            '<tr><td>%1$s</td><td>%2$s</td><td>%3$s</td></tr>',\n            esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $row['time'] ) ),\n            esc_html( $row['ip'] ),\n            esc_html( $row['ua'] )\n        );\n    }\n\n    echo '</tbody></table>';\n};\n\nadd_action( 'show_user_profile', $wpide_login_history_table );\nadd_action( 'edit_user_profile', $wpide_login_history_table );",
            "requires": [],
            "notes": "Users see their own history; administrators also see it on other users' profiles. IP addresses are personal data, so mention this in your privacy policy."
        },
        {
            "id": "last-seen-users-column",
            "version": 1,
            "title": "\"Last Seen\" Column in the Users List",
            "description": "Records when each user was last active on the site (not just when they last logged in) and shows it in a sortable \"Last seen\" column on the Users screen.",
            "category": "users",
            "tags": [
                "users",
                "activity",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id || wp_doing_cron() ) {\n        return;\n    }\n\n    $last = (int) get_user_meta( $user_id, 'wpide_last_seen', true );\n\n    if ( time() - $last > 15 * MINUTE_IN_SECONDS ) {\n        update_user_meta( $user_id, 'wpide_last_seen', time() );\n    }\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_last_seen'] = 'Last seen';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_sortable_columns', function ( $columns ) {\n    $columns['wpide_last_seen'] = 'wpide_last_seen';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_last_seen' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_last_seen', true );\n\n    return $time ? '<span title=\"' . esc_attr( wp_date( 'Y-m-d H:i', $time ) ) . '\">' . esc_html( sprintf( '%s ago', human_time_diff( $time ) ) ) . '</span>' : '&mdash;';\n}, 10, 3 );\n\nadd_action( 'pre_get_users', function ( $query ) {\n    if ( is_admin() && 'wpide_last_seen' === $query->get( 'orderby' ) ) {\n        $query->set( 'meta_key', 'wpide_last_seen' );\n        $query->set( 'orderby', 'meta_value_num' );\n    }\n} );",
            "requires": [],
            "notes": "Activity is saved at most once every 15 minutes per user, so it adds almost no database writes."
        },
        {
            "id": "per-user-login-destination",
            "version": 1,
            "title": "Personal After-Login Page for Each User",
            "description": "Adds an \"After login, go to\" field to each user's profile so administrators can send specific clients or members to their own page when they log in.",
            "category": "users",
            "tags": [
                "login",
                "redirect",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edit_user_profile', function ( $user ) {\n    if ( ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    wp_nonce_field( 'wpide_user_landing_' . $user->ID, 'wpide_user_landing_nonce' );\n    echo '<h2>After login</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\"><label for=\"wpide-user-landing\">Go to page</label></th><td>';\n    wp_dropdown_pages( array(\n        'name'              => 'wpide_user_landing',\n        'id'                => 'wpide-user-landing',\n        'selected'          => (int) get_user_meta( $user->ID, 'wpide_user_landing', true ),\n        'show_option_none'  => 'Default destination',\n        'option_none_value' => 0,\n        'post_status'       => array( 'publish', 'private' ),\n    ) );\n    echo '</td></tr></table>';\n} );\n\nadd_action( 'edit_user_profile_update', function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_user_landing_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_user_landing_nonce'] ), 'wpide_user_landing_' . $user_id ) || ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    $page = isset( $_POST['wpide_user_landing'] ) ? absint( $_POST['wpide_user_landing'] ) : 0;\n\n    if ( $page ) {\n        update_user_meta( $user_id, 'wpide_user_landing', $page );\n    } else {\n        delete_user_meta( $user_id, 'wpide_user_landing' );\n    }\n} );\n\nadd_filter( 'login_redirect', function ( $redirect, $requested, $user ) {\n    if ( ! $user instanceof WP_User ) {\n        return $redirect;\n    }\n\n    $page = (int) get_user_meta( $user->ID, 'wpide_user_landing', true );\n\n    return $page && get_post_status( $page ) ? get_permalink( $page ) : $redirect;\n}, 30, 3 );",
            "requires": [],
            "notes": "Only administrators can set the page. Users without one keep the normal login destination."
        },
        {
            "id": "personal-message-for-member",
            "version": 1,
            "title": "Personal Message Box for Individual Members",
            "description": "Lets administrators write a private note on any user's profile that only that user sees, through a [wpide_my_message] shortcode and on their own dashboard.",
            "category": "users",
            "tags": [
                "members",
                "dashboard",
                "messages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'edit_user_profile', function ( $user ) {\n    if ( ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    wp_nonce_field( 'wpide_member_message_' . $user->ID, 'wpide_member_message_nonce' );\n    printf(\n        '<h2>Personal message</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\"><label for=\"wpide-member-message\">Message for this user</label></th><td><textarea id=\"wpide-member-message\" name=\"wpide_member_message\" rows=\"4\" class=\"large-text\">%s</textarea><p class=\"description\">Shown only to this user. Leave empty to remove.</p></td></tr></table>',\n        esc_textarea( get_user_meta( $user->ID, 'wpide_member_message', true ) )\n    );\n} );\n\nadd_action( 'edit_user_profile_update', function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_member_message_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_member_message_nonce'] ), 'wpide_member_message_' . $user_id ) || ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    $message = isset( $_POST['wpide_member_message'] ) ? wp_kses_post( wp_unslash( $_POST['wpide_member_message'] ) ) : '';\n\n    if ( '' === trim( $message ) ) {\n        delete_user_meta( $user_id, 'wpide_member_message' );\n    } else {\n        update_user_meta( $user_id, 'wpide_member_message', $message );\n    }\n} );\n\nadd_shortcode( 'wpide_my_message', function () {\n    $message = is_user_logged_in() ? get_user_meta( get_current_user_id(), 'wpide_member_message', true ) : '';\n\n    return $message ? '<div class=\"wpide-my-message\" role=\"note\">' . wpautop( wp_kses_post( $message ) ) . '</div>' : '';\n} );\n\nadd_action( 'admin_notices', function () {\n    global $pagenow;\n\n    $message = in_array( $pagenow, array( 'index.php', 'profile.php' ), true ) ? get_user_meta( get_current_user_id(), 'wpide_member_message', true ) : '';\n\n    if ( $message ) {\n        echo '<div class=\"notice notice-info\">' . wpautop( wp_kses_post( $message ) ) . '</div>';\n    }\n} );",
            "requires": [],
            "notes": "Useful for client portals (\"Your invoice is ready\") and member dashboards. Basic formatting and links are allowed."
        },
        {
            "id": "self-deactivate-account",
            "version": 1,
            "title": "Let Members Deactivate Their Own Account",
            "description": "Adds a [wpide_deactivate_account] shortcode with a button members can use to close their account; they are logged out and blocked, and an administrator can reactivate them later.",
            "category": "users",
            "tags": [
                "account",
                "privacy",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_deactivate_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_deactivate_nonce'] ), 'wpide_deactivate' ) || current_user_can( 'manage_options' ) ) {\n        wp_die( 'This request could not be completed.' );\n    }\n\n    $user_id = get_current_user_id();\n    update_user_meta( $user_id, 'wpide_deactivated', time() );\n    WP_Session_Tokens::get_instance( $user_id )->destroy_all();\n    wp_clear_auth_cookie();\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        'A member deactivated their account',\n        sprintf( \"%s deactivated their account.\\n%s\", wp_get_current_user()->user_email, admin_url( 'user-edit.php?user_id=' . $user_id ) )\n    );\n\n    wp_safe_redirect( add_query_arg( 'account-closed', 1, home_url( '/' ) ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_deactivate_account', function () {\n    if ( ! is_user_logged_in() || current_user_can( 'manage_options' ) ) {\n        return '';\n    }\n\n    return '<form method=\"post\" class=\"wpide-deactivate\" onsubmit=\"return confirm(\\'Close your account? You will be logged out.\\');\">'\n        . wp_nonce_field( 'wpide_deactivate', 'wpide_deactivate_nonce', true, false )\n        . '<p><button type=\"submit\">Close my account</button></p></form>';\n} );\n\nadd_filter( 'authenticate', function ( $user ) {\n    if ( $user instanceof WP_User && get_user_meta( $user->ID, 'wpide_deactivated', true ) ) {\n        return new WP_Error( 'wpide_deactivated', '<strong>This account has been closed.</strong> Contact us if you would like to reopen it.' );\n    }\n\n    return $user;\n}, 60 );\n\nadd_filter( 'user_row_actions', function ( $actions, $user ) {\n    if ( get_user_meta( $user->ID, 'wpide_deactivated', true ) && current_user_can( 'edit_user', $user->ID ) ) {\n        $url = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_reactivate&user_id=' . $user->ID ), 'wpide_reactivate_' . $user->ID );\n        $actions['wpide_reactivate'] = '<strong>Deactivated</strong> &middot; <a href=\"' . esc_url( $url ) . '\">Reactivate</a>';\n    }\n\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'admin_post_wpide_reactivate', function () {\n    $user_id = isset( $_GET['user_id'] ) ? absint( $_GET['user_id'] ) : 0;\n\n    if ( ! $user_id || ! current_user_can( 'edit_user', $user_id ) ) {\n        wp_die( 'You are not allowed to do this.' );\n    }\n\n    check_admin_referer( 'wpide_reactivate_' . $user_id );\n    delete_user_meta( $user_id, 'wpide_deactivated' );\n\n    wp_safe_redirect( admin_url( 'users.php' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Nothing is deleted. Deactivated users show as \"Deactivated\" on the Users screen; delete the wpide_deactivated user meta (or use the row link) to reactivate. Administrators cannot deactivate themselves this way."
        },
        {
            "id": "pronouns-profile-field",
            "version": 1,
            "title": "Pronouns Field for User Profiles",
            "description": "Adds an optional \"Pronouns\" field to user profiles and a [wpide_pronouns] shortcode that shows them, for example next to an author's name.",
            "category": "users",
            "tags": [
                "profile",
                "fields",
                "inclusive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_pronouns_field = function ( $user ) {\n    printf(\n        '<h2>About you</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\"><label for=\"wpide-pronouns\">Pronouns</label></th><td><input type=\"text\" id=\"wpide-pronouns\" name=\"wpide_pronouns\" value=\"%s\" class=\"regular-text\" maxlength=\"40\" placeholder=\"e.g. she/her, they/them\"></td></tr></table>',\n        esc_attr( get_user_meta( $user->ID, 'wpide_pronouns', true ) )\n    );\n    wp_nonce_field( 'wpide_pronouns_' . $user->ID, 'wpide_pronouns_nonce' );\n};\nadd_action( 'show_user_profile', $wpide_pronouns_field );\nadd_action( 'edit_user_profile', $wpide_pronouns_field );\n\n$wpide_pronouns_save = function ( $user_id ) {\n    if ( ! isset( $_POST['wpide_pronouns_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_pronouns_nonce'] ), 'wpide_pronouns_' . $user_id ) || ! current_user_can( 'edit_user', $user_id ) ) {\n        return;\n    }\n\n    $value = isset( $_POST['wpide_pronouns'] ) ? substr( sanitize_text_field( wp_unslash( $_POST['wpide_pronouns'] ) ), 0, 40 ) : '';\n    update_user_meta( $user_id, 'wpide_pronouns', $value );\n};\nadd_action( 'personal_options_update', $wpide_pronouns_save );\nadd_action( 'edit_user_profile_update', $wpide_pronouns_save );\n\nadd_shortcode( 'wpide_pronouns', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'user' => 'current' ), $atts, 'wpide_pronouns' );\n    $user_id = 'author' === $atts['user'] ? (int) get_post_field( 'post_author', get_the_ID() ) : get_current_user_id();\n    $value   = $user_id ? get_user_meta( $user_id, 'wpide_pronouns', true ) : '';\n\n    return $value ? '<span class=\"wpide-pronouns\">(' . esc_html( $value ) . ')</span>' : '';\n} );",
            "requires": [],
            "notes": "Use [wpide_pronouns user=\"author\"] inside posts to show the post author's pronouns; the default shows the logged-in user's."
        },
        {
            "id": "signups-per-month-widget",
            "version": 1,
            "title": "New Sign-Ups per Month Dashboard Widget",
            "description": "Adds a dashboard widget showing how many people registered in each of the last six months, with simple bars, so you can see if your community is growing.",
            "category": "users",
            "tags": [
                "users",
                "dashboard",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_signups_month', 'New sign-ups per month', function () {\n        global $wpdb;\n\n        $rows = get_transient( 'wpide_signups_month' );\n\n        if ( false === $rows ) {\n            $since = gmdate( 'Y-m-01 00:00:00', strtotime( '-5 months', time() ) );\n            $rows  = $wpdb->get_results( $wpdb->prepare( \"SELECT DATE_FORMAT(user_registered, '%%Y-%%m') AS month, COUNT(*) AS total FROM {$wpdb->users} WHERE user_registered >= %s GROUP BY month ORDER BY month\", $since ), ARRAY_A );\n            set_transient( 'wpide_signups_month', $rows, 6 * HOUR_IN_SECONDS );\n        }\n\n        $totals = wp_list_pluck( (array) $rows, 'total', 'month' );\n        $max    = $totals ? max( array_map( 'intval', $totals ) ) : 0;\n\n        echo '<table class=\"widefat striped\" style=\"border:0\"><tbody>';\n\n        for ( $i = 5; $i >= 0; $i-- ) {\n            $time  = strtotime( gmdate( 'Y-m-01' ) . \" -$i months\" );\n            $count = (int) ( $totals[ gmdate( 'Y-m', $time ) ] ?? 0 );\n            $width = $max ? round( $count / $max * 100 ) : 0;\n\n            printf(\n                '<tr><th scope=\"row\" style=\"width:30%%\">%1$s</th><td><span style=\"display:inline-block;height:10px;width:%2$d%%;min-width:2px;background:#2271b1;border-radius:2px;vertical-align:middle\"></span> %3$s</td></tr>',\n                esc_html( wp_date( 'M Y', $time ) ),\n                (int) $width,\n                esc_html( number_format_i18n( $count ) )\n            );\n        }\n\n        echo '</tbody></table>';\n    } );\n} );\n\nadd_action( 'user_register', function () {\n    delete_transient( 'wpide_signups_month' );\n} );",
            "requires": [],
            "notes": "Uses one grouped query, cached for 6 hours. Visible to users who can list users."
        },
        {
            "id": "users-list-newest-first",
            "version": 1,
            "title": "Show the Newest Users First in the Users List",
            "description": "Sorts the Users screen by registration date with the newest accounts on top by default, so new sign-ups are easy to review.",
            "category": "users",
            "tags": [
                "users",
                "admin",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'pre_get_users', function ( $query ) {\n    global $pagenow;\n\n    if ( 'users.php' !== $pagenow || ! empty( $_GET['orderby'] ) || $query->get( 'fields' ) === 'ID' ) {\n        return;\n    }\n\n    $query->set( 'orderby', 'registered' );\n    $query->set( 'order', 'DESC' );\n} );",
            "requires": [],
            "notes": "Clicking a column header still sorts by that column."
        },
        {
            "id": "role-in-toolbar-greeting",
            "version": 1,
            "title": "Show Your Role in the Toolbar",
            "description": "Adds the current user's role next to their name in the admin toolbar (\"Howdy, Sam (Editor)\"), helpful when testing with several accounts.",
            "category": "users",
            "tags": [
                "roles",
                "admin-bar",
                "toolbar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    $node = $bar->get_node( 'my-account' );\n\n    if ( ! $node || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $names = array();\n\n    foreach ( (array) wp_get_current_user()->roles as $role ) {\n        $names[] = isset( wp_roles()->role_names[ $role ] ) ? translate_user_role( wp_roles()->role_names[ $role ] ) : $role;\n    }\n\n    if ( $names ) {\n        $node->title = preg_replace( '/(<span class=\"display-name\">)(.*?)(<\\/span>)/', '$1$2 (' . esc_html( implode( ', ', $names ) ) . ')$3', $node->title, 1 );\n        $bar->add_node( (array) $node );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Shows all roles when a user has more than one."
        },
        {
            "id": "limit-users-per-role",
            "version": 1,
            "title": "Limit How Many Users a Role Can Have",
            "description": "Caps the number of accounts for chosen roles, such as a maximum of 3 Editors, and blocks adding or promoting anyone beyond that limit.",
            "category": "users",
            "tags": [
                "roles",
                "users",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role => maximum number of users.\n$wpide_role_limits = array(\n    'editor'        => 3,\n    'administrator' => 2,\n);\n\n$wpide_role_full = function ( $role, $ignore_user = 0 ) use ( $wpide_role_limits ) {\n    if ( empty( $wpide_role_limits[ $role ] ) ) {\n        return false;\n    }\n\n    $count = count( get_users( array( 'role' => $role, 'fields' => 'ID', 'exclude' => array( (int) $ignore_user ), 'number' => $wpide_role_limits[ $role ] ) ) );\n\n    return $count >= $wpide_role_limits[ $role ];\n};\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_role_full ) {\n    $role = isset( $_POST['role'] ) ? sanitize_key( $_POST['role'] ) : '';\n\n    if ( ! $role ) {\n        return;\n    }\n\n    $current = $update && ! empty( $user->ID ) ? (array) get_userdata( $user->ID )->roles : array();\n\n    if ( ! in_array( $role, $current, true ) && $wpide_role_full( $role, $update ? (int) $user->ID : 0 ) ) {\n        $errors->add( 'wpide_role_full', sprintf( '<strong>Error:</strong> The %s role has reached its user limit.', esc_html( $role ) ) );\n    }\n}, 10, 3 );\n\n// Hide full roles from the role menus.\nadd_filter( 'editable_roles', function ( $roles ) use ( $wpide_role_full ) {\n    $editing = isset( $_GET['user_id'] ) ? get_userdata( absint( $_GET['user_id'] ) ) : null;\n    $keep    = $editing ? (array) $editing->roles : array();\n\n    foreach ( array_keys( $roles ) as $role ) {\n        if ( ! in_array( $role, $keep, true ) && $wpide_role_full( $role ) ) {\n            unset( $roles[ $role ] );\n        }\n    }\n\n    return $roles;\n} );",
            "requires": [],
            "notes": "Applies when users are added or edited in wp-admin. Existing users above the limit are not demoted."
        },
        {
            "id": "bulk-add-role-users",
            "version": 1,
            "title": "Bulk \"Add Role\" Action on the Users Screen",
            "description": "Adds \"Add role\" options to the Bulk actions menu on the Users screen, so you can give a second role to many users at once without replacing their current role.",
            "category": "users",
            "tags": [
                "roles",
                "bulk-actions",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'bulk_actions-users', function ( $actions ) {\n    if ( ! current_user_can( 'promote_users' ) ) {\n        return $actions;\n    }\n\n    foreach ( get_editable_roles() as $role => $details ) {\n        if ( 'administrator' !== $role ) {\n            $actions[ 'wpide_add_role_' . $role ] = sprintf( 'Add role: %s', translate_user_role( $details['name'] ) );\n        }\n    }\n\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-users', function ( $redirect, $action, $user_ids ) {\n    if ( 0 !== strpos( $action, 'wpide_add_role_' ) ) {\n        return $redirect;\n    }\n\n    $role = substr( $action, 15 );\n\n    if ( 'administrator' === $role || ! array_key_exists( $role, get_editable_roles() ) ) {\n        return $redirect;\n    }\n\n    $done = 0;\n\n    foreach ( array_map( 'intval', (array) $user_ids ) as $user_id ) {\n        if ( current_user_can( 'promote_user', $user_id ) ) {\n            ( new WP_User( $user_id ) )->add_role( $role );\n            $done++;\n        }\n    }\n\n    return add_query_arg( 'wpide_roles_added', $done, $redirect );\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_roles_added'] ) ) {\n        $count = absint( $_GET['wpide_roles_added'] );\n        echo '<div class=\"notice notice-success is-dismissible\"><p>' . esc_html( sprintf( _n( 'Role added to %d user.', 'Role added to %d users.', $count ), $count ) ) . '</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Only roles you are allowed to assign are offered, never Administrator, and users you cannot promote are skipped."
        },
        {
            "id": "hide-members-only-menu-items",
            "version": 1,
            "title": "Hide Members-Only Pages From Menus for Visitors",
            "description": "Removes menu links to posts and pages marked members-only (by the WPIDE \"Members-Only Posts\" snippet) or set to private, so visitors never see links they can't open.",
            "category": "memberships",
            "tags": [
                "menus",
                "members",
                "private"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_nav_menu_objects', function ( $items ) {\n    if ( is_user_logged_in() ) {\n        return $items;\n    }\n\n    $removed = array();\n\n    foreach ( $items as $key => $item ) {\n        $hide = in_array( (int) $item->menu_item_parent, $removed, true );\n\n        if ( ! $hide && 'post_type' === ( $item->type ?? '' ) && ! empty( $item->object_id ) ) {\n            $object_id = (int) $item->object_id;\n            $hide      = 'private' === get_post_status( $object_id ) || (bool) get_post_meta( $object_id, '_wpide_members_only', true );\n        }\n\n        if ( $hide ) {\n            $removed[] = (int) $item->ID;\n            unset( $items[ $key ] );\n        }\n    }\n\n    return $items;\n} );",
            "requires": [],
            "notes": "Logged-in users still see every link; restrictions by role are checked by the members-only snippet itself when the page is opened."
        },
        {
            "id": "registration-source-column",
            "version": 1,
            "title": "Record Where Each User Signed Up",
            "description": "Notes how every new account was created (registration form, admin screen, checkout, REST API or WP-CLI) and shows it in a \"Signed up via\" column on the Users screen.",
            "category": "users",
            "tags": [
                "registration",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'user_register', function ( $user_id ) {\n    if ( defined( 'WP_CLI' ) && WP_CLI ) {\n        $source = 'WP-CLI';\n    } elseif ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {\n        $source = 'REST API';\n    } elseif ( did_action( 'woocommerce_checkout_process' ) || did_action( 'woocommerce_before_checkout_process' ) ) {\n        $source = 'Checkout';\n    } elseif ( is_admin() && current_user_can( 'create_users' ) && ! wp_doing_ajax() ) {\n        $source = 'Admin';\n    } elseif ( isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] ) {\n        $source = 'Registration form';\n    } else {\n        $source = 'Front-end form';\n    }\n\n    update_user_meta( $user_id, 'wpide_signup_source', $source );\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_signup_source'] = 'Signed up via';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_signup_source' === $column ) {\n        $source = get_user_meta( $user_id, 'wpide_signup_source', true );\n        $output = $source ? esc_html( $source ) : '&mdash;';\n    }\n\n    return $output;\n}, 10, 3 );",
            "requires": [],
            "notes": "Only accounts created after activation are labelled."
        },
        {
            "id": "unique-display-names",
            "version": 1,
            "title": "Require Unique Display Names",
            "description": "Stops users from picking a public display name that another member already uses, which prevents impersonation in comments and forums.",
            "category": "users",
            "tags": [
                "profile",
                "users",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'user_profile_update_errors', function ( $errors, $update, $user ) {\n    if ( empty( $user->display_name ) ) {\n        return;\n    }\n\n    global $wpdb;\n\n    $taken = $wpdb->get_var( $wpdb->prepare(\n        \"SELECT ID FROM {$wpdb->users} WHERE display_name = %s AND ID != %d LIMIT 1\",\n        $user->display_name,\n        isset( $user->ID ) ? (int) $user->ID : 0\n    ) );\n\n    if ( $taken ) {\n        $errors->add( 'wpide_display_name_taken', '<strong>Error:</strong> That display name is already used by another member. Please choose a different one.' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Checked when profiles are saved in wp-admin; existing duplicates are left alone until those users next save their profile."
        },
        {
            "id": "bbpress-topic-starter-badge",
            "version": 1,
            "title": "bbPress: \"Topic Starter\" Badge on Replies",
            "description": "Adds a small \"Topic starter\" badge under the author's name on replies written by the person who opened the topic, making long threads easier to follow.",
            "category": "users",
            "tags": [
                "bbpress",
                "badges",
                "topics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_reply_author_id' ) ) {\n    return;\n}\n\nadd_action( 'bbp_theme_after_reply_author_details', function () {\n    $reply_id  = (int) bbp_get_reply_id();\n    $topic_id  = (int) bbp_get_reply_topic_id( $reply_id );\n    $author_id = (int) bbp_get_reply_author_id( $reply_id );\n\n    // The opening post itself is the topic, so only mark later replies.\n    if ( ! $author_id || $reply_id === $topic_id || $author_id !== (int) bbp_get_topic_author_id( $topic_id ) ) {\n        return;\n    }\n\n    echo '<span class=\"wpide-bbp-starter\" style=\"display:inline-block;margin-top:.3em;padding:0 .5em;border-radius:999px;border:1px solid currentColor;font-size:.75em\">Topic starter</span>';\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Style the badge with the .wpide-bbp-starter class."
        },
        {
            "id": "comments-count-users-column",
            "version": 1,
            "title": "Comments Column in the Users List",
            "description": "Adds a \"Comments\" column to the Users screen showing how many approved comments each user has written, linked to their comments.",
            "category": "users",
            "tags": [
                "users",
                "comments",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_user_comment_counts = null;\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_comments'] = 'Comments';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) use ( &$wpide_user_comment_counts ) {\n    global $wpdb, $wp_list_table;\n\n    if ( 'wpide_comments' !== $column ) {\n        return $output;\n    }\n\n    if ( null === $wpide_user_comment_counts ) {\n        $ids = isset( $wp_list_table->items ) ? array_map( 'intval', wp_list_pluck( (array) $wp_list_table->items, 'ID' ) ) : array();\n        $ids = array_filter( array_merge( $ids, array( (int) $user_id ) ) );\n\n        $rows = $wpdb->get_results( \"SELECT user_id, COUNT(*) AS total FROM {$wpdb->comments} WHERE comment_approved = '1' AND user_id IN (\" . implode( ',', $ids ) . ') GROUP BY user_id' );\n        $wpide_user_comment_counts = wp_list_pluck( (array) $rows, 'total', 'user_id' );\n    }\n\n    $count = (int) ( $wpide_user_comment_counts[ $user_id ] ?? 0 );\n\n    return $count ? '<a href=\"' . esc_url( admin_url( 'edit-comments.php?user_id=' . (int) $user_id ) ) . '\">' . esc_html( number_format_i18n( $count ) ) . '</a>' : '0';\n}, 10, 3 );",
            "requires": [],
            "notes": "Counts for the whole page of users are fetched with one query."
        },
        {
            "id": "guest-join-bar",
            "version": 1,
            "title": "\"Join for Free\" Bar for Visitors",
            "description": "Shows a slim, dismissible bar at the bottom of the screen inviting visitors who are not logged in to create a free account or log in.",
            "category": "memberships",
            "tags": [
                "members",
                "signup",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_join_text = 'Get members-only articles and resources.';\n\nadd_action( 'wp_footer', function () use ( $wpide_join_text ) {\n    if ( is_user_logged_in() || ! empty( $_COOKIE['wpide_join_dismissed'] ) ) {\n        return;\n    }\n\n    $links = '<a href=\"' . esc_url( wp_login_url( home_url( add_query_arg( array() ) ) ) ) . '\">Log in</a>';\n\n    if ( get_option( 'users_can_register' ) ) {\n        $links = '<a href=\"' . esc_url( wp_registration_url() ) . '\" style=\"font-weight:600\">Join for free</a> &middot; ' . $links;\n    }\n    ?>\n    <div id=\"wpide-join-bar\" role=\"region\" aria-label=\"Membership invitation\" style=\"position:fixed;left:0;right:0;bottom:0;z-index:9999;display:flex;flex-wrap:wrap;gap:.5em 1em;align-items:center;justify-content:center;padding:10px 48px 10px 16px;background:#1d2327;color:#fff;font-size:15px\">\n        <span><?php echo esc_html( $wpide_join_text ); ?></span>\n        <span class=\"wpide-join-links\"><?php echo wp_kses_post( $links ); ?></span>\n        <button type=\"button\" aria-label=\"Dismiss\" style=\"position:absolute;right:10px;top:50%;transform:translateY(-50%);background:none;border:0;color:inherit;font-size:22px;cursor:pointer\">&times;</button>\n    </div>\n    <style>#wpide-join-bar a{color:inherit;text-decoration:underline}</style>\n    <script>\n    (function () {\n        var bar = document.getElementById('wpide-join-bar');\n        bar.querySelector('button').addEventListener('click', function () {\n            document.cookie = 'wpide_join_dismissed=1; max-age=' + (30 * 86400) + '; path=/; SameSite=Lax';\n            bar.remove();\n        });\n    })();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Dismissing hides it for 30 days. It never shows to logged-in users or on the login page, and only links to registration when sign-ups are open."
        },
        {
            "id": "magic-link-login",
            "version": 1,
            "title": "Passwordless Login With an Email Link",
            "description": "Adds an \"Email me a login link\" option to the login screen; members enter their email or username and get a one-time link that logs them in for 15 minutes.",
            "category": "login",
            "tags": [
                "login",
                "passwordless",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minutes a login link stays valid.\n$wpide_magic_minutes = 15;\n// Roles that may not use login links.\n$wpide_magic_excluded_roles = array( 'administrator' );\n\nadd_action( 'login_form', function () {\n    echo '<p style=\"margin:0 0 16px\"><a href=\"' . esc_url( add_query_arg( 'action', 'wpide_magic', wp_login_url() ) ) . '\">Email me a login link instead</a></p>';\n} );\n\nadd_action( 'login_form_wpide_magic', function () use ( $wpide_magic_minutes, $wpide_magic_excluded_roles ) {\n    if ( ! function_exists( 'login_header' ) ) {\n        return;\n    }\n\n    $sent = false;\n\n    if ( 'POST' === ( $_SERVER['REQUEST_METHOD'] ?? '' ) && isset( $_POST['wpide_magic_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['wpide_magic_nonce'] ), 'wpide_magic' ) ) {\n        $login = isset( $_POST['wpide_magic_login'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_magic_login'] ) ) : '';\n        $user  = is_email( $login ) ? get_user_by( 'email', $login ) : get_user_by( 'login', $login );\n        $sent  = true; // Same message either way, so nobody can test which accounts exist.\n\n        $recent = $user ? (int) get_user_meta( $user->ID, 'wpide_magic_sent', true ) : 0;\n\n        if ( $user && ! array_intersect( $wpide_magic_excluded_roles, (array) $user->roles ) && time() - $recent > MINUTE_IN_SECONDS ) {\n            $token = wp_generate_password( 40, false );\n            update_user_meta( $user->ID, 'wpide_magic_token', array( 'hash' => wp_hash( $token ), 'expires' => time() + $wpide_magic_minutes * MINUTE_IN_SECONDS ) );\n            update_user_meta( $user->ID, 'wpide_magic_sent', time() );\n\n            $link = add_query_arg( array( 'action' => 'wpide_magic_login', 'uid' => $user->ID, 'token' => $token ), wp_login_url() );\n\n            wp_mail(\n                $user->user_email,\n                sprintf( '[%s] Your login link', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n                sprintf( \"Hi %s,\\n\\nClick this link to log in. It works once and expires in %d minutes:\\n%s\\n\\nIf you did not ask for it, you can ignore this email.\", $user->display_name, $wpide_magic_minutes, $link )\n            );\n        }\n    }\n\n    login_header( 'Email me a login link' );\n\n    if ( $sent ) {\n        echo '<p class=\"message\">If that account exists, a login link is on its way. Check your email.</p>';\n    } else {\n        ?>\n        <form method=\"post\" action=\"<?php echo esc_url( add_query_arg( 'action', 'wpide_magic', wp_login_url() ) ); ?>\">\n            <p><label for=\"wpide_magic_login\">Username or email address</label>\n            <input type=\"text\" name=\"wpide_magic_login\" id=\"wpide_magic_login\" class=\"input\" autocomplete=\"username\" required></p>\n            <?php wp_nonce_field( 'wpide_magic', 'wpide_magic_nonce' ); ?>\n            <p class=\"submit\"><button type=\"submit\" class=\"button button-primary button-large\">Send login link</button></p>\n        </form>\n        <?php\n    }\n\n    echo '<p id=\"nav\"><a href=\"' . esc_url( wp_login_url() ) . '\">Log in with a password</a></p>';\n    login_footer();\n    exit;\n} );\n\nadd_action( 'login_form_wpide_magic_login', function () {\n    $user_id = isset( $_GET['uid'] ) ? absint( $_GET['uid'] ) : 0;\n    $token   = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';\n    $data    = $user_id ? get_user_meta( $user_id, 'wpide_magic_token', true ) : '';\n\n    if ( ! $token || ! is_array( $data ) || time() > (int) $data['expires'] || ! hash_equals( $data['hash'], wp_hash( $token ) ) ) {\n        wp_safe_redirect( add_query_arg( 'wpide_magic', 'invalid', wp_login_url() ) );\n        exit;\n    }\n\n    delete_user_meta( $user_id, 'wpide_magic_token' );\n    wp_set_current_user( $user_id );\n    wp_set_auth_cookie( $user_id );\n    do_action( 'wp_login', get_userdata( $user_id )->user_login, get_userdata( $user_id ) );\n\n    wp_safe_redirect( apply_filters( 'login_redirect', admin_url(), '', get_userdata( $user_id ) ) );\n    exit;\n} );\n\nadd_filter( 'login_message', function ( $message ) {\n    if ( isset( $_GET['wpide_magic'] ) ) {\n        $message .= '<p class=\"message\">That login link is invalid or has expired. Please ask for a new one.</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "Links work once and expire after 15 minutes. Roles in $wpide_magic_excluded_roles (administrators by default) must still use their password."
        },
        {
            "id": "email-login-code-for-staff",
            "version": 1,
            "title": "Emailed Login Code for Staff Accounts",
            "description": "Adds a second step for administrators and editors: after the correct password, WordPress emails a 6-digit code that must be entered to finish logging in.",
            "category": "login",
            "tags": [
                "login",
                "two-factor",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles that need the emailed code.\n$wpide_code_roles = array( 'administrator', 'editor' );\n$wpide_code_minutes = 10;\n\nadd_action( 'login_form', function () {\n    if ( isset( $_POST['log'] ) || isset( $_GET['wpide_code'] ) ) {\n        echo '<p><label for=\"wpide_login_code\">Login code (if we emailed you one)</label><input type=\"text\" name=\"wpide_login_code\" id=\"wpide_login_code\" class=\"input\" inputmode=\"numeric\" autocomplete=\"one-time-code\" pattern=\"[0-9]*\" maxlength=\"6\"></p>';\n    }\n} );\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_code_roles, $wpide_code_minutes ) {\n    if ( ! $user instanceof WP_User || ! array_intersect( $wpide_code_roles, (array) $user->roles ) ) {\n        return $user;\n    }\n\n    // Only the interactive login form gets the second step (not XML-RPC or application passwords).\n    if ( ! isset( $_POST['log'], $_POST['pwd'] ) ) {\n        return $user;\n    }\n\n    $entered = isset( $_POST['wpide_login_code'] ) ? preg_replace( '/\\D/', '', (string) wp_unslash( $_POST['wpide_login_code'] ) ) : '';\n    $stored  = get_user_meta( $user->ID, 'wpide_login_code', true );\n\n    if ( $entered && is_array( $stored ) && time() < (int) $stored['expires'] && (int) $stored['tries'] < 5 ) {\n        if ( hash_equals( $stored['hash'], wp_hash( $entered ) ) ) {\n            delete_user_meta( $user->ID, 'wpide_login_code' );\n            return $user;\n        }\n\n        $stored['tries']++;\n        update_user_meta( $user->ID, 'wpide_login_code', $stored );\n\n        return new WP_Error( 'wpide_code_wrong', '<strong>Error:</strong> That login code is not correct. Enter your password and the code again.' );\n    }\n\n    $code = (string) wp_rand( 100000, 999999 );\n    update_user_meta( $user->ID, 'wpide_login_code', array( 'hash' => wp_hash( $code ), 'expires' => time() + $wpide_code_minutes * MINUTE_IN_SECONDS, 'tries' => 0 ) );\n\n    wp_mail(\n        $user->user_email,\n        sprintf( '[%s] Your login code: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $code ),\n        sprintf( \"Your login code is %s\\n\\nIt expires in %d minutes. If you did not try to log in, change your password now.\", $code, $wpide_code_minutes )\n    );\n\n    return new WP_Error( 'wpide_code_sent', '<strong>Check your email.</strong> We sent you a 6-digit login code. Enter your password again together with the code.' );\n}, 99 );",
            "requires": [],
            "notes": "Make sure staff can receive email from the site before turning this on. Codes expire after 10 minutes and are locked after 5 wrong tries."
        },
        {
            "id": "new-device-login-alert",
            "version": 1,
            "title": "Email Users When They Log In From a New Device",
            "description": "Sends members an email when their account is used from a browser and network it has not seen before, so they can react quickly if it was not them.",
            "category": "login",
            "tags": [
                "login",
                "security",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// How many known devices to remember per user.\n$wpide_known_devices = 10;\n\nadd_action( 'wp_login', function ( $login, $user ) use ( $wpide_known_devices ) {\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $ua  = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';\n    // Use the network part of the IP so a changing last digit does not count as new.\n    $net = preg_replace( '/\\.\\d+$/', '', $ip );\n    $key = wp_hash( $net . '|' . $ua );\n\n    $known = get_user_meta( $user->ID, 'wpide_known_devices', true );\n    $first = ! is_array( $known );\n    $known = $first ? array() : $known;\n\n    if ( in_array( $key, $known, true ) ) {\n        return;\n    }\n\n    array_unshift( $known, $key );\n    update_user_meta( $user->ID, 'wpide_known_devices', array_slice( $known, 0, $wpide_known_devices ) );\n\n    if ( $first ) {\n        return;\n    }\n\n    wp_mail(\n        $user->user_email,\n        sprintf( '[%s] New login to your account', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf(\n            \"Hi %s,\\n\\nYour account was just used to log in from a new device.\\n\\nTime: %s\\nIP address: %s\\nBrowser: %s\\n\\nIf this was you, there is nothing to do. If not, reset your password now:\\n%s\",\n            $user->display_name,\n            wp_date( 'Y-m-d H:i' ),\n            $ip,\n            $ua,\n            wp_lostpassword_url()\n        )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "The first login after activation is remembered without an email. Only a hash of the browser and network is stored, not the full IP address."
        },
        {
            "id": "force-password-change-first-login",
            "version": 1,
            "title": "Make New Users Change Their Password on First Login",
            "description": "When an administrator creates an account, the new user is sent to their profile and asked to choose a new password before they can use the rest of the site.",
            "category": "login",
            "tags": [
                "password",
                "login",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'user_register', function ( $user_id ) {\n    if ( is_admin() && current_user_can( 'create_users' ) ) {\n        update_user_meta( $user_id, 'wpide_must_change_password', 1 );\n    }\n} );\n\n// Changing the password from the profile screen or the reset form clears the flag.\nadd_action( 'profile_update', function ( $user_id, $old_user ) {\n    $user = get_userdata( $user_id );\n\n    if ( $old_user instanceof WP_User && $user && $user->user_pass !== $old_user->user_pass ) {\n        delete_user_meta( $user_id, 'wpide_must_change_password' );\n    }\n}, 10, 2 );\nadd_action( 'after_password_reset', function ( $user ) {\n    delete_user_meta( $user->ID, 'wpide_must_change_password' );\n} );\n\n$wpide_needs_new_password = function () {\n    if ( ! is_user_logged_in() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {\n        return false;\n    }\n\n    return (bool) get_user_meta( get_current_user_id(), 'wpide_must_change_password', true );\n};\n\nadd_action( 'admin_init', function () use ( $wpide_needs_new_password ) {\n    global $pagenow;\n\n    if ( 'profile.php' !== $pagenow && $wpide_needs_new_password() ) {\n        wp_safe_redirect( admin_url( 'profile.php#password' ) );\n        exit;\n    }\n} );\n\nadd_action( 'template_redirect', function () use ( $wpide_needs_new_password ) {\n    if ( $wpide_needs_new_password() ) {\n        wp_safe_redirect( admin_url( 'profile.php#password' ) );\n        exit;\n    }\n} );\n\nadd_action( 'admin_notices', function () use ( $wpide_needs_new_password ) {\n    if ( $wpide_needs_new_password() ) {\n        echo '<div class=\"notice notice-warning\"><p><strong>Please choose a new password</strong> under \"Account Management\" below before continuing.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Applies to accounts created by administrators from now on, not to self-registrations or existing users."
        },
        {
            "id": "password-expiry-for-roles",
            "version": 1,
            "title": "Expire Passwords After 90 Days",
            "description": "Makes users in chosen roles reset their password once it is older than 90 days; at login they are told it has expired and pointed to the password reset form.",
            "category": "login",
            "tags": [
                "password",
                "security",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles whose passwords expire, and after how many days.\n$wpide_expiring_roles  = array( 'administrator', 'editor', 'shop_manager' );\n$wpide_password_days   = 90;\n\n$wpide_mark_password_changed = function ( $user_id ) {\n    update_user_meta( $user_id, 'wpide_password_changed', time() );\n};\n\nadd_action( 'user_register', $wpide_mark_password_changed );\nadd_action( 'after_password_reset', function ( $user ) use ( $wpide_mark_password_changed ) {\n    $wpide_mark_password_changed( $user->ID );\n} );\nadd_action( 'profile_update', function ( $user_id, $old_user ) use ( $wpide_mark_password_changed ) {\n    $user = get_userdata( $user_id );\n\n    if ( $old_user instanceof WP_User && $user && $user->user_pass !== $old_user->user_pass ) {\n        $wpide_mark_password_changed( $user_id );\n    }\n}, 10, 2 );\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_expiring_roles, $wpide_password_days, $wpide_mark_password_changed ) {\n    if ( ! $user instanceof WP_User || ! array_intersect( $wpide_expiring_roles, (array) $user->roles ) ) {\n        return $user;\n    }\n\n    $changed = (int) get_user_meta( $user->ID, 'wpide_password_changed', true );\n\n    if ( ! $changed ) {\n        $wpide_mark_password_changed( $user->ID );\n        return $user;\n    }\n\n    if ( time() - $changed > $wpide_password_days * DAY_IN_SECONDS ) {\n        return new WP_Error(\n            'wpide_password_expired',\n            sprintf( '<strong>Your password has expired.</strong> Please <a href=\"%s\">set a new password</a> to continue.', esc_url( wp_lostpassword_url() ) )\n        );\n    }\n\n    return $user;\n}, 60 );",
            "requires": [],
            "notes": "The clock starts for everyone on the day you activate the snippet, so nobody is locked out straight away."
        },
        {
            "id": "custom-lost-password-page",
            "version": 1,
            "title": "Lost Password Form on Your Own Page",
            "description": "Adds a [wpide_lost_password] shortcode with a \"reset your password\" form and points WordPress's \"Lost your password?\" links to the page that holds it.",
            "category": "login",
            "tags": [
                "password",
                "shortcode",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// ID of the page that contains [wpide_lost_password].\n$wpide_lost_password_page = 0;\n\nadd_filter( 'lostpassword_url', function ( $url, $redirect ) use ( $wpide_lost_password_page ) {\n    if ( ! $wpide_lost_password_page || 'publish' !== get_post_status( $wpide_lost_password_page ) ) {\n        return $url;\n    }\n\n    $page = get_permalink( $wpide_lost_password_page );\n\n    return $redirect ? add_query_arg( 'redirect_to', rawurlencode( $redirect ), $page ) : $page;\n}, 10, 2 );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_lostpass_nonce'] ) ) {\n        return;\n    }\n\n    if ( ! wp_verify_nonce( sanitize_key( $_POST['wpide_lostpass_nonce'] ), 'wpide_lostpass' ) ) {\n        wp_die( 'The form expired. Please go back and try again.' );\n    }\n\n    $login = isset( $_POST['wpide_user_login'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_user_login'] ) ) : '';\n\n    if ( '' !== $login ) {\n        retrieve_password( $login ); // Errors are ignored so the form never reveals which accounts exist.\n    }\n\n    wp_safe_redirect( add_query_arg( 'reset-sent', 1 ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_lost_password', function () {\n    if ( is_user_logged_in() ) {\n        return '<p>You are already logged in.</p>';\n    }\n\n    if ( isset( $_GET['reset-sent'] ) ) {\n        return '<p class=\"wpide-lostpass-notice\" role=\"status\">If an account matches, we have emailed a link to reset the password. Please check your inbox.</p>';\n    }\n\n    return '<form method=\"post\" class=\"wpide-lostpass-form\">'\n        . '<p><label for=\"wpide-user-login\">Username or email address</label><br><input type=\"text\" id=\"wpide-user-login\" name=\"wpide_user_login\" autocomplete=\"username\" required></p>'\n        . wp_nonce_field( 'wpide_lostpass', 'wpide_lostpass_nonce', true, false )\n        . '<p><button type=\"submit\">Email me a reset link</button></p></form>';\n} );",
            "requires": [],
            "notes": "Set $wpide_lost_password_page to the page ID. The reset link in the email still opens the secure WordPress screen to choose the new password."
        },
        {
            "id": "block-login-without-role",
            "version": 1,
            "title": "Block Logins From Accounts Without a Role",
            "description": "Stops accounts that have no role on this site (often left over after a role was removed) from logging in, which closes a common gap on older sites.",
            "category": "login",
            "tags": [
                "login",
                "roles",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'authenticate', function ( $user ) {\n    if ( ! $user instanceof WP_User || is_super_admin( $user->ID ) ) {\n        return $user;\n    }\n\n    if ( empty( $user->roles ) ) {\n        return new WP_Error( 'wpide_no_role', '<strong>Error:</strong> Your account does not have access to this site.' );\n    }\n\n    return $user;\n}, 60 );",
            "requires": [],
            "notes": "On multisite, network super admins are always allowed through."
        },
        {
            "id": "login-notice-for-guests-on-protected-pages",
            "version": 1,
            "title": "Explain Why Visitors Were Sent to the Login Page",
            "description": "Shows \"Please log in to view that page\" above the login form when a visitor was redirected there from a members-only page, instead of a bare form.",
            "category": "login",
            "tags": [
                "login",
                "message",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'login_message', function ( $message ) {\n    if ( empty( $_GET['redirect_to'] ) || isset( $_GET['action'] ) ) {\n        return $message;\n    }\n\n    $target = wp_validate_redirect( esc_url_raw( wp_unslash( $_GET['redirect_to'] ) ), '' );\n\n    if ( $target && false === strpos( $target, admin_url() ) ) {\n        $message .= '<p class=\"message\">Please log in to view that page.</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "It shows whenever redirect_to points to a page on your own site rather than wp-admin."
        },
        {
            "id": "logout-back-to-same-page",
            "version": 1,
            "title": "Log Out Back to the Same Page",
            "description": "Makes every \"Log out\" link on the front of the site return people to the page they were reading instead of the login screen.",
            "category": "login",
            "tags": [
                "logout",
                "redirect",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'logout_url', function ( $url, $redirect ) {\n    if ( $redirect || is_admin() ) {\n        return $url;\n    }\n\n    $current = home_url( add_query_arg( array() ) );\n\n    return add_query_arg( 'redirect_to', rawurlencode( $current ), $url );\n}, 10, 2 );\n\nadd_filter( 'logout_redirect', function ( $redirect_to, $requested ) {\n    if ( ! $requested ) {\n        return $redirect_to;\n    }\n\n    $post_id = url_to_postid( $requested );\n\n    // Do not send people back to private or members-only pages.\n    if ( $post_id && ( 'private' === get_post_status( $post_id ) || get_post_meta( $post_id, '_wpide_members_only', true ) ) ) {\n        return home_url( '/' );\n    }\n\n    return $redirect_to;\n}, 10, 2 );",
            "requires": [],
            "notes": "Pages that need a login send people to the homepage instead, so nobody lands on a locked page."
        },
        {
            "id": "reject-common-username-logins",
            "version": 1,
            "title": "Reject Login Attempts for Common Usernames",
            "description": "Refuses login attempts for usernames bots love to try, such as \"admin\", \"administrator\" or \"root\", before WordPress even checks a password.",
            "category": "login",
            "tags": [
                "login",
                "security",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Usernames that can never log in.\n$wpide_banned_logins = array( 'admin', 'administrator', 'root', 'test', 'user', 'demo' );\n\nadd_filter( 'authenticate', function ( $user, $username ) use ( $wpide_banned_logins ) {\n    if ( is_string( $username ) && in_array( strtolower( trim( $username ) ), $wpide_banned_logins, true ) ) {\n        return new WP_Error( 'wpide_banned_login', '<strong>Error:</strong> The username or password you entered is incorrect.' );\n    }\n\n    return $user;\n}, 1, 2 );",
            "requires": [],
            "notes": "Make sure none of your real accounts use one of the listed usernames before activating."
        },
        {
            "id": "welcome-back-message-after-login",
            "version": 1,
            "title": "\"Welcome Back\" Message After Logging In",
            "description": "Shows a small, dismissible \"Welcome back, Sam!\" notice on the front of the site right after a member logs in.",
            "category": "login",
            "tags": [
                "login",
                "members",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_login', function ( $login, $user ) {\n    update_user_meta( $user->ID, 'wpide_welcome_back', 1 );\n}, 10, 2 );\n\nadd_action( 'wp_footer', function () {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id || ! get_user_meta( $user_id, 'wpide_welcome_back', true ) ) {\n        return;\n    }\n\n    delete_user_meta( $user_id, 'wpide_welcome_back' );\n    $user = wp_get_current_user();\n    $name = $user->first_name ? $user->first_name : $user->display_name;\n    ?>\n    <div id=\"wpide-welcome\" role=\"status\" style=\"position:fixed;left:16px;right:16px;bottom:16px;max-width:360px;margin-left:auto;padding:12px 44px 12px 16px;border-radius:8px;background:#1d2327;color:#fff;box-shadow:0 4px 16px rgba(0,0,0,.25);z-index:99999\">\n        <?php echo esc_html( sprintf( 'Welcome back, %s!', $name ) ); ?>\n        <button type=\"button\" aria-label=\"Dismiss\" onclick=\"this.parentNode.remove()\" style=\"position:absolute;top:6px;right:8px;background:none;border:0;color:inherit;font-size:20px;cursor:pointer\">&times;</button>\n    </div>\n    <script>\n    setTimeout(function () {\n        var el = document.getElementById('wpide-welcome');\n        if (!el) { return; }\n        if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { el.remove(); return; }\n        el.style.transition = 'opacity .4s';\n        el.style.opacity = '0';\n        setTimeout(function () { el.remove(); }, 400);\n    }, 6000);\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "The notice shows once per login and disappears after 6 seconds; it respects reduced-motion settings."
        },
        {
            "id": "staff-login-office-hours",
            "version": 1,
            "title": "Allow Some Roles to Log In Only During Office Hours",
            "description": "Lets users in chosen roles, such as shop managers or support staff, log in only on working days between set hours in the site's time zone.",
            "category": "login",
            "tags": [
                "login",
                "roles",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Roles limited to office hours, and the hours (24h clock, site time zone).\n$wpide_office_roles = array( 'shop_manager', 'editor' );\n$wpide_office_start = 8;\n$wpide_office_end   = 18;\n$wpide_office_days  = array( 1, 2, 3, 4, 5 ); // 1 = Monday ... 7 = Sunday.\n\n$wpide_in_office_hours = function () use ( $wpide_office_start, $wpide_office_end, $wpide_office_days ) {\n    $now  = current_datetime();\n    $hour = (int) $now->format( 'G' );\n\n    return in_array( (int) $now->format( 'N' ), $wpide_office_days, true ) && $hour >= $wpide_office_start && $hour < $wpide_office_end;\n};\n\n$wpide_is_office_user = function ( $user ) use ( $wpide_office_roles ) {\n    return $user instanceof WP_User && ! user_can( $user, 'manage_options' ) && array_intersect( $wpide_office_roles, (array) $user->roles );\n};\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_in_office_hours, $wpide_is_office_user ) {\n    if ( $wpide_is_office_user( $user ) && ! $wpide_in_office_hours() ) {\n        return new WP_Error( 'wpide_office_hours', '<strong>Error:</strong> Your account can only be used during office hours.' );\n    }\n\n    return $user;\n}, 60 );\n\nadd_action( 'init', function () use ( $wpide_in_office_hours, $wpide_is_office_user ) {\n    if ( is_user_logged_in() && $wpide_is_office_user( wp_get_current_user() ) && ! $wpide_in_office_hours() ) {\n        wp_logout();\n    }\n} );",
            "requires": [],
            "notes": "Administrators are never restricted. Staff already logged in are logged out when office hours end."
        },
        {
            "id": "logout-on-role-change",
            "version": 1,
            "title": "Log Users Out Everywhere When Their Role Changes",
            "description": "Ends all of a user's active sessions as soon as an administrator changes or removes their role, so a demoted account cannot keep using old access.",
            "category": "login",
            "tags": [
                "sessions",
                "roles",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_end_sessions = function ( $user_id ) {\n    if ( (int) $user_id !== get_current_user_id() ) {\n        WP_Session_Tokens::get_instance( (int) $user_id )->destroy_all();\n    }\n};\n\nadd_action( 'set_user_role', function ( $user_id, $role, $old_roles ) use ( $wpide_end_sessions ) {\n    if ( $old_roles && ! in_array( $role, (array) $old_roles, true ) ) {\n        $wpide_end_sessions( $user_id );\n    }\n}, 10, 3 );\n\nadd_action( 'remove_user_role', function ( $user_id ) use ( $wpide_end_sessions ) {\n    $wpide_end_sessions( $user_id );\n} );",
            "requires": [],
            "notes": "Your own sessions are kept when you change your own role, so you are not logged out mid-edit."
        },
        {
            "id": "block-dormant-accounts",
            "version": 1,
            "title": "Lock Accounts That Haven't Been Used for Months",
            "description": "Blocks logins from accounts that have been inactive for a long time (180 days by default) until they reset their password, which protects forgotten accounts.",
            "category": "login",
            "tags": [
                "login",
                "security",
                "inactive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days without activity before the account is locked.\n$wpide_dormant_days = 180;\n\n// Record activity at most once a day.\nadd_action( 'init', function () {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id ) {\n        return;\n    }\n\n    $last = (int) get_user_meta( $user_id, 'wpide_last_active', true );\n\n    if ( time() - $last > DAY_IN_SECONDS ) {\n        update_user_meta( $user_id, 'wpide_last_active', time() );\n    }\n} );\n\nadd_action( 'after_password_reset', function ( $user ) {\n    update_user_meta( $user->ID, 'wpide_last_active', time() );\n} );\n\nadd_filter( 'authenticate', function ( $user ) use ( $wpide_dormant_days ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'manage_options' ) ) {\n        return $user;\n    }\n\n    $last = (int) get_user_meta( $user->ID, 'wpide_last_active', true );\n\n    if ( ! $last ) {\n        update_user_meta( $user->ID, 'wpide_last_active', time() );\n        return $user;\n    }\n\n    if ( time() - $last > $wpide_dormant_days * DAY_IN_SECONDS ) {\n        return new WP_Error( 'wpide_dormant', sprintf( '<strong>Your account was locked after a long time without use.</strong> Please <a href=\"%s\">reset your password</a> to unlock it.', esc_url( wp_lostpassword_url() ) ) );\n    }\n\n    return $user;\n}, 60 );",
            "requires": [],
            "notes": "Activity is tracked from activation, so existing users get a full period before any lock. Administrators are exempt."
        },
        {
            "id": "password-reset-email-only",
            "version": 1,
            "title": "Password Reset by Email Address Only",
            "description": "Makes the \"Lost your password?\" form accept only an email address, not a username, so the form can't be used to probe which usernames exist.",
            "category": "login",
            "tags": [
                "password",
                "security",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'lostpassword_post', function ( $errors ) {\n    $value = isset( $_POST['user_login'] ) ? sanitize_text_field( wp_unslash( $_POST['user_login'] ) ) : '';\n\n    if ( '' !== $value && ! is_email( $value ) ) {\n        $errors->add( 'wpide_email_only', '<strong>Error:</strong> Please enter the email address of your account.' );\n    }\n} );\n\nadd_filter( 'gettext', function ( $translation, $text, $domain ) {\n    if ( 'default' === $domain && 'Username or Email Address' === $text && isset( $GLOBALS['pagenow'] ) && 'wp-login.php' === $GLOBALS['pagenow'] && isset( $_GET['action'] ) && 'lostpassword' === $_GET['action'] ) {\n        return 'Email address';\n    }\n\n    return $translation;\n}, 10, 3 );",
            "requires": [],
            "notes": "The field label changes to \"Email address\" to match."
        },
        {
            "id": "members-only-posts-meta-box",
            "version": 1,
            "title": "Members-Only Posts and Pages With Role Choice",
            "description": "Adds a \"Members only\" box to the post and page editor; restricted content shows a login prompt to visitors and to roles you did not tick, and stays out of search and feeds for them.",
            "category": "memberships",
            "tags": [
                "members",
                "restrict",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the box.\n$wpide_mo_post_types = array( 'post', 'page' );\n\n$wpide_mo_can_view = function ( $post_id ) {\n    if ( ! get_post_meta( $post_id, '_wpide_members_only', true ) ) {\n        return true;\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return false;\n    }\n\n    $roles = (array) get_post_meta( $post_id, '_wpide_members_roles', true );\n    $roles = array_filter( $roles );\n\n    return ! $roles || array_intersect( $roles, (array) wp_get_current_user()->roles ) || current_user_can( 'edit_post', $post_id );\n};\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_mo_post_types ) {\n    add_meta_box( 'wpide-members-only', 'Members only', function ( $post ) {\n        $roles = array_filter( (array) get_post_meta( $post->ID, '_wpide_members_roles', true ) );\n        wp_nonce_field( 'wpide_members_only', 'wpide_members_only_nonce' );\n        printf( '<p><label><input type=\"checkbox\" name=\"wpide_members_only\" value=\"1\" %s> Only logged-in members can read this</label></p><p>Limit to these roles (optional):</p>', checked( (bool) get_post_meta( $post->ID, '_wpide_members_only', true ), true, false ) );\n\n        foreach ( wp_roles()->role_names as $role => $name ) {\n            printf( '<label style=\"display:block\"><input type=\"checkbox\" name=\"wpide_members_roles[]\" value=\"%1$s\" %2$s> %3$s</label>', esc_attr( $role ), checked( in_array( $role, $roles, true ), true, false ), esc_html( translate_user_role( $name ) ) );\n        }\n    }, $wpide_mo_post_types, 'side' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_members_only_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_members_only_nonce'] ), 'wpide_members_only' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    if ( empty( $_POST['wpide_members_only'] ) ) {\n        delete_post_meta( $post_id, '_wpide_members_only' );\n        delete_post_meta( $post_id, '_wpide_members_roles' );\n        return;\n    }\n\n    $roles = isset( $_POST['wpide_members_roles'] ) ? array_map( 'sanitize_key', (array) wp_unslash( $_POST['wpide_members_roles'] ) ) : array();\n    update_post_meta( $post_id, '_wpide_members_only', 1 );\n    update_post_meta( $post_id, '_wpide_members_roles', array_values( array_intersect( $roles, array_keys( wp_roles()->role_names ) ) ) );\n} );\n\n$wpide_mo_message = function () {\n    $login = wp_login_url( get_permalink() );\n\n    if ( is_user_logged_in() ) {\n        return '<div class=\"wpide-members-only\"><p>This content is not available with your current membership.</p></div>';\n    }\n\n    $html = '<div class=\"wpide-members-only\"><p>This content is for members. <a href=\"' . esc_url( $login ) . '\">Log in</a>';\n\n    if ( get_option( 'users_can_register' ) ) {\n        $html .= ' or <a href=\"' . esc_url( wp_registration_url() ) . '\">create a free account</a>';\n    }\n\n    return $html . ' to keep reading.</p></div>';\n};\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_mo_can_view, $wpide_mo_message ) {\n    $post = get_post();\n\n    return ( $post && ! $wpide_mo_can_view( $post->ID ) ) ? $wpide_mo_message() : $content;\n}, 1 );\n\nadd_filter( 'get_the_excerpt', function ( $excerpt, $post ) use ( $wpide_mo_can_view ) {\n    return ( $post && ! $wpide_mo_can_view( $post->ID ) ) ? '' : $excerpt;\n}, 10, 2 );\n\n// Keep restricted items out of search results and feeds for visitors.\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || is_user_logged_in() || ! $query->is_main_query() || ! ( $query->is_search() || $query->is_feed() ) ) {\n        return;\n    }\n\n    $meta   = (array) $query->get( 'meta_query' );\n    $meta[] = array( 'key' => '_wpide_members_only', 'compare' => 'NOT EXISTS' );\n    $query->set( 'meta_query', $meta );\n} );",
            "requires": [],
            "notes": "Tick no roles to allow every logged-in user. Titles stay public; only the content and excerpt are hidden. Works in the block and classic editors."
        },
        {
            "id": "members-only-category-teaser",
            "version": 1,
            "title": "Members-Only Category With a Teaser",
            "description": "Posts in a chosen category show visitors the first few lines followed by a log-in or sign-up box; logged-in members see the full post.",
            "category": "memberships",
            "tags": [
                "members",
                "category",
                "teaser"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slug that holds members-only posts, and how many words visitors see.\n$wpide_members_category = 'members';\n$wpide_teaser_words     = 60;\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_members_category, $wpide_teaser_words ) {\n    if ( is_user_logged_in() || ! in_the_loop() || ! has_category( $wpide_members_category ) ) {\n        return $content;\n    }\n\n    $teaser = wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_post_field( 'post_content', get_the_ID() ) ) ), $wpide_teaser_words );\n    $box    = '<div class=\"wpide-teaser-box\" style=\"border:1px solid currentColor;padding:1em 1.25em;margin-top:1.5em;border-radius:6px\">'\n        . '<p><strong>Keep reading with a free membership.</strong></p><p><a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">Log in</a>';\n\n    if ( get_option( 'users_can_register' ) ) {\n        $box .= ' &middot; <a href=\"' . esc_url( wp_registration_url() ) . '\">Create an account</a>';\n    }\n\n    return '<p>' . esc_html( $teaser ) . '</p>' . $box . '</p></div>';\n}, 1 );\n\nadd_filter( 'the_content_feed', function ( $content ) use ( $wpide_members_category ) {\n    return has_category( $wpide_members_category ) ? '<p>' . esc_html( get_the_excerpt() ) . '</p>' : $content;\n} );",
            "requires": [],
            "notes": "Set the category slug and teaser length at the top. The teaser is plain text, so images and embeds in the hidden part are never sent to visitors."
        },
        {
            "id": "drip-content-shortcode",
            "version": 1,
            "title": "Drip Content by Registration Date",
            "description": "Adds a [wpide_drip days=\"7\"] shortcode that unlocks the content inside it a set number of days after each member registered, and tells them when it unlocks.",
            "category": "memberships",
            "tags": [
                "drip",
                "members",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_drip', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'days' => 0, 'locked' => 'This part unlocks on %s.' ), $atts, 'wpide_drip' );\n\n    if ( ! is_user_logged_in() ) {\n        return '<p class=\"wpide-drip-locked\">Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to see this content.</p>';\n    }\n\n    if ( current_user_can( 'edit_others_posts' ) ) {\n        return do_shortcode( $content );\n    }\n\n    $unlocks = strtotime( wp_get_current_user()->user_registered . ' UTC' ) + max( 0, (int) $atts['days'] ) * DAY_IN_SECONDS;\n\n    if ( time() >= $unlocks ) {\n        return do_shortcode( $content );\n    }\n\n    return '<p class=\"wpide-drip-locked\">' . esc_html( sprintf( $atts['locked'], wp_date( get_option( 'date_format' ), $unlocks ) ) ) . '</p>';\n} );",
            "requires": [],
            "notes": "Example: [wpide_drip days=\"14\"]Week 3 lesson[/wpide_drip]. Administrators and editors always see the content."
        },
        {
            "id": "drip-posts-after-registration",
            "version": 1,
            "title": "Unlock Posts a Number of Days After Joining",
            "description": "Adds an \"Unlock after\" field to the post editor so a post's content only appears to members a set number of days after they registered, perfect for e-mail-course style sites.",
            "category": "memberships",
            "tags": [
                "drip",
                "members",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the field.\n$wpide_drip_post_types = array( 'post', 'page' );\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_drip_post_types ) {\n    add_meta_box( 'wpide-drip', 'Drip schedule', function ( $post ) {\n        wp_nonce_field( 'wpide_drip', 'wpide_drip_nonce' );\n        printf(\n            '<p><label for=\"wpide-drip-days\">Unlock this many days after a member joins</label><br><input type=\"number\" min=\"0\" step=\"1\" id=\"wpide-drip-days\" name=\"wpide_drip_days\" value=\"%s\" style=\"width:6em\"></p>',\n            esc_attr( get_post_meta( $post->ID, '_wpide_drip_days', true ) )\n        );\n    }, $wpide_drip_post_types, 'side' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_drip_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_drip_nonce'] ), 'wpide_drip' ) || ! current_user_can( 'edit_post', $post_id ) || wp_is_post_revision( $post_id ) ) {\n        return;\n    }\n\n    $days = isset( $_POST['wpide_drip_days'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_drip_days'] ) ) : '';\n\n    if ( '' === $days ) {\n        delete_post_meta( $post_id, '_wpide_drip_days' );\n    } else {\n        update_post_meta( $post_id, '_wpide_drip_days', absint( $days ) );\n    }\n} );\n\nadd_filter( 'the_content', function ( $content ) {\n    $post = get_post();\n\n    if ( ! $post ) {\n        return $content;\n    }\n\n    $days = get_post_meta( $post->ID, '_wpide_drip_days', true );\n\n    if ( '' === $days || current_user_can( 'edit_post', $post->ID ) ) {\n        return $content;\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return '<p class=\"wpide-drip-locked\">This lesson is for members. <a href=\"' . esc_url( wp_login_url( get_permalink( $post ) ) ) . '\">Log in</a> to continue.</p>';\n    }\n\n    $unlocks = strtotime( wp_get_current_user()->user_registered . ' UTC' ) + (int) $days * DAY_IN_SECONDS;\n\n    if ( time() >= $unlocks ) {\n        return $content;\n    }\n\n    return '<p class=\"wpide-drip-locked\">' . esc_html( sprintf( 'This lesson unlocks on %s. See you then!', wp_date( get_option( 'date_format' ), $unlocks ) ) ) . '</p>';\n}, 1 );",
            "requires": [],
            "notes": "Leave the field empty for posts everyone can read. Visitors who are not logged in see a login prompt on dripped posts."
        },
        {
            "id": "metered-paywall-free-articles",
            "version": 1,
            "title": "Free Article Limit for Visitors (Metered Paywall)",
            "description": "Lets visitors read a few posts each month for free, then asks them to log in or sign up to keep reading; members always read without limits.",
            "category": "memberships",
            "tags": [
                "paywall",
                "members",
                "articles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Free posts per visitor every 30 days.\n$wpide_free_articles = 3;\n\n$wpide_meter_read = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_free_articles, &$wpide_meter_read ) {\n    if ( is_user_logged_in() || ! is_singular( 'post' ) ) {\n        return;\n    }\n\n    $read = isset( $_COOKIE['wpide_meter'] ) ? array_map( 'absint', explode( '.', sanitize_text_field( wp_unslash( $_COOKIE['wpide_meter'] ) ) ) ) : array();\n    $read = array_values( array_filter( array_unique( $read ) ) );\n    $id   = get_queried_object_id();\n\n    if ( ! in_array( $id, $read, true ) && count( $read ) < $wpide_free_articles ) {\n        $read[] = $id;\n        setcookie( 'wpide_meter', implode( '.', $read ), array(\n            'expires'  => time() + 30 * DAY_IN_SECONDS,\n            'path'     => COOKIEPATH ? COOKIEPATH : '/',\n            'domain'   => COOKIE_DOMAIN,\n            'secure'   => is_ssl(),\n            'httponly' => true,\n            'samesite' => 'Lax',\n        ) );\n    }\n\n    $wpide_meter_read = $read;\n    nocache_headers();\n} );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_free_articles, &$wpide_meter_read ) {\n    if ( is_user_logged_in() || ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $id = get_the_ID();\n\n    if ( in_array( $id, $wpide_meter_read, true ) ) {\n        $left = $wpide_free_articles - count( $wpide_meter_read );\n\n        return '<p class=\"wpide-meter-note\"><small>' . esc_html( sprintf( _n( '%d free article left this month.', '%d free articles left this month.', $left ), $left ) ) . '</small></p>' . $content;\n    }\n\n    $html = '<p>' . esc_html( wp_trim_words( wp_strip_all_tags( strip_shortcodes( get_post_field( 'post_content', $id ) ) ), 40 ) ) . '</p>'\n        . '<div class=\"wpide-meter-wall\"><p><strong>You have read your free articles for this month.</strong></p><p><a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">Log in</a>';\n\n    if ( get_option( 'users_can_register' ) ) {\n        $html .= ' or <a href=\"' . esc_url( wp_registration_url() ) . '\">sign up for free</a>';\n    }\n\n    return $html . ' to keep reading.</p></div>';\n}, 1 );",
            "requires": [],
            "notes": "The count is kept in a cookie, so it is a soft wall rather than strict protection. Exclude these posts from full-page caching for visitors, or the limit cannot apply."
        },
        {
            "id": "block-visibility-classes-members",
            "version": 1,
            "title": "Show or Hide Blocks for Members and Guests",
            "description": "Hides any block from visitors or members based on a CSS class you add in the block's Advanced panel: wpide-members-only, wpide-guests-only or wpide-role-{role}.",
            "category": "memberships",
            "tags": [
                "blocks",
                "members",
                "visibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'render_block', function ( $html, $block ) {\n    $classes = isset( $block['attrs']['className'] ) ? preg_split( '/\\s+/', (string) $block['attrs']['className'] ) : array();\n\n    if ( ! $classes || false === strpos( $block['attrs']['className'], 'wpide-' ) ) {\n        return $html;\n    }\n\n    $logged_in = is_user_logged_in();\n\n    if ( in_array( 'wpide-members-only', $classes, true ) && ! $logged_in ) {\n        return '';\n    }\n\n    if ( in_array( 'wpide-guests-only', $classes, true ) && $logged_in ) {\n        return '';\n    }\n\n    $wanted = array();\n\n    foreach ( $classes as $class ) {\n        if ( 0 === strpos( $class, 'wpide-role-' ) ) {\n            $wanted[] = substr( $class, 11 );\n        }\n    }\n\n    if ( $wanted && ! array_intersect( $wanted, $logged_in ? (array) wp_get_current_user()->roles : array() ) ) {\n        return '';\n    }\n\n    return $html;\n}, 10, 2 );",
            "requires": [],
            "notes": "Hidden blocks are removed on the server, so their content never reaches the browser. Example class for editors only: wpide-role-editor."
        },
        {
            "id": "members-only-download-links",
            "version": 1,
            "title": "Members-Only Download Links",
            "description": "Adds a [wpide_member_download id=\"123\"] shortcode that gives logged-in members a personal download link for a Media Library file and shows visitors a login link instead.",
            "category": "memberships",
            "tags": [
                "downloads",
                "members",
                "files"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_member_download', function ( $atts ) {\n    $atts = shortcode_atts( array( 'id' => 0, 'label' => 'Download' ), $atts, 'wpide_member_download' );\n    $id   = absint( $atts['id'] );\n\n    if ( ! $id || 'attachment' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return '<a class=\"wpide-member-download is-locked\" href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">' . esc_html( 'Log in to download' ) . '</a>';\n    }\n\n    $url = wp_nonce_url( add_query_arg( 'wpide_download', $id, home_url( '/' ) ), 'wpide_download_' . $id );\n\n    return '<a class=\"wpide-member-download\" href=\"' . esc_url( $url ) . '\">' . esc_html( $atts['label'] ) . '</a>';\n} );\n\nadd_action( 'init', function () {\n    if ( empty( $_GET['wpide_download'] ) ) {\n        return;\n    }\n\n    $id = absint( $_GET['wpide_download'] );\n\n    if ( ! is_user_logged_in() ) {\n        auth_redirect();\n    }\n\n    if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wpide_download_' . $id ) ) {\n        wp_die( 'This download link has expired. Please go back and click it again.', 'Download', array( 'response' => 403 ) );\n    }\n\n    $file    = get_attached_file( $id );\n    $uploads = wp_get_upload_dir();\n\n    if ( 'attachment' !== get_post_type( $id ) || ! $file || ! is_readable( $file ) || 0 !== strpos( wp_normalize_path( realpath( $file ) ), wp_normalize_path( realpath( $uploads['basedir'] ) ) ) ) {\n        wp_die( 'File not found.', 'Download', array( 'response' => 404 ) );\n    }\n\n    nocache_headers();\n    header( 'Content-Type: ' . ( get_post_mime_type( $id ) ? get_post_mime_type( $id ) : 'application/octet-stream' ) );\n    header( 'Content-Disposition: attachment; filename=\"' . sanitize_file_name( wp_basename( $file ) ) . '\"' );\n    header( 'Content-Length: ' . filesize( $file ) );\n    header( 'X-Content-Type-Options: nosniff' );\n\n    while ( ob_get_level() ) {\n        ob_end_clean();\n    }\n\n    readfile( $file );\n    exit;\n} );",
            "requires": [],
            "notes": "Links are tied to the member's session, so shared links do not work for others. The file's normal uploads URL is still public unless your server blocks it."
        },
        {
            "id": "private-client-pages",
            "version": 1,
            "title": "Private Pages for Individual Members",
            "description": "Adds a \"Page owner\" setting to pages so you can build a private page for one member or client; only that person and editors can open it, and a [wpide_my_pages] shortcode lists a member's pages.",
            "category": "memberships",
            "tags": [
                "private",
                "members",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_page', function ( $post ) {\n    add_meta_box( 'wpide-page-owner', 'Page owner', function ( $post ) {\n        wp_nonce_field( 'wpide_page_owner', 'wpide_page_owner_nonce' );\n        echo '<p><label for=\"wpide-page-owner\">Only this member (and editors) can view the page:</label></p>';\n        wp_dropdown_users( array(\n            'name'              => 'wpide_page_owner',\n            'id'                => 'wpide-page-owner',\n            'selected'          => (int) get_post_meta( $post->ID, '_wpide_page_owner', true ),\n            'show_option_none'  => 'Everyone (not private)',\n            'option_none_value' => 0,\n        ) );\n    }, 'page', 'side' );\n} );\n\nadd_action( 'save_post_page', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_page_owner_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_page_owner_nonce'] ), 'wpide_page_owner' ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $owner = isset( $_POST['wpide_page_owner'] ) ? absint( $_POST['wpide_page_owner'] ) : 0;\n\n    if ( $owner ) {\n        update_post_meta( $post_id, '_wpide_page_owner', $owner );\n    } else {\n        delete_post_meta( $post_id, '_wpide_page_owner' );\n    }\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( ! is_page() ) {\n        return;\n    }\n\n    $page_id = get_queried_object_id();\n    $owner   = (int) get_post_meta( $page_id, '_wpide_page_owner', true );\n\n    if ( ! $owner || get_current_user_id() === $owner || current_user_can( 'edit_post', $page_id ) ) {\n        return;\n    }\n\n    if ( ! is_user_logged_in() ) {\n        auth_redirect();\n    }\n\n    global $wp_query;\n    $wp_query->set_404();\n    status_header( 404 );\n    nocache_headers();\n} );\n\nadd_shortcode( 'wpide_my_pages', function () {\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $pages = get_posts( array(\n        'post_type'     => 'page',\n        'numberposts'   => 50,\n        'meta_key'      => '_wpide_page_owner',\n        'meta_value'    => get_current_user_id(),\n        'orderby'       => 'title',\n        'order'         => 'ASC',\n        'no_found_rows' => true,\n    ) );\n\n    if ( ! $pages ) {\n        return '<p>You do not have any private pages yet.</p>';\n    }\n\n    $html = '<ul class=\"wpide-my-pages\">';\n    foreach ( $pages as $page ) {\n        $html .= '<li><a href=\"' . esc_url( get_permalink( $page ) ) . '\">' . esc_html( get_the_title( $page ) ) . '</a></li>';\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [],
            "notes": "Visitors are sent to the login screen and other members get a 404, so the page's existence is not revealed."
        },
        {
            "id": "members-only-teaser-fade-css",
            "version": 1,
            "title": "Fade-Out Effect Above Members-Only Prompts",
            "description": "Styles the members-only and paywall prompts from the WPIDE library with a soft fade over the last lines of the teaser and a clear call-to-action box.",
            "category": "memberships",
            "tags": [
                "css",
                "paywall",
                "members"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-members-only,\n.wpide-teaser-box,\n.wpide-meter-wall {\n    position: relative;\n    margin-top: 2.5em;\n    padding: 1.25em 1.5em;\n    border: 1px solid rgba(127, 127, 127, 0.35);\n    border-radius: 8px;\n    background: rgba(127, 127, 127, 0.06);\n    text-align: center;\n}\n\n.wpide-members-only::before,\n.wpide-teaser-box::before,\n.wpide-meter-wall::before {\n    content: \"\";\n    position: absolute;\n    left: 0;\n    right: 0;\n    bottom: 100%;\n    height: 4em;\n    margin-bottom: 1px;\n    background: linear-gradient(to bottom, transparent, var(--wp--preset--color--base, #fff));\n    pointer-events: none;\n}\n\n.wpide-members-only a,\n.wpide-teaser-box a,\n.wpide-meter-wall a {\n    font-weight: 600;\n}\n\n@media (prefers-color-scheme: dark) {\n    .wpide-members-only::before,\n    .wpide-teaser-box::before,\n    .wpide-meter-wall::before {\n        background: linear-gradient(to bottom, transparent, var(--wp--preset--color--base, #111));\n    }\n}",
            "requires": [],
            "notes": "Targets the .wpide-members-only, .wpide-teaser-box and .wpide-meter-wall boxes; change the colours to match your theme."
        },
        {
            "id": "private-member-feed",
            "version": 1,
            "title": "Private Full-Content RSS Feed for Members",
            "description": "Gives each member a personal feed URL (shown on their profile) that includes members-only posts in full, so they can read in a feed reader or podcast app.",
            "category": "memberships",
            "tags": [
                "rss",
                "members",
                "feeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_feed_key = function ( $user_id, $reset = false ) {\n    $key = get_user_meta( $user_id, 'wpide_feed_key', true );\n\n    if ( ! $key || $reset ) {\n        $key = wp_generate_password( 24, false );\n        update_user_meta( $user_id, 'wpide_feed_key', $key );\n    }\n\n    return $key;\n};\n\n// Treat a feed request with a valid key as that member, for this request only.\nadd_filter( 'determine_current_user', function ( $user_id ) {\n    $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';\n\n    if ( $user_id || empty( $_GET['member_key'] ) || ( empty( $_GET['feed'] ) && false === strpos( $uri, '/feed' ) ) ) {\n        return $user_id;\n    }\n\n    $key   = sanitize_text_field( wp_unslash( $_GET['member_key'] ) );\n    $users = get_users( array( 'meta_key' => 'wpide_feed_key', 'meta_value' => $key, 'fields' => 'ID', 'number' => 1 ) );\n\n    if ( $users && ! user_can( (int) $users[0], 'edit_posts' ) ) {\n        add_action( 'template_redirect', function () {\n            if ( ! is_feed() ) {\n                wp_die( 'This key only works for feeds.', 'Forbidden', array( 'response' => 403 ) );\n            }\n        }, 0 );\n        return (int) $users[0];\n    }\n\n    return $user_id;\n}, 30 );\n\n$wpide_feed_profile = function ( $user ) use ( $wpide_feed_key ) {\n    if ( get_current_user_id() !== $user->ID || user_can( $user, 'edit_posts' ) ) {\n        return;\n    }\n\n    $url = add_query_arg( 'member_key', $wpide_feed_key( $user->ID ), get_feed_link() );\n    wp_nonce_field( 'wpide_feed_reset', 'wpide_feed_nonce' );\n    printf(\n        '<h2>Your private feed</h2><table class=\"form-table\" role=\"presentation\"><tr><th scope=\"row\">Feed URL</th><td><input type=\"text\" readonly class=\"large-text code\" value=\"%s\" onfocus=\"this.select()\"><p><label><input type=\"checkbox\" name=\"wpide_feed_reset\" value=\"1\"> Create a new URL (the old one stops working)</label></p></td></tr></table>',\n        esc_attr( $url )\n    );\n};\nadd_action( 'show_user_profile', $wpide_feed_profile );\n\nadd_action( 'personal_options_update', function ( $user_id ) use ( $wpide_feed_key ) {\n    if ( ! empty( $_POST['wpide_feed_reset'] ) && isset( $_POST['wpide_feed_nonce'] ) && wp_verify_nonce( sanitize_key( $_POST['wpide_feed_nonce'] ), 'wpide_feed_reset' ) ) {\n        $wpide_feed_key( $user_id, true );\n    }\n} );",
            "requires": [],
            "notes": "The key only unlocks feeds, never the rest of the site. Members can reset their key from their profile if the URL leaks."
        },
        {
            "id": "trial-role-expiry",
            "version": 1,
            "title": "Trial Accounts That Downgrade After a Set Number of Days",
            "description": "Gives new sign-ups a \"trial\" role for a limited time (14 days by default) and then switches them to a basic role automatically the next time they visit.",
            "category": "memberships",
            "tags": [
                "trial",
                "roles",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role during the trial, role afterwards, and trial length.\n$wpide_trial_role  = 'contributor';\n$wpide_after_role  = 'subscriber';\n$wpide_trial_days  = 14;\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_trial_role ) {\n    if ( current_user_can( 'create_users' ) || ! get_role( $wpide_trial_role ) ) {\n        return;\n    }\n\n    ( new WP_User( $user_id ) )->set_role( $wpide_trial_role );\n    update_user_meta( $user_id, 'wpide_trial_started', time() );\n}, 20 );\n\nadd_action( 'init', function () use ( $wpide_trial_role, $wpide_after_role, $wpide_trial_days ) {\n    $user_id = get_current_user_id();\n    $started = $user_id ? (int) get_user_meta( $user_id, 'wpide_trial_started', true ) : 0;\n\n    if ( ! $started || time() - $started < $wpide_trial_days * DAY_IN_SECONDS ) {\n        return;\n    }\n\n    $user = wp_get_current_user();\n\n    if ( in_array( $wpide_trial_role, (array) $user->roles, true ) ) {\n        $user->remove_role( $wpide_trial_role );\n        $user->add_role( $wpide_after_role );\n    }\n\n    delete_user_meta( $user_id, 'wpide_trial_started' );\n    update_user_meta( $user_id, 'wpide_trial_ended', time() );\n} );\n\nadd_shortcode( 'wpide_trial_days_left', function () use ( $wpide_trial_days ) {\n    $started = is_user_logged_in() ? (int) get_user_meta( get_current_user_id(), 'wpide_trial_started', true ) : 0;\n\n    if ( ! $started ) {\n        return '';\n    }\n\n    $left = max( 0, (int) ceil( ( $started + $wpide_trial_days * DAY_IN_SECONDS - time() ) / DAY_IN_SECONDS ) );\n\n    return esc_html( sprintf( _n( '%d day left in your trial', '%d days left in your trial', $left ), $left ) );\n} );",
            "requires": [],
            "notes": "Both roles must exist. Accounts made by administrators are not put on trial; [wpide_trial_days_left] shows the days remaining."
        },
        {
            "id": "lock-icon-on-members-only-titles",
            "version": 1,
            "title": "Lock Icon on Members-Only Post Titles",
            "description": "Adds a small lock symbol before the titles of members-only posts in lists and archives for visitors, so they know what needs an account before clicking.",
            "category": "memberships",
            "tags": [
                "members",
                "titles",
                "icons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_lock_category = 'members';\n\nadd_filter( 'the_title', function ( $title, $post_id = 0 ) use ( $wpide_lock_category ) {\n    if ( is_user_logged_in() || ! $post_id || is_admin() || ! in_the_loop() || is_singular() && get_queried_object_id() === (int) $post_id ) {\n        return $title;\n    }\n\n    $locked = get_post_meta( $post_id, '_wpide_members_only', true ) || ( 'post' === get_post_type( $post_id ) && has_category( $wpide_lock_category, $post_id ) );\n\n    return $locked ? '<span class=\"wpide-lock\" aria-label=\"Members only\" role=\"img\">&#128274;</span> ' . $title : $title;\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with posts marked members-only by the WPIDE \"Members-Only Posts\" snippet or placed in the members category; edit $wpide_lock_category to match yours."
        },
        {
            "id": "members-only-after-more-tag",
            "version": 1,
            "title": "Members-Only Content After the \"More\" Tag",
            "description": "Shows visitors everything above the \"More\" block of a post and keeps the rest for logged-in members, with a login prompt where the content stops.",
            "category": "memberships",
            "tags": [
                "members",
                "teaser",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slugs to apply this to; leave empty for all posts.\n$wpide_more_categories = array();\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_more_categories ) {\n    if ( is_user_logged_in() || ! is_singular( 'post' ) || ! in_the_loop() ) {\n        return $content;\n    }\n\n    $raw = get_post_field( 'post_content', get_the_ID() );\n\n    if ( false === strpos( $raw, '<!--more' ) || ( $wpide_more_categories && ! has_category( $wpide_more_categories ) ) ) {\n        return $content;\n    }\n\n    $parts  = get_extended( $raw );\n    $teaser = apply_filters( 'wpide_members_more_teaser', do_blocks( $parts['main'] ) );\n\n    $box = '<div class=\"wpide-members-only\"><p>The rest of this post is for members. <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">Log in</a>';\n\n    if ( get_option( 'users_can_register' ) ) {\n        $box .= ' or <a href=\"' . esc_url( wp_registration_url() ) . '\">join for free</a>';\n    }\n\n    return wp_kses_post( wpautop( $teaser ) ) . $box . ' to keep reading.</p></div>';\n}, 1 );",
            "requires": [],
            "notes": "Only posts that contain a More block (or <!--more-->) are affected. Limit it to some categories with $wpide_more_categories."
        },
        {
            "id": "learndash-redirect-after-course-completion",
            "version": 1,
            "title": "LearnDash: Redirect Students After They Finish a Course",
            "description": "Sends students to a page of your choice (a thank-you page, survey or upsell) the next time they load a page after completing a LearnDash course.",
            "category": "learning",
            "tags": [
                "learndash",
                "redirect",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Course ID => URL. \"default\" applies to all other courses.\n$wpide_ld_done_pages = array(\n    'default' => home_url( '/thank-you/' ),\n);\n\nadd_action( 'learndash_course_completed', function ( $data ) use ( $wpide_ld_done_pages ) {\n    $course_id = isset( $data['course']->ID ) ? (int) $data['course']->ID : 0;\n    $user_id   = isset( $data['user']->ID ) ? (int) $data['user']->ID : 0;\n    $url       = $wpide_ld_done_pages[ $course_id ] ?? ( $wpide_ld_done_pages['default'] ?? '' );\n\n    if ( $user_id && $url ) {\n        update_user_meta( $user_id, 'wpide_ld_done_redirect', esc_url_raw( $url ) );\n    }\n} );\n\nadd_action( 'template_redirect', function () {\n    $user_id = get_current_user_id();\n    $url     = $user_id ? get_user_meta( $user_id, 'wpide_ld_done_redirect', true ) : '';\n\n    if ( $url ) {\n        delete_user_meta( $user_id, 'wpide_ld_done_redirect' );\n        wp_safe_redirect( $url );\n        exit;\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Map course IDs to pages in $wpide_ld_done_pages; the \"default\" entry is used for every other course. Leave it empty to only redirect the courses you list."
        },
        {
            "id": "learndash-course-progress-shortcode",
            "version": 1,
            "title": "LearnDash: Course Progress Bar Shortcode",
            "description": "Adds a [wpide_ld_progress] shortcode that shows the logged-in student's progress in a course as an accessible bar with \"4 of 10 steps (40%)\".",
            "category": "learning",
            "tags": [
                "learndash",
                "progress",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_progress', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0 ), $atts, 'wpide_ld_progress' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $user_id || ! $course_id || ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n        return '';\n    }\n\n    $progress = learndash_course_progress( array( 'user_id' => $user_id, 'course_id' => $course_id, 'array' => true ) );\n    $percent  = isset( $progress['percentage'] ) ? (int) $progress['percentage'] : 0;\n    $label    = sprintf( '%1$d of %2$d steps (%3$d%%)', (int) ( $progress['completed'] ?? 0 ), (int) ( $progress['total'] ?? 0 ), $percent );\n\n    return sprintf(\n        '<div class=\"wpide-ld-progress\"><div role=\"progressbar\" aria-valuenow=\"%1$d\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"%2$s\" style=\"height:10px;border-radius:5px;background:rgba(127,127,127,.25);overflow:hidden\"><div style=\"width:%1$d%%;height:100%%;background:currentColor\"></div></div><p style=\"margin:.4em 0 0;font-size:.9em\">%3$s</p></div>',\n        $percent,\n        esc_attr( get_the_title( $course_id ) . ' progress' ),\n        esc_html( $label )\n    );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Inside a course, lesson or topic it uses the current course; elsewhere pass course_id=\"123\"."
        },
        {
            "id": "learndash-my-courses-list",
            "version": 1,
            "title": "LearnDash: \"My Courses\" List With Progress",
            "description": "Adds a [wpide_ld_my_courses] shortcode that lists every course the student is enrolled in, with their progress and a Start, Continue or Review link.",
            "category": "learning",
            "tags": [
                "learndash",
                "courses",
                "dashboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_my_courses', function () {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id ) {\n        return '<p>Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to see your courses.</p>';\n    }\n\n    $course_ids = learndash_user_get_enrolled_courses( $user_id );\n\n    if ( ! $course_ids ) {\n        return '<p>You are not enrolled in any courses yet.</p>';\n    }\n\n    $courses = get_posts( array( 'post_type' => 'sfwd-courses', 'post__in' => array_map( 'intval', $course_ids ), 'numberposts' => count( $course_ids ), 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true ) );\n    $html    = '<ul class=\"wpide-ld-my-courses\" style=\"list-style:none;padding:0\">';\n\n    foreach ( $courses as $course ) {\n        $progress = learndash_course_progress( array( 'user_id' => $user_id, 'course_id' => $course->ID, 'array' => true ) );\n        $percent  = (int) ( $progress['percentage'] ?? 0 );\n        $action   = $percent >= 100 ? 'Review' : ( $percent > 0 ? 'Continue' : 'Start' );\n\n        $html .= sprintf(\n            '<li style=\"display:flex;flex-wrap:wrap;justify-content:space-between;gap:.5em;padding:.75em 0;border-bottom:1px solid rgba(127,127,127,.25)\"><span><strong>%1$s</strong> <small>%2$d%% complete</small></span><a href=\"%3$s\">%4$s</a></li>',\n            esc_html( get_the_title( $course ) ),\n            $percent,\n            esc_url( get_permalink( $course ) ),\n            esc_html( $action )\n        );\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Visitors who are not logged in see a login link. Courses are listed in alphabetical order."
        },
        {
            "id": "learndash-students-column-on-courses",
            "version": 1,
            "title": "LearnDash: Students Column on the Courses Screen",
            "description": "Adds a \"Students\" column to the LearnDash Courses list showing how many users are enrolled in each course, cached for speed.",
            "category": "learning",
            "tags": [
                "learndash",
                "admin",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'manage_sfwd-courses_posts_columns', function ( $columns ) {\n    $columns['wpide_ld_students'] = 'Students';\n\n    return $columns;\n} );\n\nadd_action( 'manage_sfwd-courses_posts_custom_column', function ( $column, $course_id ) {\n    if ( 'wpide_ld_students' !== $column ) {\n        return;\n    }\n\n    $key   = 'wpide_ld_students_' . $course_id;\n    $count = get_transient( $key );\n\n    if ( false === $count ) {\n        $query = new WP_User_Query( array(\n            'fields'      => 'ID',\n            'number'      => 1,\n            'count_total' => true,\n            'meta_key'    => 'course_' . (int) $course_id . '_access_from',\n            'meta_compare' => 'EXISTS',\n        ) );\n        $count = $query->get_total();\n        set_transient( $key, $count, 6 * HOUR_IN_SECONDS );\n    }\n\n    echo esc_html( number_format_i18n( (int) $count ) );\n}, 10, 2 );\n\nadd_action( 'learndash_update_course_access', function ( $user_id, $course_id ) {\n    delete_transient( 'wpide_ld_students_' . (int) $course_id );\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "Counts direct enrollments; students who only get access through a group, or courses set to \"Open\", are not included. Counts refresh every 6 hours."
        },
        {
            "id": "learndash-access-mode-column",
            "version": 1,
            "title": "LearnDash: Access Mode Column on the Courses Screen",
            "description": "Shows each course's access mode (Open, Free, Buy now, Recurring or Closed) in a column on the Courses list, so you can check pricing at a glance.",
            "category": "learning",
            "tags": [
                "learndash",
                "admin",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'manage_sfwd-courses_posts_columns', function ( $columns ) {\n    $columns['wpide_ld_access'] = 'Access';\n\n    return $columns;\n} );\n\nadd_action( 'manage_sfwd-courses_posts_custom_column', function ( $column, $course_id ) {\n    if ( 'wpide_ld_access' !== $column ) {\n        return;\n    }\n\n    $labels = array(\n        'open'      => 'Open',\n        'free'      => 'Free',\n        'paynow'    => 'Buy now',\n        'subscribe' => 'Recurring',\n        'closed'    => 'Closed',\n    );\n    $type  = (string) learndash_get_setting( $course_id, 'course_price_type' );\n    $price = (string) learndash_get_setting( $course_id, 'course_price' );\n\n    echo esc_html( ( $labels[ $type ] ?? ucfirst( $type ) ) . ( $price && in_array( $type, array( 'paynow', 'subscribe' ), true ) ? ' (' . $price . ')' : '' ) );\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "Reads the course's own setting; nothing is stored."
        },
        {
            "id": "learndash-auto-enroll-new-users",
            "version": 1,
            "title": "LearnDash: Enroll New Users in Starter Courses",
            "description": "Automatically enrolls every newly registered user in the LearnDash courses you choose, such as a free onboarding course.",
            "category": "learning",
            "tags": [
                "learndash",
                "enrollment",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Course IDs to enroll new users in.\n$wpide_ld_starter_courses = array( 123 );\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_ld_starter_courses ) {\n    foreach ( $wpide_ld_starter_courses as $course_id ) {\n        if ( 'sfwd-courses' === get_post_type( $course_id ) ) {\n            ld_update_course_access( $user_id, (int) $course_id );\n        }\n    }\n}, 20 );",
            "requires": [
                "learndash"
            ],
            "notes": "Add your course IDs to $wpide_ld_starter_courses. Existing users are not enrolled."
        },
        {
            "id": "learndash-enroll-by-role",
            "version": 1,
            "title": "LearnDash: Enroll Users When They Get a Role",
            "description": "Gives users access to chosen LearnDash courses as soon as they are given a role, for example enrolling every new \"Employee\" in the compliance training.",
            "category": "learning",
            "tags": [
                "learndash",
                "roles",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Role => course IDs.\n$wpide_ld_role_courses = array(\n    'editor' => array( 123, 456 ),\n);\n\n$wpide_ld_enroll_role = function ( $user_id, $role ) use ( $wpide_ld_role_courses ) {\n    foreach ( $wpide_ld_role_courses[ $role ] ?? array() as $course_id ) {\n        if ( ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n            ld_update_course_access( $user_id, (int) $course_id );\n        }\n    }\n};\n\nadd_action( 'set_user_role', function ( $user_id, $role ) use ( $wpide_ld_enroll_role ) {\n    $wpide_ld_enroll_role( $user_id, $role );\n}, 10, 2 );\n\nadd_action( 'add_user_role', function ( $user_id, $role ) use ( $wpide_ld_enroll_role ) {\n    $wpide_ld_enroll_role( $user_id, $role );\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "Access is added, never removed, when roles change. Works for role changes from the admin, other plugins and new registrations."
        },
        {
            "id": "learndash-email-admin-course-completed",
            "version": 1,
            "title": "LearnDash: Email the Admin When a Course Is Completed",
            "description": "Emails you (or a list of addresses) whenever a student completes a LearnDash course, with the student's name and a link to their profile.",
            "category": "learning",
            "tags": [
                "learndash",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n$wpide_ld_notify_emails  = array( get_option( 'admin_email' ) );\n// Leave empty for all courses.\n$wpide_ld_notify_courses = array();\n\nadd_action( 'learndash_course_completed', function ( $data ) use ( $wpide_ld_notify_emails, $wpide_ld_notify_courses ) {\n    if ( empty( $data['user'] ) || empty( $data['course'] ) ) {\n        return;\n    }\n\n    if ( $wpide_ld_notify_courses && ! in_array( (int) $data['course']->ID, $wpide_ld_notify_courses, true ) ) {\n        return;\n    }\n\n    wp_mail(\n        $wpide_ld_notify_emails,\n        sprintf( 'Course completed: %s', get_the_title( $data['course'] ) ),\n        sprintf(\n            \"%s (%s) completed \\\"%s\\\" on %s.\\n\\nProfile: %s\",\n            $data['user']->display_name,\n            $data['user']->user_email,\n            get_the_title( $data['course'] ),\n            wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),\n            admin_url( 'user-edit.php?user_id=' . (int) $data['user']->ID )\n        )\n    );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Add more addresses to $wpide_ld_notify_emails, or limit it to some courses with $wpide_ld_notify_courses."
        },
        {
            "id": "learndash-congratulations-email",
            "version": 1,
            "title": "LearnDash: Congratulations Email With Certificate Link",
            "description": "Sends students a friendly congratulations email when they complete a course, including a link to download their certificate when the course has one.",
            "category": "learning",
            "tags": [
                "learndash",
                "email",
                "certificates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n$wpide_ld_congrats_subject = 'Congratulations on finishing %2$s!';\n$wpide_ld_congrats_message = \"Hi %1\\$s,\\n\\nWell done, you have completed \\\"%2\\$s\\\".\";\n\nadd_action( 'learndash_course_completed', function ( $data ) use ( $wpide_ld_congrats_subject, $wpide_ld_congrats_message ) {\n    if ( empty( $data['user'] ) || empty( $data['course'] ) ) {\n        return;\n    }\n\n    $user   = $data['user'];\n    $course = $data['course'];\n    $name   = $user->first_name ? $user->first_name : $user->display_name;\n    $title  = get_the_title( $course );\n    $body   = sprintf( $wpide_ld_congrats_message, $name, $title );\n    $cert   = learndash_get_course_certificate_link( $course->ID, $user->ID );\n\n    if ( $cert ) {\n        $body .= \"\\n\\nDownload your certificate:\\n\" . $cert;\n    }\n\n    $body .= \"\\n\\nSee you in the next course!\\n\" . get_bloginfo( 'name' );\n\n    wp_mail( $user->user_email, sprintf( $wpide_ld_congrats_subject, $name, $title ), $body );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Edit the subject and message at the top; %1$s is the student's first name and %2$s the course title."
        },
        {
            "id": "learndash-quiz-failed-email",
            "version": 1,
            "title": "LearnDash: Encouraging Email When a Quiz Is Failed",
            "description": "Emails students who did not pass a LearnDash quiz with their score and a link to try again, so they do not give up.",
            "category": "learning",
            "tags": [
                "learndash",
                "quizzes",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'learndash_quiz_completed', function ( $quizdata, $user ) {\n    if ( ! $user instanceof WP_User || ! empty( $quizdata['pass'] ) ) {\n        return;\n    }\n\n    $quiz_id = isset( $quizdata['quiz'] ) ? ( is_object( $quizdata['quiz'] ) ? (int) $quizdata['quiz']->ID : (int) $quizdata['quiz'] ) : 0;\n\n    if ( ! $quiz_id ) {\n        return;\n    }\n\n    $key = 'wpide_ld_fail_' . $user->ID . '_' . $quiz_id;\n\n    if ( get_transient( $key ) ) {\n        return;\n    }\n\n    set_transient( $key, 1, DAY_IN_SECONDS );\n\n    wp_mail(\n        $user->user_email,\n        sprintf( 'Almost there: %s', get_the_title( $quiz_id ) ),\n        sprintf(\n            \"Hi %s,\\n\\nYou scored %s%% on \\\"%s\\\". That is not a pass yet, but you can review the lessons and try again any time:\\n%s\\n\\nYou've got this!\",\n            $user->first_name ? $user->first_name : $user->display_name,\n            isset( $quizdata['percentage'] ) ? round( (float) $quizdata['percentage'] ) : 0,\n            get_the_title( $quiz_id ),\n            get_permalink( $quiz_id )\n        )\n    );\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "Sent at most once a day per quiz and student, so repeated attempts do not flood their inbox."
        },
        {
            "id": "learndash-login-required-for-lessons",
            "version": 1,
            "title": "LearnDash: Send Visitors to Login From Lessons",
            "description": "Redirects logged-out visitors who open a lesson, topic or quiz to the login screen, then brings them back to that page after they log in.",
            "category": "learning",
            "tags": [
                "learndash",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( is_user_logged_in() || ! is_singular( array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz' ) ) ) {\n        return;\n    }\n\n    wp_safe_redirect( wp_login_url( get_permalink() ) );\n    exit;\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Course pages stay public so visitors can still see what they would be buying."
        },
        {
            "id": "learndash-certificate-button-shortcode",
            "version": 1,
            "title": "LearnDash: Certificate Download Button Shortcode",
            "description": "Adds a [wpide_ld_certificate course_id=\"123\"] shortcode that shows a \"Download your certificate\" button once the student has completed the course, and nothing before that.",
            "category": "learning",
            "tags": [
                "learndash",
                "certificates",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_certificate', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0, 'label' => 'Download your certificate' ), $atts, 'wpide_ld_certificate' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $user_id || ! $course_id ) {\n        return '';\n    }\n\n    $link = learndash_get_course_certificate_link( $course_id, $user_id );\n\n    if ( ! $link ) {\n        return '';\n    }\n\n    return '<a class=\"wpide-ld-certificate wp-element-button\" href=\"' . esc_url( $link ) . '\" target=\"_blank\" rel=\"noopener\">' . esc_html( $atts['label'] ) . '</a>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Inside a course or lesson the course_id can be left out. Change the button text with label=\"...\"."
        },
        {
            "id": "learndash-completion-date-shortcode",
            "version": 1,
            "title": "LearnDash: Course Completion Date Shortcode",
            "description": "Adds a [wpide_ld_completed_on] shortcode that shows the date the student completed a course, or a \"not completed yet\" message.",
            "category": "learning",
            "tags": [
                "learndash",
                "courses",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_completed_on', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0, 'format' => get_option( 'date_format' ), 'pending' => 'Not completed yet' ), $atts, 'wpide_ld_completed_on' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $user_id || ! $course_id ) {\n        return '';\n    }\n\n    $time = function_exists( 'learndash_user_get_course_completed_date' )\n        ? (int) learndash_user_get_course_completed_date( $user_id, $course_id )\n        : (int) get_user_meta( $user_id, 'course_completed_' . $course_id, true );\n\n    return esc_html( $time ? wp_date( $atts['format'], $time ) : $atts['pending'] );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Use course_id=\"123\" outside a course, and format=\"j F Y\" for a custom date format."
        },
        {
            "id": "learndash-access-expiry-notice",
            "version": 1,
            "title": "LearnDash: Show Students When Their Course Access Expires",
            "description": "Shows a notice at the top of course, lesson and topic pages telling enrolled students the date their access ends, for courses that use access expiration.",
            "category": "learning",
            "tags": [
                "learndash",
                "expiry",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( array( 'sfwd-courses', 'sfwd-lessons', 'sfwd-topic' ) ) || ! in_the_loop() || ! is_user_logged_in() ) {\n        return $content;\n    }\n\n    $course_id = (int) learndash_get_course_id( get_the_ID() );\n    $expires   = $course_id ? (int) ld_course_access_expires_on( $course_id, get_current_user_id() ) : 0;\n\n    if ( ! $expires || $expires < time() ) {\n        return $content;\n    }\n\n    $soon   = $expires - time() < 7 * DAY_IN_SECONDS;\n    $notice = sprintf(\n        '<p class=\"wpide-ld-expiry%1$s\" role=\"status\" style=\"padding:.6em 1em;border-left:4px solid %2$s;background:rgba(127,127,127,.08)\">%3$s</p>',\n        $soon ? ' is-soon' : '',\n        $soon ? '#d63638' : '#2271b1',\n        esc_html( sprintf( 'Your access to this course ends on %s.', wp_date( get_option( 'date_format' ), $expires ) ) )\n    );\n\n    return $notice . $content;\n}, 5 );",
            "requires": [
                "learndash"
            ],
            "notes": "Only shows for courses with \"Expire access\" turned on. The notice turns into a warning in the last 7 days."
        },
        {
            "id": "learndash-last-activity-users-column",
            "version": 1,
            "title": "LearnDash: Last Learning Activity Column in Users",
            "description": "Records when each student last completed a lesson, topic or quiz and shows it in a sortable \"Last learning activity\" column on the Users screen.",
            "category": "learning",
            "tags": [
                "learndash",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n$wpide_ld_touch = function ( $data ) {\n    $user_id = isset( $data['user']->ID ) ? (int) $data['user']->ID : get_current_user_id();\n\n    if ( $user_id ) {\n        update_user_meta( $user_id, 'wpide_ld_last_activity', time() );\n    }\n};\n\nadd_action( 'learndash_lesson_completed', $wpide_ld_touch );\nadd_action( 'learndash_topic_completed', $wpide_ld_touch );\nadd_action( 'learndash_quiz_completed', function ( $quizdata, $user ) {\n    if ( $user instanceof WP_User ) {\n        update_user_meta( $user->ID, 'wpide_ld_last_activity', time() );\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_ld_activity'] = 'Last learning activity';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_sortable_columns', function ( $columns ) {\n    $columns['wpide_ld_activity'] = 'wpide_ld_activity';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_ld_activity' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_ld_last_activity', true );\n\n    return $time ? esc_html( sprintf( '%s ago', human_time_diff( $time ) ) ) : '&mdash;';\n}, 10, 3 );\n\nadd_action( 'pre_get_users', function ( $query ) {\n    if ( is_admin() && 'wpide_ld_activity' === $query->get( 'orderby' ) ) {\n        $query->set( 'meta_key', 'wpide_ld_last_activity' );\n        $query->set( 'orderby', 'meta_value_num' );\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Activity is recorded from the moment the snippet is active; earlier activity is not back-filled."
        },
        {
            "id": "learndash-enrolled-courses-users-column",
            "version": 1,
            "title": "LearnDash: Enrolled Courses Column in Users",
            "description": "Adds a \"Courses\" column to the Users screen showing how many LearnDash courses each user is enrolled in and how many they have completed.",
            "category": "learning",
            "tags": [
                "learndash",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_ld_courses'] = 'Courses';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_ld_courses' !== $column ) {\n        return $output;\n    }\n\n    $courses = (array) learndash_user_get_enrolled_courses( $user_id );\n\n    if ( ! $courses ) {\n        return '&mdash;';\n    }\n\n    $done = 0;\n\n    foreach ( $courses as $course_id ) {\n        if ( learndash_course_completed( $user_id, $course_id ) ) {\n            $done++;\n        }\n    }\n\n    return esc_html( sprintf( '%1$d enrolled, %2$d completed', count( $courses ), $done ) );\n}, 10, 3 );",
            "requires": [
                "learndash"
            ],
            "notes": "Uses LearnDash's own enrollment data, which LearnDash caches per user."
        },
        {
            "id": "learndash-exclude-steps-from-search",
            "version": 1,
            "title": "LearnDash: Keep Lessons and Quizzes Out of Site Search",
            "description": "Removes LearnDash lessons, topics, quizzes and certificates from your site's search results so visitors only find courses, not locked lesson pages.",
            "category": "learning",
            "tags": [
                "learndash",
                "search",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Post types to hide from search.\n$wpide_ld_hidden_types = array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz', 'sfwd-certificates', 'sfwd-question' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_ld_hidden_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( ! $types || 'any' === $types ) {\n        $types = get_post_types( array( 'exclude_from_search' => false ) );\n    }\n\n    $query->set( 'post_type', array_values( array_diff( (array) $types, $wpide_ld_hidden_types ) ) );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Courses stay searchable. Add sfwd-courses to the list to hide those too."
        },
        {
            "id": "learndash-noindex-course-steps",
            "version": 1,
            "title": "LearnDash: Stop Search Engines Indexing Lessons",
            "description": "Tells search engines not to index LearnDash lessons, topics, quizzes and certificates, which are usually locked and only show a login message to crawlers.",
            "category": "learning",
            "tags": [
                "learndash",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular( array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz', 'sfwd-certificates' ) ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Links on those pages are still followed. Course pages stay indexable."
        },
        {
            "id": "learndash-progress-bar-brand-color",
            "version": 1,
            "title": "LearnDash: Brand Colour for Progress Bars and Buttons",
            "description": "Recolours LearnDash's progress bars, \"Mark Complete\" buttons and completed-step checkmarks to your brand colour with a few lines of CSS.",
            "category": "learning",
            "tags": [
                "learndash",
                "css",
                "branding"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":root {\n    --wpide-ld-brand: #6b4eff;\n}\n\n.learndash-wrapper .ld-progress .ld-progress-bar .ld-progress-bar-percentage {\n    background: var(--wpide-ld-brand);\n}\n\n.learndash-wrapper .ld-button,\n.learndash-wrapper .learndash_mark_complete_button,\n.learndash-wrapper #learndash_mark_complete_button {\n    background-color: var(--wpide-ld-brand);\n    border-color: var(--wpide-ld-brand);\n}\n\n.learndash-wrapper .ld-status-complete,\n.learndash-wrapper .ld-status-icon.ld-status-complete {\n    background: var(--wpide-ld-brand);\n}\n\n.learndash-wrapper .ld-button:focus-visible,\n.learndash-wrapper .learndash_mark_complete_button:focus-visible {\n    outline: 3px solid var(--wpide-ld-brand);\n    outline-offset: 2px;\n}",
            "requires": [
                "learndash"
            ],
            "notes": "Change --wpide-ld-brand to your colour. LearnDash's own colour settings in the Design panel take precedence when set."
        },
        {
            "id": "learndash-course-completed-body-class",
            "version": 1,
            "title": "LearnDash: Body Classes for Course Status",
            "description": "Adds body classes such as wpide-ld-enrolled and wpide-ld-completed on course, lesson and topic pages, so you can style or hide things with CSS depending on the student's status.",
            "category": "learning",
            "tags": [
                "learndash",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    if ( ! is_singular( array( 'sfwd-courses', 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz' ) ) ) {\n        return $classes;\n    }\n\n    $user_id   = get_current_user_id();\n    $course_id = (int) learndash_get_course_id( get_queried_object_id() );\n\n    if ( ! $user_id ) {\n        $classes[] = 'wpide-ld-guest';\n    } elseif ( ! $course_id || ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n        $classes[] = 'wpide-ld-not-enrolled';\n    } else {\n        $classes[] = 'wpide-ld-enrolled';\n\n        if ( learndash_course_completed( $user_id, $course_id ) ) {\n            $classes[] = 'wpide-ld-completed';\n        }\n    }\n\n    return $classes;\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Classes: wpide-ld-guest, wpide-ld-not-enrolled, wpide-ld-enrolled and wpide-ld-completed."
        },
        {
            "id": "learndash-resume-course-button",
            "version": 1,
            "title": "LearnDash: \"Resume Course\" Button Shortcode",
            "description": "Adds a [wpide_ld_resume course_id=\"123\"] shortcode that takes students straight to the first lesson or topic they have not finished yet.",
            "category": "learning",
            "tags": [
                "learndash",
                "courses",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_resume', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0, 'label' => 'Resume course' ), $atts, 'wpide_ld_resume' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $user_id || ! $course_id || ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n        return '';\n    }\n\n    $url = get_permalink( $course_id );\n\n    if ( function_exists( 'learndash_user_progress_get_first_incomplete_step' ) ) {\n        $step = (int) learndash_user_progress_get_first_incomplete_step( $user_id, $course_id );\n\n        if ( $step && get_post_status( $step ) ) {\n            $url = function_exists( 'learndash_get_step_permalink' ) ? learndash_get_step_permalink( $step, $course_id ) : get_permalink( $step );\n        }\n    }\n\n    return '<a class=\"wpide-ld-resume wp-element-button\" href=\"' . esc_url( $url ) . '\">' . esc_html( $atts['label'] ) . '</a>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Falls back to the course page when every step is done or the next step cannot be found."
        },
        {
            "id": "learndash-best-quiz-score-shortcode",
            "version": 1,
            "title": "LearnDash: Best Quiz Score Shortcode",
            "description": "Adds a [wpide_ld_best_score quiz_id=\"123\"] shortcode that shows the student's best percentage on a quiz and whether they passed.",
            "category": "learning",
            "tags": [
                "learndash",
                "quizzes",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_best_score', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'quiz_id' => 0 ), $atts, 'wpide_ld_best_score' );\n    $user_id = get_current_user_id();\n    $quiz_id = $atts['quiz_id'] ? absint( $atts['quiz_id'] ) : ( 'sfwd-quiz' === get_post_type() ? get_the_ID() : 0 );\n\n    if ( ! $user_id || ! $quiz_id ) {\n        return '';\n    }\n\n    $best   = null;\n    $passed = false;\n\n    foreach ( (array) get_user_meta( $user_id, '_sfwd-quizzes', true ) as $attempt ) {\n        if ( ! is_array( $attempt ) || (int) ( $attempt['quiz'] ?? 0 ) !== $quiz_id ) {\n            continue;\n        }\n\n        $best   = max( (float) $best, (float) ( $attempt['percentage'] ?? 0 ) );\n        $passed = $passed || ! empty( $attempt['pass'] );\n    }\n\n    if ( null === $best ) {\n        return '<span class=\"wpide-ld-score\">Not attempted yet</span>';\n    }\n\n    return '<span class=\"wpide-ld-score\">' . esc_html( sprintf( 'Best score: %s%% (%s)', round( $best ), $passed ? 'passed' : 'not passed yet' ) ) . '</span>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Inside a quiz the quiz_id can be left out. Students who have not taken the quiz see \"Not attempted yet\"."
        },
        {
            "id": "learndash-role-after-quiz-pass",
            "version": 1,
            "title": "LearnDash: Give a Role When a Quiz Is Passed",
            "description": "Adds a role to students when they pass a chosen LearnDash quiz, for example a \"Certified\" role that unlocks a members area.",
            "category": "learning",
            "tags": [
                "learndash",
                "quizzes",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Quiz ID => role to add.\n$wpide_ld_quiz_roles = array(\n    123 => 'contributor',\n);\n\nadd_action( 'learndash_quiz_completed', function ( $quizdata, $user ) use ( $wpide_ld_quiz_roles ) {\n    if ( ! $user instanceof WP_User || empty( $quizdata['pass'] ) ) {\n        return;\n    }\n\n    $quiz_id = is_object( $quizdata['quiz'] ?? null ) ? (int) $quizdata['quiz']->ID : (int) ( $quizdata['quiz'] ?? 0 );\n    $role    = $wpide_ld_quiz_roles[ $quiz_id ] ?? '';\n\n    if ( $role && get_role( $role ) && ! in_array( $role, (array) $user->roles, true ) ) {\n        $user->add_role( $role );\n    }\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "The role is added alongside the student's current role. Make sure the role exists (for example with a role editor plugin)."
        },
        {
            "id": "learndash-role-after-course-completion",
            "version": 1,
            "title": "LearnDash: Change Role After Course Completion",
            "description": "Adds a role (or replaces the current one) when a student completes a chosen course, such as promoting trainees to \"Instructor\".",
            "category": "learning",
            "tags": [
                "learndash",
                "roles",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Course ID => role.\n$wpide_ld_course_roles = array(\n    123 => 'author',\n);\n$wpide_ld_replace_role = false;\n\nadd_action( 'learndash_course_completed', function ( $data ) use ( $wpide_ld_course_roles, $wpide_ld_replace_role ) {\n    $user      = $data['user'] ?? null;\n    $course_id = isset( $data['course']->ID ) ? (int) $data['course']->ID : 0;\n    $role      = $wpide_ld_course_roles[ $course_id ] ?? '';\n\n    if ( ! $user instanceof WP_User || ! $role || ! get_role( $role ) || user_can( $user, 'manage_options' ) ) {\n        return;\n    }\n\n    if ( $wpide_ld_replace_role ) {\n        $user->set_role( $role );\n    } elseif ( ! in_array( $role, (array) $user->roles, true ) ) {\n        $user->add_role( $role );\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Set $wpide_ld_replace_role to true to swap the role instead of adding it. Administrators are never changed."
        },
        {
            "id": "learndash-close-comments-on-steps",
            "version": 1,
            "title": "LearnDash: Turn Off Comments on Lessons and Quizzes",
            "description": "Closes comments on LearnDash lessons, topics and quizzes, and hides existing ones, while leaving comments on courses and blog posts alone.",
            "category": "learning",
            "tags": [
                "learndash",
                "comments",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n$wpide_ld_no_comment_types = array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz' );\n\nadd_filter( 'comments_open', function ( $open, $post_id ) use ( $wpide_ld_no_comment_types ) {\n    return in_array( get_post_type( $post_id ), $wpide_ld_no_comment_types, true ) ? false : $open;\n}, 10, 2 );\n\nadd_filter( 'get_comments_number', function ( $count, $post_id ) use ( $wpide_ld_no_comment_types ) {\n    return in_array( get_post_type( $post_id ), $wpide_ld_no_comment_types, true ) ? 0 : $count;\n}, 10, 2 );",
            "requires": [
                "learndash"
            ],
            "notes": "Remove a post type from the list to keep its comments open."
        },
        {
            "id": "learndash-add-new-users-to-group",
            "version": 1,
            "title": "LearnDash: Add New Users to a Group",
            "description": "Puts every newly registered user into a LearnDash group automatically, giving them the group's courses and placing them under its group leaders.",
            "category": "learning",
            "tags": [
                "learndash",
                "groups",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// LearnDash group ID.\n$wpide_ld_new_user_group = 0;\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_ld_new_user_group ) {\n    if ( $wpide_ld_new_user_group && 'groups' === get_post_type( $wpide_ld_new_user_group ) ) {\n        ld_update_group_access( $user_id, $wpide_ld_new_user_group );\n    }\n}, 20 );",
            "requires": [
                "learndash"
            ],
            "notes": "Set the group ID at the top. Users created by an administrator are included too."
        },
        {
            "id": "learndash-redirect-unenrolled-to-course",
            "version": 1,
            "title": "LearnDash: Send Unenrolled Students to the Course Page",
            "description": "When a logged-in user opens a lesson or topic of a course they are not enrolled in, they are sent to the course page where they can enroll, instead of seeing a locked page.",
            "category": "learning",
            "tags": [
                "learndash",
                "redirect",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( ! is_user_logged_in() || ! is_singular( array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz' ) ) || current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    $course_id = (int) learndash_get_course_id( get_queried_object_id() );\n\n    if ( $course_id && ! sfwd_lms_has_access( $course_id, get_current_user_id() ) ) {\n        wp_safe_redirect( get_permalink( $course_id ) );\n        exit;\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Administrators and group leaders are never redirected."
        },
        {
            "id": "learndash-ungraded-essays-widget",
            "version": 1,
            "title": "LearnDash: Ungraded Essays Dashboard Widget",
            "description": "Adds a dashboard widget that shows how many essay submissions are waiting to be graded, with a link straight to the list.",
            "category": "learning",
            "tags": [
                "learndash",
                "dashboard",
                "quizzes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_ld_essays', 'Essays to grade', function () {\n        $count = get_transient( 'wpide_ld_ungraded' );\n\n        if ( false === $count ) {\n            $query = new WP_Query( array(\n                'post_type'      => 'sfwd-essays',\n                'post_status'    => 'not_graded',\n                'posts_per_page' => 1,\n                'fields'         => 'ids',\n            ) );\n            $count = (int) $query->found_posts;\n            set_transient( 'wpide_ld_ungraded', $count, 15 * MINUTE_IN_SECONDS );\n        }\n\n        if ( ! $count ) {\n            echo '<p>All caught up: no essays are waiting.</p>';\n            return;\n        }\n\n        printf(\n            '<p style=\"font-size:1.4em;margin:0 0 .5em\"><strong>%1$s</strong> waiting</p><p><a class=\"button\" href=\"%2$s\">Grade essays</a></p>',\n            esc_html( number_format_i18n( $count ) ),\n            esc_url( admin_url( 'edit.php?post_type=sfwd-essays&post_status=not_graded' ) )\n        );\n    } );\n} );\n\nadd_action( 'save_post_sfwd-essays', function () {\n    delete_transient( 'wpide_ld_ungraded' );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Visible to users who can edit others' posts. The count is cached for 15 minutes."
        },
        {
            "id": "learndash-recent-completions-widget",
            "version": 1,
            "title": "LearnDash: Recent Course Completions Dashboard Widget",
            "description": "Keeps a short log of the latest course completions and shows it in a dashboard widget with student names, courses and how long ago.",
            "category": "learning",
            "tags": [
                "learndash",
                "dashboard",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'learndash_course_completed', function ( $data ) {\n    if ( empty( $data['user'] ) || empty( $data['course'] ) ) {\n        return;\n    }\n\n    $log = (array) get_option( 'wpide_ld_completions', array() );\n    array_unshift( $log, array( 'user' => (int) $data['user']->ID, 'course' => (int) $data['course']->ID, 'time' => time() ) );\n    update_option( 'wpide_ld_completions', array_slice( $log, 0, 15 ), false );\n} );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'list_users' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_ld_completions', 'Recent course completions', function () {\n        $log = array_filter( (array) get_option( 'wpide_ld_completions', array() ), 'is_array' );\n\n        if ( ! $log ) {\n            echo '<p>No completions recorded yet.</p>';\n            return;\n        }\n\n        echo '<ul>';\n\n        foreach ( $log as $row ) {\n            $user = get_userdata( $row['user'] );\n\n            printf(\n                '<li><strong>%1$s</strong> completed <a href=\"%2$s\">%3$s</a> <span style=\"color:#646970\">%4$s ago</span></li>',\n                esc_html( $user ? $user->display_name : 'Deleted user' ),\n                esc_url( get_edit_post_link( $row['course'] ) ),\n                esc_html( get_the_title( $row['course'] ) ),\n                esc_html( human_time_diff( (int) $row['time'] ) )\n            );\n        }\n\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "The log starts when the snippet is activated and keeps the 15 most recent completions in one small option."
        },
        {
            "id": "learndash-notify-group-leaders",
            "version": 1,
            "title": "LearnDash: Email Group Leaders When Their Students Finish a Course",
            "description": "Notifies the leaders of each LearnDash group a student belongs to when that student completes a course, so managers can follow their team's training.",
            "category": "learning",
            "tags": [
                "learndash",
                "groups",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'learndash_course_completed', function ( $data ) {\n    if ( empty( $data['user'] ) || empty( $data['course'] ) || ! function_exists( 'learndash_get_users_group_ids' ) ) {\n        return;\n    }\n\n    $leaders = array();\n\n    foreach ( (array) learndash_get_users_group_ids( $data['user']->ID ) as $group_id ) {\n        $leaders = array_merge( $leaders, (array) learndash_get_groups_administrator_ids( $group_id ) );\n    }\n\n    $leaders = array_diff( array_unique( array_map( 'intval', $leaders ) ), array( (int) $data['user']->ID ) );\n\n    foreach ( $leaders as $leader_id ) {\n        $leader = get_userdata( $leader_id );\n\n        if ( ! $leader ) {\n            continue;\n        }\n\n        wp_mail(\n            $leader->user_email,\n            sprintf( '%s completed %s', $data['user']->display_name, get_the_title( $data['course'] ) ),\n            sprintf( \"Hi %s,\\n\\n%s from your group has completed \\\"%s\\\".\", $leader->display_name, $data['user']->display_name, get_the_title( $data['course'] ) )\n        );\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Leaders get one email per completion, even if they lead several of the student's groups."
        },
        {
            "id": "learndash-course-sequence-enrollment",
            "version": 1,
            "title": "LearnDash: Enroll in the Next Course After Completing One",
            "description": "Builds a learning path by enrolling students in the next course automatically as soon as they complete the previous one.",
            "category": "learning",
            "tags": [
                "learndash",
                "enrollment",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n// Completed course ID => next course IDs.\n$wpide_ld_next_courses = array(\n    101 => array( 102 ),\n    102 => array( 103 ),\n);\n\nadd_action( 'learndash_course_completed', function ( $data ) use ( $wpide_ld_next_courses ) {\n    $user_id   = isset( $data['user']->ID ) ? (int) $data['user']->ID : 0;\n    $course_id = isset( $data['course']->ID ) ? (int) $data['course']->ID : 0;\n\n    foreach ( $wpide_ld_next_courses[ $course_id ] ?? array() as $next ) {\n        if ( $user_id && 'sfwd-courses' === get_post_type( $next ) && ! sfwd_lms_has_access( $next, $user_id ) ) {\n            ld_update_course_access( $user_id, (int) $next );\n        }\n    }\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Map each completed course to the course(s) that should open next. Use LearnDash's prerequisites as well if the next course must stay locked otherwise."
        },
        {
            "id": "learndash-student-count-shortcode",
            "version": 1,
            "title": "LearnDash: Student Count Shortcode for Course Pages",
            "description": "Adds a [wpide_ld_students] shortcode that shows social proof such as \"1,240 students enrolled\" on a course page, cached so it stays fast.",
            "category": "learning",
            "tags": [
                "learndash",
                "shortcode",
                "social-proof"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_students', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0, 'min' => 0, 'text' => '%s students enrolled' ), $atts, 'wpide_ld_students' );\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $course_id ) {\n        return '';\n    }\n\n    $key   = 'wpide_ld_count_' . $course_id;\n    $count = get_transient( $key );\n\n    if ( false === $count ) {\n        $query = new WP_User_Query( array( 'fields' => 'ID', 'number' => 1, 'count_total' => true, 'meta_key' => 'course_' . $course_id . '_access_from', 'meta_compare' => 'EXISTS' ) );\n        $count = (int) $query->get_total();\n        set_transient( $key, $count, 12 * HOUR_IN_SECONDS );\n    }\n\n    if ( $count < (int) $atts['min'] ) {\n        return '';\n    }\n\n    return '<span class=\"wpide-ld-students\">' . esc_html( sprintf( $atts['text'], number_format_i18n( $count ) ) ) . '</span>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Use course_id=\"123\" outside a course and min=\"50\" to hide the count until it is worth showing. Counts direct enrollments and refresh every 12 hours."
        },
        {
            "id": "learndash-course-status-badge",
            "version": 1,
            "title": "LearnDash: Course Status Badge Shortcode",
            "description": "Adds a [wpide_ld_status] shortcode that shows a small \"Not started\", \"In progress\" or \"Completed\" badge for the current student and course.",
            "category": "learning",
            "tags": [
                "learndash",
                "shortcode",
                "progress"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_ld_status', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0 ), $atts, 'wpide_ld_status' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : (int) learndash_get_course_id( get_the_ID() );\n\n    if ( ! $user_id || ! $course_id || ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n        return '';\n    }\n\n    $progress = learndash_course_progress( array( 'user_id' => $user_id, 'course_id' => $course_id, 'array' => true ) );\n    $percent  = (int) ( $progress['percentage'] ?? 0 );\n\n    if ( learndash_course_completed( $user_id, $course_id ) || $percent >= 100 ) {\n        $state = array( 'is-completed', 'Completed' );\n    } elseif ( $percent > 0 ) {\n        $state = array( 'is-in-progress', 'In progress' );\n    } else {\n        $state = array( 'is-not-started', 'Not started' );\n    }\n\n    return '<span class=\"wpide-ld-status ' . esc_attr( $state[0] ) . '\" style=\"display:inline-block;padding:.15em .6em;border-radius:999px;border:1px solid currentColor;font-size:.85em\">' . esc_html( $state[1] ) . '</span>';\n} );",
            "requires": [
                "learndash"
            ],
            "notes": "Style it with the classes .wpide-ld-status.is-not-started, .is-in-progress and .is-completed."
        },
        {
            "id": "lifterlms-redirect-after-course-completion",
            "version": 1,
            "title": "LifterLMS: Custom Page After a Student Completes a Course",
            "description": "Sends students to a page you choose, such as a survey or next-steps page, the next time they load a page after completing a LifterLMS course.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "redirect",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n// Course ID => URL. \"default\" applies to all other courses.\n$wpide_llms_done_pages = array(\n    'default' => home_url( '/thank-you/' ),\n);\n\nadd_action( 'lifterlms_course_completed', function ( $user_id, $course_id ) use ( $wpide_llms_done_pages ) {\n    $url = $wpide_llms_done_pages[ (int) $course_id ] ?? ( $wpide_llms_done_pages['default'] ?? '' );\n\n    if ( $url ) {\n        update_user_meta( (int) $user_id, 'wpide_llms_done_redirect', esc_url_raw( $url ) );\n    }\n}, 10, 2 );\n\nadd_action( 'template_redirect', function () {\n    $user_id = get_current_user_id();\n    $url     = $user_id ? get_user_meta( $user_id, 'wpide_llms_done_redirect', true ) : '';\n\n    if ( $url ) {\n        delete_user_meta( $user_id, 'wpide_llms_done_redirect' );\n        wp_safe_redirect( $url );\n        exit;\n    }\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Map course IDs to URLs; the \"default\" entry covers every other course. Remove it to only redirect the courses you list."
        },
        {
            "id": "lifterlms-auto-enroll-new-users",
            "version": 1,
            "title": "LifterLMS: Enroll New Users in Starter Courses",
            "description": "Enrolls every newly registered user in the LifterLMS courses or memberships you choose, such as a free welcome course.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "enrollment",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_enroll_student' ) ) {\n    return;\n}\n\n// Course or membership IDs.\n$wpide_llms_starter_products = array( 123 );\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_llms_starter_products ) {\n    foreach ( $wpide_llms_starter_products as $product_id ) {\n        if ( in_array( get_post_type( $product_id ), array( 'course', 'llms_membership' ), true ) && ! llms_is_user_enrolled( $user_id, $product_id ) ) {\n            llms_enroll_student( $user_id, (int) $product_id, 'wpide_new_user' );\n        }\n    }\n}, 20 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Add course or membership IDs to $wpide_llms_starter_products. Existing users are not enrolled."
        },
        {
            "id": "lifterlms-course-progress-shortcode",
            "version": 1,
            "title": "LifterLMS: Course Progress Bar Shortcode",
            "description": "Adds a [wpide_llms_progress] shortcode that shows the logged-in student's progress through a course as an accessible bar with a percentage.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "progress",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_llms_progress', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0 ), $atts, 'wpide_llms_progress' );\n    $course_id = absint( $atts['course_id'] );\n\n    if ( ! $course_id ) {\n        $post_id   = get_the_ID();\n        $course_id = 'lesson' === get_post_type( $post_id ) ? (int) get_post_meta( $post_id, '_llms_parent_course', true ) : ( 'course' === get_post_type( $post_id ) ? $post_id : 0 );\n    }\n\n    $student = llms_get_student();\n\n    if ( ! $course_id || ! $student || ! $student->is_enrolled( $course_id ) ) {\n        return '';\n    }\n\n    $percent = (int) round( (float) $student->get_progress( $course_id, 'course' ) );\n\n    return sprintf(\n        '<div class=\"wpide-llms-progress\"><div role=\"progressbar\" aria-valuenow=\"%1$d\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"%2$s\" style=\"height:10px;border-radius:5px;background:rgba(127,127,127,.25);overflow:hidden\"><div style=\"width:%1$d%%;height:100%%;background:currentColor\"></div></div><p style=\"margin:.4em 0 0;font-size:.9em\">%3$s</p></div>',\n        $percent,\n        esc_attr( get_the_title( $course_id ) . ' progress' ),\n        esc_html( sprintf( '%d%% complete', $percent ) )\n    );\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "On a course or lesson page it uses that course; elsewhere add course_id=\"123\"."
        },
        {
            "id": "lifterlms-email-admin-course-completed",
            "version": 1,
            "title": "LifterLMS: Course Completion Alerts for the Site Admin",
            "description": "Emails you whenever a student completes a LifterLMS course, with their name, email and a link to their reporting page.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n$wpide_llms_notify_emails = array( get_option( 'admin_email' ) );\n\nadd_action( 'lifterlms_course_completed', function ( $user_id, $course_id ) use ( $wpide_llms_notify_emails ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        $wpide_llms_notify_emails,\n        sprintf( 'Course completed: %s', get_the_title( $course_id ) ),\n        sprintf(\n            \"%s (%s) completed \\\"%s\\\".\\n\\nStudent report: %s\",\n            $user->display_name,\n            $user->user_email,\n            get_the_title( $course_id ),\n            admin_url( 'admin.php?page=llms-reporting&tab=students&student_id=' . (int) $user_id )\n        )\n    );\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Add more recipients to $wpide_llms_notify_emails."
        },
        {
            "id": "lifterlms-welcome-email-on-enrollment",
            "version": 1,
            "title": "LifterLMS: Personal Welcome Email on Enrollment",
            "description": "Sends students a short, personal welcome email when they are enrolled in a LifterLMS course, with a direct link to start learning.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "email",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n$wpide_llms_welcome_courses = array();\n\nadd_action( 'llms_user_enrolled_in_course', function ( $user_id, $course_id ) use ( $wpide_llms_welcome_courses ) {\n    if ( $wpide_llms_welcome_courses && ! in_array( (int) $course_id, $wpide_llms_welcome_courses, true ) ) {\n        return;\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        $user->user_email,\n        sprintf( 'Welcome to %s', get_the_title( $course_id ) ),\n        sprintf(\n            \"Hi %s,\\n\\nYou are now enrolled in \\\"%s\\\". Start whenever you are ready:\\n%s\\n\\nHappy learning!\\n%s\",\n            $user->first_name ? $user->first_name : $user->display_name,\n            get_the_title( $course_id ),\n            get_permalink( $course_id ),\n            get_bloginfo( 'name' )\n        )\n    );\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Limit it to some courses with $wpide_llms_welcome_courses; leave it empty for all courses."
        },
        {
            "id": "lifterlms-quiz-failed-email",
            "version": 1,
            "title": "LifterLMS: Encouraging Email When a Quiz Is Failed",
            "description": "Emails students who fail a LifterLMS quiz with their grade and a link to review the lesson and try again.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "quizzes",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'lifterlms_quiz_failed', function ( $user_id, $quiz_id, $attempt ) {\n    $user = get_userdata( $user_id );\n    $key  = 'wpide_llms_fail_' . (int) $user_id . '_' . (int) $quiz_id;\n\n    if ( ! $user || get_transient( $key ) ) {\n        return;\n    }\n\n    set_transient( $key, 1, DAY_IN_SECONDS );\n\n    $grade  = is_object( $attempt ) && method_exists( $attempt, 'get' ) ? round( (float) $attempt->get( 'grade' ) ) : null;\n    $lesson = (int) get_post_meta( $quiz_id, '_llms_lesson_id', true );\n\n    wp_mail(\n        $user->user_email,\n        sprintf( 'Keep going: %s', get_the_title( $quiz_id ) ),\n        sprintf(\n            \"Hi %s,\\n\\n%s Review the lesson and give it another go:\\n%s\",\n            $user->first_name ? $user->first_name : $user->display_name,\n            null === $grade ? 'You did not pass the quiz this time.' : sprintf( 'You scored %d%% on \"%s\", just short of a pass.', $grade, get_the_title( $quiz_id ) ),\n            get_permalink( $lesson ? $lesson : $quiz_id )\n        )\n    );\n}, 10, 3 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Sent at most once a day per student and quiz."
        },
        {
            "id": "lifterlms-remove-dashboard-tabs",
            "version": 1,
            "title": "LifterLMS: Remove Student Dashboard Tabs You Don't Use",
            "description": "Hides tabs such as Redeem a Voucher, Notifications or Achievements from the LifterLMS student dashboard to keep it simple.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "dashboard",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n// Tabs to remove.\n$wpide_llms_hidden_tabs = array( 'redeem-voucher', 'notifications' );\n\nadd_filter( 'llms_get_student_dashboard_tabs', function ( $tabs ) use ( $wpide_llms_hidden_tabs ) {\n    foreach ( $wpide_llms_hidden_tabs as $tab ) {\n        unset( $tabs[ $tab ] );\n    }\n\n    return $tabs;\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Tab IDs include dashboard, view-courses, view-memberships, view-achievements, view-certificates, notifications, edit-account, redeem-voucher and orders."
        },
        {
            "id": "lifterlms-login-redirect-to-dashboard",
            "version": 1,
            "title": "LifterLMS: Send Students to Their Dashboard After Login",
            "description": "Sends students who log in through wp-login.php to the LifterLMS student dashboard instead of the WordPress admin profile.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_page_url' ) ) {\n    return;\n}\n\nadd_filter( 'login_redirect', function ( $redirect, $requested, $user ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $redirect;\n    }\n\n    if ( $requested && false === strpos( $requested, admin_url() ) ) {\n        return $redirect;\n    }\n\n    $dashboard = llms_get_page_url( 'myaccount' );\n\n    return $dashboard ? $dashboard : $redirect;\n}, 10, 3 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Staff who can edit posts still go to wp-admin, and an explicit redirect_to link is respected."
        },
        {
            "id": "lifterlms-enrollments-users-column",
            "version": 1,
            "title": "LifterLMS: Courses Column in the Users List",
            "description": "Adds a \"Courses\" column to the Users screen showing how many LifterLMS courses each user is enrolled in.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_llms_courses'] = 'Courses';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_llms_courses' !== $column ) {\n        return $output;\n    }\n\n    $student = llms_get_student( $user_id );\n    $courses = $student ? $student->get_courses( array( 'limit' => 1 ) ) : array();\n    $found   = (int) ( $courses['found'] ?? 0 );\n\n    if ( ! $found ) {\n        return '&mdash;';\n    }\n\n    return sprintf(\n        '<a href=\"%1$s\">%2$s</a>',\n        esc_url( admin_url( 'admin.php?page=llms-reporting&tab=students&student_id=' . (int) $user_id ) ),\n        esc_html( sprintf( _n( '%d course', '%d courses', $found ), $found ) )\n    );\n}, 10, 3 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Links to the student's LifterLMS reporting page for details."
        },
        {
            "id": "lifterlms-students-column-on-courses",
            "version": 1,
            "title": "LifterLMS: Students Column on the Courses Screen",
            "description": "Adds a \"Students\" column to the LifterLMS Courses list showing how many students are currently enrolled, cached for speed.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "admin",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_enrolled_students' ) ) {\n    return;\n}\n\nadd_filter( 'manage_course_posts_columns', function ( $columns ) {\n    $columns['wpide_llms_students'] = 'Students';\n\n    return $columns;\n} );\n\nadd_action( 'manage_course_posts_custom_column', function ( $column, $course_id ) {\n    if ( 'wpide_llms_students' !== $column ) {\n        return;\n    }\n\n    $key   = 'wpide_llms_students_' . $course_id;\n    $count = get_transient( $key );\n\n    if ( false === $count ) {\n        $count = count( (array) llms_get_enrolled_students( $course_id, 'enrolled', 10000, 0 ) );\n        set_transient( $key, $count, 6 * HOUR_IN_SECONDS );\n    }\n\n    echo esc_html( number_format_i18n( (int) $count ) );\n}, 10, 2 );\n\n$wpide_llms_flush_students = function ( $user_id, $course_id ) {\n    delete_transient( 'wpide_llms_students_' . (int) $course_id );\n};\nadd_action( 'llms_user_enrolled_in_course', $wpide_llms_flush_students, 10, 2 );\nadd_action( 'llms_user_removed_from_course', $wpide_llms_flush_students, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Counts refresh every 6 hours and whenever someone enrolls or is removed."
        },
        {
            "id": "lifterlms-noindex-lessons",
            "version": 1,
            "title": "LifterLMS: Stop Search Engines Indexing Lessons and Quizzes",
            "description": "Adds a noindex instruction to LifterLMS lessons, quizzes and certificates, which are usually locked and only show crawlers a restriction message.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular( array( 'lesson', 'llms_quiz', 'llms_my_certificate', 'llms_certificate' ) ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Course and membership pages stay indexable."
        },
        {
            "id": "lifterlms-exclude-lessons-from-search",
            "version": 1,
            "title": "LifterLMS: Remove Lessons and Quizzes From Search Results",
            "description": "Removes LifterLMS lessons and quizzes from your site's search results so visitors find courses instead of locked lesson pages.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "search",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( ! $types || 'any' === $types ) {\n        $types = get_post_types( array( 'exclude_from_search' => false ) );\n    }\n\n    $query->set( 'post_type', array_values( array_diff( (array) $types, array( 'lesson', 'llms_quiz' ) ) ) );\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Courses and memberships stay searchable."
        },
        {
            "id": "lifterlms-membership-role-sync",
            "version": 1,
            "title": "LifterLMS: Add a Role While Users Are in a Membership",
            "description": "Gives users an extra role when they join a LifterLMS membership and removes it again when they leave, so other plugins can use roles for access.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "memberships",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n// Membership ID => role.\n$wpide_llms_membership_roles = array(\n    123 => 'contributor',\n);\n\nadd_action( 'llms_user_added_to_membership_level', function ( $user_id, $membership_id ) use ( $wpide_llms_membership_roles ) {\n    $role = $wpide_llms_membership_roles[ (int) $membership_id ] ?? '';\n    $user = get_userdata( $user_id );\n\n    if ( $role && $user && get_role( $role ) ) {\n        $user->add_role( $role );\n    }\n}, 10, 2 );\n\n$wpide_llms_membership_left = function ( $user_id, $membership_id ) use ( $wpide_llms_membership_roles ) {\n    $role = $wpide_llms_membership_roles[ (int) $membership_id ] ?? '';\n    $user = get_userdata( $user_id );\n\n    if ( $role && $user && count( $user->roles ) > 1 ) {\n        $user->remove_role( $role );\n    }\n};\nadd_action( 'llms_user_removed_from_membership', $wpide_llms_membership_left, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Map membership IDs to roles that already exist. The user's main role is never touched."
        },
        {
            "id": "lifterlms-course-status-body-class",
            "version": 1,
            "title": "LifterLMS: Body Classes for Enrollment Status",
            "description": "Adds body classes such as wpide-llms-enrolled or wpide-llms-completed on course and lesson pages so you can tailor the design for each student.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    if ( ! is_singular( array( 'course', 'lesson' ) ) ) {\n        return $classes;\n    }\n\n    $post_id   = get_queried_object_id();\n    $course_id = 'lesson' === get_post_type( $post_id ) ? (int) get_post_meta( $post_id, '_llms_parent_course', true ) : $post_id;\n    $student   = llms_get_student();\n\n    if ( ! $student ) {\n        $classes[] = 'wpide-llms-guest';\n    } elseif ( ! $course_id || ! $student->is_enrolled( $course_id ) ) {\n        $classes[] = 'wpide-llms-not-enrolled';\n    } else {\n        $classes[] = 'wpide-llms-enrolled';\n\n        if ( $student->is_complete( $course_id, 'course' ) ) {\n            $classes[] = 'wpide-llms-completed';\n        }\n    }\n\n    return $classes;\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Classes: wpide-llms-guest, wpide-llms-not-enrolled, wpide-llms-enrolled, wpide-llms-completed."
        },
        {
            "id": "lifterlms-login-required-for-lessons",
            "version": 1,
            "title": "LifterLMS: Send Visitors to Login From Lessons",
            "description": "Redirects logged-out visitors who open a lesson or quiz to the login screen, then back to that lesson once they log in.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( is_user_logged_in() || ! is_singular( array( 'lesson', 'llms_quiz' ) ) ) {\n        return;\n    }\n\n    if ( 'yes' === get_post_meta( get_queried_object_id(), '_llms_free_lesson', true ) ) {\n        return;\n    }\n\n    wp_safe_redirect( wp_login_url( get_permalink() ) );\n    exit;\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Free lessons marked as \"free lesson\" in LifterLMS stay open to everyone."
        },
        {
            "id": "lifterlms-close-comments-on-lessons",
            "version": 1,
            "title": "LifterLMS: Turn Off Comments on Lessons and Quizzes",
            "description": "Closes and hides comments on LifterLMS lessons and quizzes without affecting comments on your blog posts.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "comments",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n$wpide_llms_no_comment_types = array( 'lesson', 'llms_quiz' );\n\nadd_filter( 'comments_open', function ( $open, $post_id ) use ( $wpide_llms_no_comment_types ) {\n    return in_array( get_post_type( $post_id ), $wpide_llms_no_comment_types, true ) ? false : $open;\n}, 10, 2 );\n\nadd_filter( 'get_comments_number', function ( $count, $post_id ) use ( $wpide_llms_no_comment_types ) {\n    return in_array( get_post_type( $post_id ), $wpide_llms_no_comment_types, true ) ? 0 : $count;\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Remove lesson from the list if you use lesson comments for discussions."
        },
        {
            "id": "lifterlms-role-after-course-completion",
            "version": 1,
            "title": "LifterLMS: Add a Role After Course Completion",
            "description": "Adds a role to students when they complete a chosen LifterLMS course, for example to unlock a graduates-only area.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "roles",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\n// Course ID => role to add.\n$wpide_llms_course_roles = array(\n    123 => 'author',\n);\n\nadd_action( 'lifterlms_course_completed', function ( $user_id, $course_id ) use ( $wpide_llms_course_roles ) {\n    $role = $wpide_llms_course_roles[ (int) $course_id ] ?? '';\n    $user = get_userdata( $user_id );\n\n    if ( $role && $user && get_role( $role ) && ! in_array( $role, (array) $user->roles, true ) ) {\n        $user->add_role( $role );\n    }\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "The role is added next to the student's current role and must already exist."
        },
        {
            "id": "lifterlms-course-sequence-enrollment",
            "version": 1,
            "title": "LifterLMS: Learning Path With Automatic Next-Course Enrollment",
            "description": "Creates a learning path by enrolling students in the next LifterLMS course automatically when they finish the previous one.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "enrollment",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_enroll_student' ) ) {\n    return;\n}\n\n// Completed course ID => next course IDs.\n$wpide_llms_next_courses = array(\n    101 => array( 102 ),\n);\n\nadd_action( 'lifterlms_course_completed', function ( $user_id, $course_id ) use ( $wpide_llms_next_courses ) {\n    foreach ( $wpide_llms_next_courses[ (int) $course_id ] ?? array() as $next ) {\n        if ( 'course' === get_post_type( $next ) && ! llms_is_user_enrolled( $user_id, $next ) ) {\n            llms_enroll_student( (int) $user_id, (int) $next, 'wpide_sequence' );\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Map each completed course to the course(s) that should follow."
        },
        {
            "id": "lifterlms-enrolled-content-shortcode",
            "version": 1,
            "title": "LifterLMS: Content Only for Enrolled Students",
            "description": "Adds a [wpide_llms_enrolled id=\"123\"] shortcode that shows its content only to students enrolled in a course or membership, with an optional message for everyone else.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "shortcode",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_is_user_enrolled' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_llms_enrolled', function ( $atts, $content = '' ) {\n    $atts    = shortcode_atts( array( 'id' => 0, 'message' => '' ), $atts, 'wpide_llms_enrolled' );\n    $id      = absint( $atts['id'] );\n    $user_id = get_current_user_id();\n\n    if ( $id && $user_id && ( llms_is_user_enrolled( $user_id, $id ) || current_user_can( 'edit_post', $id ) ) ) {\n        return do_shortcode( $content );\n    }\n\n    return $atts['message'] ? '<p class=\"wpide-llms-locked\">' . esc_html( $atts['message'] ) . '</p>' : '';\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Example: [wpide_llms_enrolled id=\"123\" message=\"Enroll to download the workbook.\"]Download link[/wpide_llms_enrolled]."
        },
        {
            "id": "lifterlms-recent-completions-widget",
            "version": 1,
            "title": "LifterLMS: Dashboard Widget Listing the Latest Graduates",
            "description": "Keeps a short log of the latest LifterLMS course completions and shows it as a dashboard widget for admins and instructors.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "dashboard",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'lifterlms_course_completed', function ( $user_id, $course_id ) {\n    $log = (array) get_option( 'wpide_llms_completions', array() );\n    array_unshift( $log, array( 'user' => (int) $user_id, 'course' => (int) $course_id, 'time' => time() ) );\n    update_option( 'wpide_llms_completions', array_slice( $log, 0, 15 ), false );\n}, 10, 2 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_llms_completions', 'Recent course completions', function () {\n        $log = array_filter( (array) get_option( 'wpide_llms_completions', array() ), 'is_array' );\n\n        if ( ! $log ) {\n            echo '<p>No completions recorded yet.</p>';\n            return;\n        }\n\n        echo '<ul>';\n\n        foreach ( $log as $row ) {\n            $user = get_userdata( $row['user'] );\n            printf(\n                '<li><strong>%1$s</strong> completed %2$s <span style=\"color:#646970\">%3$s ago</span></li>',\n                esc_html( $user ? $user->display_name : 'Deleted user' ),\n                esc_html( get_the_title( $row['course'] ) ),\n                esc_html( human_time_diff( (int) $row['time'] ) )\n            );\n        }\n\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "lifterlms"
            ],
            "notes": "The log starts when you activate the snippet and holds the last 15 completions."
        },
        {
            "id": "lifterlms-last-activity-users-column",
            "version": 1,
            "title": "LifterLMS: Last Lesson Completed Column in Users",
            "description": "Records when each student last completed a LifterLMS lesson and shows it in a \"Last lesson\" column on the Users screen, to spot students who have stalled.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'lifterlms_lesson_completed', function ( $user_id ) {\n    update_user_meta( (int) $user_id, 'wpide_llms_last_lesson', time() );\n} );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_llms_last'] = 'Last lesson';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_llms_last' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_llms_last_lesson', true );\n\n    return $time ? esc_html( sprintf( '%s ago', human_time_diff( $time ) ) ) : '&mdash;';\n}, 10, 3 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Activity is tracked from activation onwards."
        },
        {
            "id": "tutor-redirect-after-course-completion",
            "version": 1,
            "title": "Tutor LMS: Redirect Students After They Complete a Course",
            "description": "Sends students to a page you choose, such as a review request or next course, right after they click \"Complete Course\" in Tutor LMS.",
            "category": "learning",
            "tags": [
                "tutor",
                "redirect",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n// Course ID => URL. \"default\" applies to all other courses.\n$wpide_tutor_done_pages = array(\n    'default' => home_url( '/thank-you/' ),\n);\n\nadd_action( 'tutor_course_complete_after', function ( $course_id, $user_id = 0 ) use ( $wpide_tutor_done_pages ) {\n    $user_id = $user_id ? (int) $user_id : get_current_user_id();\n    $url     = $wpide_tutor_done_pages[ (int) $course_id ] ?? ( $wpide_tutor_done_pages['default'] ?? '' );\n\n    if ( $user_id && $url ) {\n        update_user_meta( $user_id, 'wpide_tutor_done_redirect', esc_url_raw( $url ) );\n    }\n}, 10, 2 );\n\nadd_action( 'template_redirect', function () {\n    $user_id = get_current_user_id();\n    $url     = $user_id ? get_user_meta( $user_id, 'wpide_tutor_done_redirect', true ) : '';\n\n    if ( $url ) {\n        delete_user_meta( $user_id, 'wpide_tutor_done_redirect' );\n        wp_safe_redirect( $url );\n        exit;\n    }\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Map course IDs to URLs; \"default\" applies to all other courses."
        },
        {
            "id": "tutor-auto-enroll-new-users",
            "version": 1,
            "title": "Tutor LMS: Enroll New Users in Free Courses",
            "description": "Enrolls every newly registered user in the Tutor LMS courses you choose, such as a free getting-started course.",
            "category": "learning",
            "tags": [
                "tutor",
                "enrollment",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n// Course IDs.\n$wpide_tutor_starter_courses = array( 123 );\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_tutor_starter_courses ) {\n    foreach ( $wpide_tutor_starter_courses as $course_id ) {\n        if ( get_post_type( $course_id ) === tutor()->course_post_type && ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) {\n            tutor_utils()->do_enroll( (int) $course_id, 0, (int) $user_id );\n        }\n    }\n}, 20 );",
            "requires": [
                "tutor"
            ],
            "notes": "Use free courses here; paid courses should be enrolled through their checkout."
        },
        {
            "id": "tutor-course-progress-shortcode",
            "version": 1,
            "title": "Tutor LMS: Course Progress Bar Shortcode",
            "description": "Adds a [wpide_tutor_progress] shortcode that shows the logged-in student's completion percentage for a Tutor LMS course as an accessible progress bar.",
            "category": "learning",
            "tags": [
                "tutor",
                "progress",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_tutor_progress', function ( $atts ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0 ), $atts, 'wpide_tutor_progress' );\n    $user_id   = get_current_user_id();\n    $course_id = $atts['course_id'] ? absint( $atts['course_id'] ) : ( get_post_type() === tutor()->course_post_type ? get_the_ID() : 0 );\n\n    if ( ! $user_id || ! $course_id || ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) {\n        return '';\n    }\n\n    $percent = (int) tutor_utils()->get_course_completed_percent( $course_id, $user_id );\n\n    return sprintf(\n        '<div class=\"wpide-tutor-progress\"><div role=\"progressbar\" aria-valuenow=\"%1$d\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"%2$s\" style=\"height:10px;border-radius:5px;background:rgba(127,127,127,.25);overflow:hidden\"><div style=\"width:%1$d%%;height:100%%;background:currentColor\"></div></div><p style=\"margin:.4em 0 0;font-size:.9em\">%3$s</p></div>',\n        $percent,\n        esc_attr( get_the_title( $course_id ) . ' progress' ),\n        esc_html( sprintf( '%d%% complete', $percent ) )\n    );\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "On a course page it uses that course; elsewhere add course_id=\"123\"."
        },
        {
            "id": "tutor-email-admin-course-completed",
            "version": 1,
            "title": "Tutor LMS: Email the Admin When a Course Is Completed",
            "description": "Emails you whenever a student completes a Tutor LMS course, with the student's name, email and the course.",
            "category": "learning",
            "tags": [
                "tutor",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n$wpide_tutor_notify_emails = array( get_option( 'admin_email' ) );\n// Also email the course author (the main instructor).\n$wpide_tutor_notify_author = true;\n\nadd_action( 'tutor_course_complete_after', function ( $course_id, $user_id = 0 ) use ( $wpide_tutor_notify_emails, $wpide_tutor_notify_author ) {\n    $user = get_userdata( $user_id ? (int) $user_id : get_current_user_id() );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $to = $wpide_tutor_notify_emails;\n\n    if ( $wpide_tutor_notify_author ) {\n        $author = get_userdata( (int) get_post_field( 'post_author', $course_id ) );\n        if ( $author ) {\n            $to[] = $author->user_email;\n        }\n    }\n\n    wp_mail(\n        array_unique( $to ),\n        sprintf( 'Course completed: %s', get_the_title( $course_id ) ),\n        sprintf( \"%s (%s) completed \\\"%s\\\" on %s.\", $user->display_name, $user->user_email, get_the_title( $course_id ), wp_date( get_option( 'date_format' ) ) )\n    );\n}, 10, 2 );",
            "requires": [
                "tutor"
            ],
            "notes": "Add more addresses to $wpide_tutor_notify_emails; the course instructor can be added with the option below."
        },
        {
            "id": "tutor-welcome-email-on-enrollment",
            "version": 1,
            "title": "Tutor LMS: Send a Start-Learning Email to New Students",
            "description": "Sends students a friendly welcome email with a direct \"start learning\" link as soon as they enroll in a Tutor LMS course.",
            "category": "learning",
            "tags": [
                "tutor",
                "email",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n$wpide_tutor_welcome_courses = array();\n\nadd_action( 'tutor_after_enrolled', function ( $course_id, $user_id ) use ( $wpide_tutor_welcome_courses ) {\n    if ( $wpide_tutor_welcome_courses && ! in_array( (int) $course_id, $wpide_tutor_welcome_courses, true ) ) {\n        return;\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        $user->user_email,\n        sprintf( 'Welcome to %s', get_the_title( $course_id ) ),\n        sprintf(\n            \"Hi %s,\\n\\nYou are enrolled in \\\"%s\\\". Jump in whenever you are ready:\\n%s\\n\\nYour dashboard: %s\",\n            $user->first_name ? $user->first_name : $user->display_name,\n            get_the_title( $course_id ),\n            get_permalink( $course_id ),\n            tutor_utils()->tutor_dashboard_url()\n        )\n    );\n}, 10, 2 );",
            "requires": [
                "tutor"
            ],
            "notes": "Limit it to some courses with $wpide_tutor_welcome_courses; leave empty for all."
        },
        {
            "id": "tutor-remove-dashboard-menu-items",
            "version": 1,
            "title": "Tutor LMS: Remove Student Dashboard Menu Items",
            "description": "Hides student dashboard menu items you don't use, such as Wishlist, Reviews or Q&A, to keep the Tutor LMS dashboard focused.",
            "category": "learning",
            "tags": [
                "tutor",
                "dashboard",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n// Dashboard items to remove.\n$wpide_tutor_hidden_items = array( 'wishlist', 'reviews' );\n\n$wpide_tutor_hide_items = function ( $items ) use ( $wpide_tutor_hidden_items ) {\n    foreach ( $wpide_tutor_hidden_items as $key ) {\n        unset( $items[ $key ] );\n    }\n\n    return $items;\n};\n\nadd_filter( 'tutor_dashboard/nav_items', $wpide_tutor_hide_items );\nadd_filter( 'tutor_dashboard/nav_ui_items', $wpide_tutor_hide_items );",
            "requires": [
                "tutor"
            ],
            "notes": "Item keys depend on your Tutor version; in Tutor 2 and 3 they include wishlist, reviews, question-answer, my-quiz-attempts and purchase_history."
        },
        {
            "id": "tutor-students-column-on-courses",
            "version": 1,
            "title": "Tutor LMS: Enrolled Student Count in the Courses Admin List",
            "description": "Adds a \"Students\" column to the Tutor LMS courses list in wp-admin showing how many students are enrolled in each course.",
            "category": "learning",
            "tags": [
                "tutor",
                "admin",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_filter( 'manage_courses_posts_columns', function ( $columns ) {\n    $columns['wpide_tutor_students'] = 'Students';\n\n    return $columns;\n} );\n\nadd_action( 'manage_courses_posts_custom_column', function ( $column, $course_id ) {\n    if ( 'wpide_tutor_students' !== $column ) {\n        return;\n    }\n\n    $key   = 'wpide_tutor_students_' . $course_id;\n    $count = get_transient( $key );\n\n    if ( false === $count ) {\n        $count = (int) tutor_utils()->count_enrolled_users_by_course( $course_id );\n        set_transient( $key, $count, 6 * HOUR_IN_SECONDS );\n    }\n\n    echo esc_html( number_format_i18n( (int) $count ) );\n}, 10, 2 );\n\nadd_action( 'tutor_after_enrolled', function ( $course_id ) {\n    delete_transient( 'wpide_tutor_students_' . (int) $course_id );\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Counts are cached for 6 hours and refreshed when someone enrolls."
        },
        {
            "id": "tutor-enrolled-courses-users-column",
            "version": 1,
            "title": "Tutor LMS: Enrolled Courses Column in Users",
            "description": "Adds a \"Courses\" column to the Users screen showing how many Tutor LMS courses each user is enrolled in.",
            "category": "learning",
            "tags": [
                "tutor",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_tutor_courses'] = 'Courses';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_tutor_courses' !== $column ) {\n        return $output;\n    }\n\n    $ids = (array) tutor_utils()->get_enrolled_courses_ids_by_user( $user_id );\n\n    return $ids ? esc_html( number_format_i18n( count( $ids ) ) ) : '&mdash;';\n}, 10, 3 );",
            "requires": [
                "tutor"
            ],
            "notes": "Uses Tutor's own enrollment records."
        },
        {
            "id": "tutor-noindex-lessons",
            "version": 1,
            "title": "Tutor LMS: Noindex for Lessons, Quizzes and Assignments",
            "description": "Adds a noindex instruction to Tutor LMS lessons, quizzes and assignments, which are normally locked and only show crawlers a login wall.",
            "category": "learning",
            "tags": [
                "tutor",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor' ) ) {\n    return;\n}\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular( array( tutor()->lesson_post_type, 'tutor_quiz', 'tutor_assignments' ) ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Course pages stay indexable."
        },
        {
            "id": "tutor-exclude-lessons-from-search",
            "version": 1,
            "title": "Tutor LMS: Keep Lessons Out of Site Search",
            "description": "Removes Tutor LMS lessons, quizzes and assignments from your site search so visitors land on course pages instead.",
            "category": "learning",
            "tags": [
                "tutor",
                "search",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor' ) ) {\n    return;\n}\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( ! $types || 'any' === $types ) {\n        $types = get_post_types( array( 'exclude_from_search' => false ) );\n    }\n\n    $query->set( 'post_type', array_values( array_diff( (array) $types, array( tutor()->lesson_post_type, 'tutor_quiz', 'tutor_assignments' ) ) ) );\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Courses stay searchable."
        },
        {
            "id": "tutor-role-after-course-completion",
            "version": 1,
            "title": "Tutor LMS: Grant a Role to Course Graduates",
            "description": "Adds a role to students when they complete a chosen Tutor LMS course, for example to open a graduates-only forum or downloads area.",
            "category": "learning",
            "tags": [
                "tutor",
                "roles",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n// Course ID => role.\n$wpide_tutor_course_roles = array(\n    123 => 'author',\n);\n\nadd_action( 'tutor_course_complete_after', function ( $course_id, $user_id = 0 ) use ( $wpide_tutor_course_roles ) {\n    $role = $wpide_tutor_course_roles[ (int) $course_id ] ?? '';\n    $user = get_userdata( $user_id ? (int) $user_id : get_current_user_id() );\n\n    if ( $role && $user && get_role( $role ) && ! in_array( $role, (array) $user->roles, true ) ) {\n        $user->add_role( $role );\n    }\n}, 10, 2 );",
            "requires": [
                "tutor"
            ],
            "notes": "The role must already exist and is added next to the student's current role."
        },
        {
            "id": "tutor-quiz-attempt-email-instructor",
            "version": 1,
            "title": "Tutor LMS: Email the Instructor When a Quiz Is Submitted",
            "description": "Emails the course author each time a student finishes a Tutor LMS quiz, with the score, so instructors can follow up or grade open questions quickly.",
            "category": "learning",
            "tags": [
                "tutor",
                "quizzes",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_action( 'tutor_quiz/attempt_ended', function ( $attempt_id, $course_id = 0, $user_id = 0 ) {\n    $attempt = tutor_utils()->get_attempt( $attempt_id );\n\n    if ( ! $attempt ) {\n        return;\n    }\n\n    $course_id = $course_id ? (int) $course_id : (int) ( $attempt->course_id ?? 0 );\n    $student   = get_userdata( $user_id ? (int) $user_id : (int) ( $attempt->user_id ?? 0 ) );\n    $author    = get_userdata( (int) get_post_field( 'post_author', $course_id ) );\n\n    if ( ! $student || ! $author ) {\n        return;\n    }\n\n    $total = (float) ( $attempt->total_marks ?? 0 );\n    $score = $total > 0 ? round( (float) $attempt->earned_marks / $total * 100 ) : 0;\n\n    wp_mail(\n        $author->user_email,\n        sprintf( 'Quiz submitted: %s', get_the_title( (int) $attempt->quiz_id ) ),\n        sprintf( \"%s finished \\\"%s\\\" in \\\"%s\\\" with a score of %d%%.\\n\\nReview attempts: %s\", $student->display_name, get_the_title( (int) $attempt->quiz_id ), get_the_title( $course_id ), $score, admin_url( 'admin.php?page=tutor_quiz_attempts' ) )\n    );\n}, 10, 3 );",
            "requires": [
                "tutor"
            ],
            "notes": "The email goes to the author of the course the quiz belongs to."
        },
        {
            "id": "tutor-enrolled-content-shortcode",
            "version": 1,
            "title": "Tutor LMS: Enrolled-Students-Only Shortcode",
            "description": "Adds a [wpide_tutor_enrolled course_id=\"123\"] shortcode that shows its content only to students enrolled in that course, with an optional message for others.",
            "category": "learning",
            "tags": [
                "tutor",
                "shortcode",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_tutor_enrolled', function ( $atts, $content = '' ) {\n    $atts      = shortcode_atts( array( 'course_id' => 0, 'message' => '' ), $atts, 'wpide_tutor_enrolled' );\n    $course_id = absint( $atts['course_id'] );\n    $user_id   = get_current_user_id();\n\n    if ( $course_id && $user_id && ( tutor_utils()->is_enrolled( $course_id, $user_id ) || current_user_can( 'edit_post', $course_id ) ) ) {\n        return do_shortcode( $content );\n    }\n\n    return $atts['message'] ? '<p class=\"wpide-tutor-locked\">' . esc_html( $atts['message'] ) . '</p>' : '';\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Example: [wpide_tutor_enrolled course_id=\"123\" message=\"Enroll to get the slides.\"]Slides link[/wpide_tutor_enrolled]."
        },
        {
            "id": "tutor-course-status-body-class",
            "version": 1,
            "title": "Tutor LMS: CSS Classes Based on the Student's Course Status",
            "description": "Adds body classes such as wpide-tutor-enrolled or wpide-tutor-completed on Tutor LMS course pages, so you can show or hide elements with CSS.",
            "category": "learning",
            "tags": [
                "tutor",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    if ( ! is_singular( tutor()->course_post_type ) ) {\n        return $classes;\n    }\n\n    $course_id = get_queried_object_id();\n    $user_id   = get_current_user_id();\n\n    if ( ! $user_id ) {\n        $classes[] = 'wpide-tutor-guest';\n    } elseif ( ! tutor_utils()->is_enrolled( $course_id, $user_id ) ) {\n        $classes[] = 'wpide-tutor-not-enrolled';\n    } else {\n        $classes[] = 'wpide-tutor-enrolled';\n\n        if ( tutor_utils()->is_completed_course( $course_id, $user_id ) ) {\n            $classes[] = 'wpide-tutor-completed';\n        }\n    }\n\n    return $classes;\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Classes: wpide-tutor-guest, wpide-tutor-not-enrolled, wpide-tutor-enrolled, wpide-tutor-completed."
        },
        {
            "id": "tutor-login-redirect-to-dashboard",
            "version": 1,
            "title": "Tutor LMS: Land on the Tutor Dashboard After Logging In",
            "description": "Sends students who log in through wp-login.php to the Tutor LMS dashboard instead of the WordPress admin profile.",
            "category": "learning",
            "tags": [
                "tutor",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_filter( 'login_redirect', function ( $redirect, $requested, $user ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $redirect;\n    }\n\n    if ( $requested && false === strpos( $requested, admin_url() ) ) {\n        return $redirect;\n    }\n\n    $dashboard = tutor_utils()->tutor_dashboard_url();\n\n    return $dashboard ? $dashboard : $redirect;\n}, 10, 3 );",
            "requires": [
                "tutor"
            ],
            "notes": "Staff who can edit posts still go to wp-admin, and explicit redirect_to links are respected."
        },
        {
            "id": "tutor-notify-admin-new-student",
            "version": 1,
            "title": "Tutor LMS: Email the Admin About New Student Sign-Ups",
            "description": "Emails the site admin when someone registers through the Tutor LMS student registration form.",
            "category": "learning",
            "tags": [
                "tutor",
                "registration",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_action( 'tutor_after_student_signup', function ( $user_id ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] New student: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $user->display_name ),\n        sprintf( \"A new student signed up.\\n\\nName: %s\\nEmail: %s\\nProfile: %s\", $user->display_name, $user->user_email, admin_url( 'user-edit.php?user_id=' . (int) $user_id ) )\n    );\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Only Tutor's student sign-up form triggers this email, not other registration forms."
        },
        {
            "id": "tutor-course-sequence-enrollment",
            "version": 1,
            "title": "Tutor LMS: Enroll in the Next Course After Completing One",
            "description": "Builds a learning path by enrolling students in the next Tutor LMS course automatically when they complete the previous one.",
            "category": "learning",
            "tags": [
                "tutor",
                "enrollment",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n// Completed course ID => next course IDs.\n$wpide_tutor_next_courses = array(\n    101 => array( 102 ),\n);\n\nadd_action( 'tutor_course_complete_after', function ( $course_id, $user_id = 0 ) use ( $wpide_tutor_next_courses ) {\n    $user_id = $user_id ? (int) $user_id : get_current_user_id();\n\n    foreach ( $wpide_tutor_next_courses[ (int) $course_id ] ?? array() as $next ) {\n        if ( $user_id && get_post_type( $next ) === tutor()->course_post_type && ! tutor_utils()->is_enrolled( $next, $user_id ) ) {\n            tutor_utils()->do_enroll( (int) $next, 0, $user_id );\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "tutor"
            ],
            "notes": "Best used with free follow-up courses."
        },
        {
            "id": "tutor-last-lesson-users-column",
            "version": 1,
            "title": "Tutor LMS: Track Each Student's Most Recent Lesson in Users",
            "description": "Records when each student last completed a Tutor LMS lesson and shows it in a \"Last lesson\" column on the Users screen.",
            "category": "learning",
            "tags": [
                "tutor",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_action( 'tutor_lesson_completed_after', function ( $lesson_id, $user_id = 0 ) {\n    $user_id = $user_id ? (int) $user_id : get_current_user_id();\n\n    if ( $user_id ) {\n        update_user_meta( $user_id, 'wpide_tutor_last_lesson', time() );\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_tutor_last'] = 'Last lesson';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_tutor_last' !== $column ) {\n        return $output;\n    }\n\n    $time = (int) get_user_meta( $user_id, 'wpide_tutor_last_lesson', true );\n\n    return $time ? esc_html( sprintf( '%s ago', human_time_diff( $time ) ) ) : '&mdash;';\n}, 10, 3 );",
            "requires": [
                "tutor"
            ],
            "notes": "Tracking starts when the snippet is activated."
        },
        {
            "id": "tutor-recent-completions-widget",
            "version": 1,
            "title": "Tutor LMS: Recent Course Completions Dashboard Widget",
            "description": "Keeps a log of the latest Tutor LMS course completions and shows them in a dashboard widget with student names and dates.",
            "category": "learning",
            "tags": [
                "tutor",
                "dashboard",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\nadd_action( 'tutor_course_complete_after', function ( $course_id, $user_id = 0 ) {\n    $log = (array) get_option( 'wpide_tutor_completions', array() );\n    array_unshift( $log, array( 'user' => $user_id ? (int) $user_id : get_current_user_id(), 'course' => (int) $course_id, 'time' => time() ) );\n    update_option( 'wpide_tutor_completions', array_slice( $log, 0, 15 ), false );\n}, 10, 2 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_tutor_completions', 'Recent course completions', function () {\n        $log = array_filter( (array) get_option( 'wpide_tutor_completions', array() ), 'is_array' );\n\n        if ( ! $log ) {\n            echo '<p>No completions recorded yet.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $log as $row ) {\n            $user = get_userdata( $row['user'] );\n            printf(\n                '<li><strong>%1$s</strong> completed %2$s <span style=\"color:#646970\">%3$s</span></li>',\n                esc_html( $user ? $user->display_name : 'Deleted user' ),\n                esc_html( get_the_title( $row['course'] ) ),\n                esc_html( wp_date( get_option( 'date_format' ), (int) $row['time'] ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [
                "tutor"
            ],
            "notes": "Keeps the 15 most recent completions, starting from activation."
        },
        {
            "id": "tutor-progress-bar-brand-color",
            "version": 1,
            "title": "Tutor LMS: Brand Colour for Progress Bars",
            "description": "Recolours Tutor LMS progress bars and the course completion button to your brand colour.",
            "category": "learning",
            "tags": [
                "tutor",
                "css",
                "branding"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":root {\n    --wpide-tutor-brand: #0f9d58;\n}\n\n.tutor-progress-bar .tutor-progress-value,\n.tutor-course-progress-wrapper .tutor-progress-value {\n    background: var(--wpide-tutor-brand);\n}\n\n.tutor-course-complete-button,\n.tutor-topbar-mark-btn {\n    background-color: var(--wpide-tutor-brand);\n    border-color: var(--wpide-tutor-brand);\n}",
            "requires": [
                "tutor"
            ],
            "notes": "Change --wpide-tutor-brand. Tutor's colour settings under Tutor LMS > Settings > Design take precedence when set."
        },
        {
            "id": "lesson-mark-complete-button",
            "version": 1,
            "title": "\"Mark as Complete\" Button for Lesson Posts (No LMS Needed)",
            "description": "Adds a [wpide_mark_complete] shortcode that lets logged-in readers tick off a post or page as done, turning a plain blog series into a simple course without an LMS plugin.",
            "category": "learning",
            "tags": [
                "lessons",
                "progress",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_complete_nonce'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $post_id = isset( $_POST['wpide_lesson'] ) ? absint( $_POST['wpide_lesson'] ) : 0;\n\n    if ( ! $post_id || ! wp_verify_nonce( sanitize_key( $_POST['wpide_complete_nonce'] ), 'wpide_complete_' . $post_id ) || 'publish' !== get_post_status( $post_id ) ) {\n        return;\n    }\n\n    $done = array_map( 'intval', (array) get_user_meta( get_current_user_id(), 'wpide_completed_lessons', true ) );\n\n    if ( in_array( $post_id, $done, true ) ) {\n        $done = array_diff( $done, array( $post_id ) );\n    } else {\n        $done[] = $post_id;\n    }\n\n    update_user_meta( get_current_user_id(), 'wpide_completed_lessons', array_values( array_unique( $done ) ) );\n\n    wp_safe_redirect( get_permalink( $post_id ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_mark_complete', function () {\n    $post_id = get_the_ID();\n\n    if ( ! $post_id ) {\n        return '';\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return '<p class=\"wpide-complete\"><a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">Log in</a> to track your progress.</p>';\n    }\n\n    $done = in_array( $post_id, array_map( 'intval', (array) get_user_meta( get_current_user_id(), 'wpide_completed_lessons', true ) ), true );\n\n    return '<form method=\"post\" class=\"wpide-complete' . ( $done ? ' is-done' : '' ) . '\">'\n        . wp_nonce_field( 'wpide_complete_' . $post_id, 'wpide_complete_nonce', true, false )\n        . '<input type=\"hidden\" name=\"wpide_lesson\" value=\"' . (int) $post_id . '\">'\n        . '<button type=\"submit\" aria-pressed=\"' . ( $done ? 'true' : 'false' ) . '\">' . ( $done ? '&#10003; Completed (undo)' : 'Mark as complete' ) . '</button></form>';\n} );",
            "requires": [],
            "notes": "Completed lessons are stored per user in the wpide_completed_lessons user meta. Pair it with the \"Series Progress\" snippet to show overall progress."
        },
        {
            "id": "series-progress-shortcode",
            "version": 1,
            "title": "Series Progress for a Category of Lessons (No LMS Needed)",
            "description": "Adds a [wpide_series_progress category=\"course-1\"] shortcode that shows how many posts in a category the reader has marked complete, with a progress bar and checklist.",
            "category": "learning",
            "tags": [
                "lessons",
                "progress",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_series_progress', function ( $atts ) {\n    $atts = shortcode_atts( array( 'category' => '', 'list' => 'yes' ), $atts, 'wpide_series_progress' );\n\n    if ( '' === $atts['category'] ) {\n        return '';\n    }\n\n    $lessons = get_posts( array(\n        'category_name' => sanitize_title( $atts['category'] ),\n        'numberposts'   => 100,\n        'orderby'       => 'date',\n        'order'         => 'ASC',\n        'fields'        => 'ids',\n        'no_found_rows' => true,\n    ) );\n\n    if ( ! $lessons ) {\n        return '';\n    }\n\n    $done    = is_user_logged_in() ? array_map( 'intval', (array) get_user_meta( get_current_user_id(), 'wpide_completed_lessons', true ) ) : array();\n    $count   = count( array_intersect( $lessons, $done ) );\n    $percent = (int) round( $count / count( $lessons ) * 100 );\n\n    $html = sprintf(\n        '<div class=\"wpide-series\"><div role=\"progressbar\" aria-valuenow=\"%1$d\" aria-valuemin=\"0\" aria-valuemax=\"100\" aria-label=\"Series progress\" style=\"height:10px;border-radius:5px;background:rgba(127,127,127,.25);overflow:hidden\"><div style=\"width:%1$d%%;height:100%%;background:currentColor\"></div></div><p style=\"margin:.4em 0\">%2$s</p>',\n        $percent,\n        esc_html( sprintf( '%1$d of %2$d lessons complete', $count, count( $lessons ) ) )\n    );\n\n    if ( 'yes' === $atts['list'] ) {\n        $html .= '<ol class=\"wpide-series__list\">';\n        foreach ( $lessons as $id ) {\n            $is_done = in_array( $id, $done, true );\n            $html   .= '<li' . ( $is_done ? ' class=\"is-done\"' : '' ) . '><a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( get_the_title( $id ) ) . '</a>' . ( $is_done ? ' <span aria-label=\"completed\">&#10003;</span>' : '' ) . '</li>';\n        }\n        $html .= '</ol>';\n    }\n\n    return $html . '</div>';\n} );",
            "requires": [],
            "notes": "Works with the \"Mark as Complete\" button snippet. Posts are listed oldest first; add list=\"no\" to show only the bar."
        },
        {
            "id": "lesson-series-navigation",
            "version": 1,
            "title": "Previous and Next Lesson Links Within a Category",
            "description": "Adds \"Previous lesson\" and \"Next lesson\" links at the end of posts in your course categories, in publishing order, so readers can work through a series.",
            "category": "learning",
            "tags": [
                "lessons",
                "navigation",
                "series"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Category slugs that hold lesson series.\n$wpide_course_categories = array( 'course-1' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_course_categories ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! has_category( $wpide_course_categories ) ) {\n        return $content;\n    }\n\n    $prev = get_adjacent_post( true, '', true, 'category' );\n    $next = get_adjacent_post( true, '', false, 'category' );\n\n    if ( ! $prev && ! $next ) {\n        return $content;\n    }\n\n    $nav = '<nav class=\"wpide-lesson-nav\" aria-label=\"Lesson navigation\" style=\"display:flex;flex-wrap:wrap;justify-content:space-between;gap:1em;margin-top:2em\">';\n    $nav .= $prev ? '<a rel=\"prev\" href=\"' . esc_url( get_permalink( $prev ) ) . '\">&larr; ' . esc_html( get_the_title( $prev ) ) . '</a>' : '<span></span>';\n    $nav .= $next ? '<a rel=\"next\" href=\"' . esc_url( get_permalink( $next ) ) . '\">' . esc_html( get_the_title( $next ) ) . ' &rarr;</a>' : '';\n\n    return $content . $nav . '</nav>';\n} );",
            "requires": [],
            "notes": "List the category slugs that are courses in $wpide_course_categories. Links stay inside the same category."
        },
        {
            "id": "learndash-email-admin-new-enrollment",
            "version": 1,
            "title": "LearnDash: Email the Admin About New Enrollments",
            "description": "Sends you an email whenever a user is given access to a LearnDash course, whether through purchase, a group or manual enrollment.",
            "category": "learning",
            "tags": [
                "learndash",
                "enrollment",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\n$wpide_ld_enroll_courses = array();\n\nadd_action( 'learndash_update_course_access', function ( $user_id, $course_id, $access_list = null, $remove = false ) use ( $wpide_ld_enroll_courses ) {\n    if ( $remove || ( $wpide_ld_enroll_courses && ! in_array( (int) $course_id, $wpide_ld_enroll_courses, true ) ) ) {\n        return;\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'New enrollment: %s', get_the_title( $course_id ) ),\n        sprintf( \"%s (%s) was enrolled in \\\"%s\\\".\\n\\nProfile: %s\", $user->display_name, $user->user_email, get_the_title( $course_id ), admin_url( 'user-edit.php?user_id=' . (int) $user_id ) )\n    );\n}, 10, 4 );",
            "requires": [
                "learndash"
            ],
            "notes": "Removals are not reported. Limit it to some courses with $wpide_ld_enroll_courses."
        },
        {
            "id": "learndash-edit-course-admin-bar",
            "version": 1,
            "title": "LearnDash: \"Edit Course\" Link in the Toolbar on Lessons",
            "description": "Adds an \"Edit Course\" link to the admin toolbar when you view a lesson, topic or quiz, so you can jump straight to the parent course's builder.",
            "category": "learning",
            "tags": [
                "learndash",
                "admin-bar",
                "courses"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! defined( 'LEARNDASH_VERSION' ) ) {\n    return;\n}\n\nadd_action( 'admin_bar_menu', function ( $bar ) {\n    if ( ! is_singular( array( 'sfwd-lessons', 'sfwd-topic', 'sfwd-quiz' ) ) ) {\n        return;\n    }\n\n    $course_id = (int) learndash_get_course_id( get_queried_object_id() );\n\n    if ( ! $course_id || ! current_user_can( 'edit_post', $course_id ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-ld-edit-course',\n        'title' => 'Edit Course',\n        'href'  => get_edit_post_link( $course_id ),\n    ) );\n}, 90 );",
            "requires": [
                "learndash"
            ],
            "notes": "Only shown to users who can edit the course."
        },
        {
            "id": "lifterlms-email-admin-new-enrollment",
            "version": 1,
            "title": "LifterLMS: Email the Admin About New Enrollments",
            "description": "Emails you when a student is enrolled in a LifterLMS course, so you can welcome them personally or keep an eye on sales.",
            "category": "learning",
            "tags": [
                "lifterlms",
                "enrollment",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'llms_get_student' ) ) {\n    return;\n}\n\nadd_action( 'llms_user_enrolled_in_course', function ( $user_id, $course_id ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'New enrollment: %s', get_the_title( $course_id ) ),\n        sprintf( \"%s (%s) enrolled in \\\"%s\\\".\", $user->display_name, $user->user_email, get_the_title( $course_id ) )\n    );\n}, 10, 2 );",
            "requires": [
                "lifterlms"
            ],
            "notes": "Fires for every enrollment trigger: purchases, memberships, vouchers and manual enrollments."
        },
        {
            "id": "tutor-email-admin-new-enrollment",
            "version": 1,
            "title": "Tutor LMS: Admin Alert for Every New Course Enrollment",
            "description": "Emails the site admin each time a student enrolls in a Tutor LMS course, with the student's name and the course title.",
            "category": "learning",
            "tags": [
                "tutor",
                "enrollment",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'tutor_utils' ) ) {\n    return;\n}\n\n$wpide_tutor_enroll_courses = array();\n\nadd_action( 'tutor_after_enrolled', function ( $course_id, $user_id ) use ( $wpide_tutor_enroll_courses ) {\n    if ( $wpide_tutor_enroll_courses && ! in_array( (int) $course_id, $wpide_tutor_enroll_courses, true ) ) {\n        return;\n    }\n\n    $user = get_userdata( $user_id );\n\n    if ( $user ) {\n        wp_mail(\n            get_option( 'admin_email' ),\n            sprintf( 'New enrollment: %s', get_the_title( $course_id ) ),\n            sprintf( \"%s (%s) enrolled in \\\"%s\\\".\", $user->display_name, $user->user_email, get_the_title( $course_id ) )\n        );\n    }\n}, 10, 2 );",
            "requires": [
                "tutor"
            ],
            "notes": "Limit it to some courses with $wpide_tutor_enroll_courses."
        },
        {
            "id": "buddypress-hide-last-active-time",
            "version": 1,
            "title": "BuddyPress: Hide \"Active X Minutes Ago\" on Profiles",
            "description": "Removes the \"active 5 minutes ago\" timestamps from BuddyPress profiles and the members directory, for communities where members value privacy.",
            "category": "users",
            "tags": [
                "buddypress",
                "privacy",
                "profile"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_filter( 'bp_get_last_activity', '__return_empty_string' );\nadd_filter( 'bp_member_last_active', '__return_empty_string' );\nadd_filter( 'bp_get_member_last_active', '__return_empty_string' );",
            "requires": [
                "buddypress"
            ],
            "notes": "BuddyPress still records activity internally, so \"Active\" sorting in the directory keeps working."
        },
        {
            "id": "pmpro-members-skip-levels-page",
            "version": 1,
            "title": "Paid Memberships Pro: Send Members From the Levels Page to Their Account",
            "description": "Redirects members who already have a level away from the levels page to their membership account page, unless they came to change their level.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "redirect",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    global $pmpro_pages;\n\n    if ( empty( $pmpro_pages['levels'] ) || ! is_page( (int) $pmpro_pages['levels'] ) || isset( $_GET['change'] ) ) {\n        return;\n    }\n\n    if ( is_user_logged_in() && pmpro_hasMembershipLevel() ) {\n        wp_safe_redirect( pmpro_url( 'account' ) );\n        exit;\n    }\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Add ?change=1 to links where members should be able to pick a new level."
        },
        {
            "id": "memberpress-members-skip-signup-page",
            "version": 1,
            "title": "MemberPress: Send Existing Members From Sign-Up Pages to Their Account",
            "description": "Redirects logged-in members who already own a membership away from that membership's sign-up page to their account page, avoiding accidental double purchases.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "redirect",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) || ! class_exists( 'MeprProduct' ) ) {\n    return;\n}\n\n$wpide_mepr_account_url = home_url( '/account/' );\n\nadd_action( 'template_redirect', function () use ( $wpide_mepr_account_url ) {\n    if ( ! is_user_logged_in() || ! is_singular( MeprProduct::$cpt ) ) {\n        return;\n    }\n\n    $member = new MeprUser( get_current_user_id() );\n\n    if ( $member->is_already_subscribed_to( get_queried_object_id() ) ) {\n        wp_safe_redirect( $wpide_mepr_account_url );\n        exit;\n    }\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Members can still buy other memberships; only the one they already have redirects."
        },
        {
            "id": "buddypress-login-redirect-to-profile",
            "version": 1,
            "title": "BuddyPress: Send Members to Their Profile After Login",
            "description": "Sends members to their own BuddyPress profile (or activity stream) after logging in, instead of the WordPress dashboard.",
            "category": "users",
            "tags": [
                "buddypress",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// \"profile\" or \"activity\".\n$wpide_bp_login_target = 'profile';\n\nadd_filter( 'login_redirect', function ( $redirect, $requested, $user ) use ( $wpide_bp_login_target ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $redirect;\n    }\n\n    if ( $requested && false === strpos( $requested, admin_url() ) ) {\n        return $redirect;\n    }\n\n    if ( 'activity' === $wpide_bp_login_target && bp_is_active( 'activity' ) ) {\n        return bp_get_activity_directory_permalink();\n    }\n\n    $url = function_exists( 'bp_members_get_user_url' ) ? bp_members_get_user_url( $user->ID ) : bp_core_get_user_domain( $user->ID );\n\n    return $url ? $url : $redirect;\n}, 10, 3 );",
            "requires": [
                "buddypress"
            ],
            "notes": "Set $wpide_bp_login_target to \"activity\" to land on the site-wide activity stream. Staff who can edit posts still go to wp-admin."
        },
        {
            "id": "buddypress-hide-admins-from-directory",
            "version": 1,
            "title": "BuddyPress: Hide Administrators From the Members Directory",
            "description": "Keeps site administrators (and any other roles you list) out of the BuddyPress members directory and member counts on the directory page.",
            "category": "users",
            "tags": [
                "buddypress",
                "members",
                "directory"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Roles to hide from the directory.\n$wpide_bp_hidden_roles = array( 'administrator' );\n\nadd_filter( 'bp_after_has_members_parse_args', function ( $args ) use ( $wpide_bp_hidden_roles ) {\n    if ( ! bp_is_members_directory() && ! wp_doing_ajax() ) {\n        return $args;\n    }\n\n    $hidden = get_transient( 'wpide_bp_hidden_members' );\n\n    if ( false === $hidden ) {\n        $hidden = get_users( array( 'role__in' => $wpide_bp_hidden_roles, 'fields' => 'ID', 'number' => 500 ) );\n        set_transient( 'wpide_bp_hidden_members', $hidden, HOUR_IN_SECONDS );\n    }\n\n    if ( $hidden ) {\n        $exclude         = empty( $args['exclude'] ) ? array() : wp_parse_id_list( $args['exclude'] );\n        $args['exclude'] = array_unique( array_merge( $exclude, array_map( 'intval', $hidden ) ) );\n    }\n\n    return $args;\n} );\n\nadd_action( 'set_user_role', function () {\n    delete_transient( 'wpide_bp_hidden_members' );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Hidden members can still be found by visiting their profile URL directly. The list of IDs is cached for an hour."
        },
        {
            "id": "buddypress-private-community",
            "version": 1,
            "title": "BuddyPress: Members-Only Community Pages",
            "description": "Sends logged-out visitors who open BuddyPress pages (members, profiles, activity, groups) to the login screen, so the community is visible to members only.",
            "category": "users",
            "tags": [
                "buddypress",
                "private",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( is_user_logged_in() || ! is_buddypress() ) {\n        return;\n    }\n\n    if ( bp_is_register_page() || bp_is_activation_page() ) {\n        return;\n    }\n\n    wp_safe_redirect( wp_login_url( home_url( add_query_arg( array() ) ) ) );\n    exit;\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Registration and account activation pages stay open so new members can still sign up."
        },
        {
            "id": "buddypress-remove-profile-tabs",
            "version": 1,
            "title": "BuddyPress: Remove Profile Tabs You Don't Need",
            "description": "Removes tabs from BuddyPress member profiles, such as Friends or Groups, while leaving the components switched on elsewhere.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Profile tabs (or parent/child sub-tabs) to remove.\n$wpide_bp_removed_tabs = array( 'friends', 'settings/profile' );\n\nadd_action( 'bp_setup_nav', function () use ( $wpide_bp_removed_tabs ) {\n    foreach ( $wpide_bp_removed_tabs as $tab ) {\n        if ( false !== strpos( $tab, '/' ) ) {\n            list( $parent, $child ) = explode( '/', $tab, 2 );\n            bp_core_remove_subnav_item( $parent, $child );\n        } else {\n            bp_core_remove_nav_item( $tab );\n        }\n    }\n}, 1000 );",
            "requires": [
                "buddypress"
            ],
            "notes": "Use tab slugs such as friends, groups, notifications, messages or settings. To remove a sub-tab add \"parent/child\", for example \"settings/profile\"."
        },
        {
            "id": "buddypress-strip-links-new-members",
            "version": 1,
            "title": "BuddyPress: Strip Links From New Members' Activity Updates",
            "description": "Turns links into plain text in activity updates posted by members who joined recently, which takes the reward out of spam sign-ups.",
            "category": "users",
            "tags": [
                "buddypress",
                "spam",
                "activity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Members registered fewer than this many days ago cannot post links.\n$wpide_bp_link_days = 7;\n\nadd_action( 'bp_activity_before_save', function ( $activity ) use ( $wpide_bp_link_days ) {\n    if ( empty( $activity->user_id ) || empty( $activity->content ) || user_can( $activity->user_id, 'bp_moderate' ) ) {\n        return;\n    }\n\n    $user = get_userdata( $activity->user_id );\n\n    if ( ! $user || time() - strtotime( $user->user_registered . ' UTC' ) > $wpide_bp_link_days * DAY_IN_SECONDS ) {\n        return;\n    }\n\n    $content = preg_replace( '#<a\\b[^>]*>(.*?)</a>#is', '$1', $activity->content );\n    $content = preg_replace( '#\\bhttps?://#i', '', $content );\n    $activity->content = preg_replace( '#\\bwww\\.#i', 'www ', $content );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Change $wpide_bp_link_days to set how long members count as new. Moderators are never affected."
        },
        {
            "id": "buddypress-activity-blocked-words",
            "version": 1,
            "title": "BuddyPress: Mark Activity With Blocked Words as Spam",
            "description": "Automatically marks activity updates and comments that contain words from your blocklist as spam, so they never appear in the stream.",
            "category": "users",
            "tags": [
                "buddypress",
                "spam",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Words or phrases that mark an update as spam.\n$wpide_bp_blocked_words = array( 'casino', 'free followers', 'crypto giveaway' );\n\nadd_action( 'bp_activity_before_save', function ( $activity ) use ( $wpide_bp_blocked_words ) {\n    if ( empty( $activity->content ) || ! empty( $activity->is_spam ) || user_can( (int) $activity->user_id, 'bp_moderate' ) ) {\n        return;\n    }\n\n    $text = wp_strip_all_tags( $activity->content );\n\n    foreach ( $wpide_bp_blocked_words as $word ) {\n        if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $text ) ) {\n            $activity->is_spam = 1;\n            return;\n        }\n    }\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Words are matched case-insensitively as whole words. Spammed items can be reviewed under Activity in wp-admin."
        },
        {
            "id": "buddypress-hide-activity-types",
            "version": 1,
            "title": "BuddyPress: Hide Noisy Activity Types From the Stream",
            "description": "Removes routine entries such as \"became a registered member\", \"changed their profile picture\" and new friendships from the activity streams.",
            "category": "users",
            "tags": [
                "buddypress",
                "activity",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Activity types to hide.\n$wpide_bp_hidden_types = array( 'new_member', 'new_avatar', 'updated_profile', 'friendship_created' );\n\nadd_filter( 'bp_after_has_activities_parse_args', function ( $args ) use ( $wpide_bp_hidden_types ) {\n    $query   = empty( $args['filter_query'] ) ? array() : (array) $args['filter_query'];\n    $query[] = array( 'column' => 'type', 'value' => $wpide_bp_hidden_types, 'compare' => 'NOT IN' );\n\n    $args['filter_query'] = $query;\n\n    return $args;\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "The entries are hidden, not deleted, so removing a type from the list brings them back."
        },
        {
            "id": "buddypress-profile-field-in-directory",
            "version": 1,
            "title": "BuddyPress: Show a Profile Field in the Members Directory",
            "description": "Shows a profile field such as Location or Job Title under each member's name in the BuddyPress members directory.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "directory"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Profile field names to show.\n$wpide_bp_directory_fields = array( 'Location' );\n\nadd_action( 'bp_directory_members_item', function () use ( $wpide_bp_directory_fields ) {\n    if ( ! bp_is_active( 'xprofile' ) ) {\n        return;\n    }\n\n    $user_id = bp_get_member_user_id();\n    // Fields this member has hidden from the current viewer.\n    $hidden  = array_map( 'intval', (array) bp_xprofile_get_hidden_fields_for_user( $user_id, bp_loggedin_user_id() ) );\n\n    foreach ( $wpide_bp_directory_fields as $field ) {\n        $field_id = (int) xprofile_get_field_id_from_name( $field );\n\n        if ( ! $field_id || in_array( $field_id, $hidden, true ) ) {\n            continue;\n        }\n\n        $value = xprofile_get_field_data( $field_id, $user_id, 'comma' );\n\n        if ( $value ) {\n            printf( '<div class=\"wpide-bp-field wpide-bp-field-%1$d\">%2$s</div>', (int) $field_id, wp_kses_post( $value ) );\n        }\n    }\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Use the exact field name from Users > Profile Fields. Field visibility settings are respected."
        },
        {
            "id": "buddypress-profile-field-shortcode",
            "version": 1,
            "title": "BuddyPress: Profile Field Shortcode",
            "description": "Adds a [wpide_bp_field name=\"Location\"] shortcode that prints a BuddyPress profile field for the logged-in member, or for the profile being viewed.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_bp_field', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => '', 'user' => 'current', 'fallback' => '' ), $atts, 'wpide_bp_field' );\n    $user_id = 'displayed' === $atts['user'] ? bp_displayed_user_id() : bp_loggedin_user_id();\n\n    if ( ! $user_id || '' === $atts['name'] || ! bp_is_active( 'xprofile' ) ) {\n        return esc_html( $atts['fallback'] );\n    }\n\n    $field_id = xprofile_get_field_id_from_name( $atts['name'] );\n\n    if ( ! $field_id ) {\n        return esc_html( $atts['fallback'] );\n    }\n\n    // Respect the member's visibility choice when showing someone else's field.\n    if ( in_array( (int) $field_id, array_map( 'intval', (array) bp_xprofile_get_hidden_fields_for_user( $user_id, bp_loggedin_user_id() ) ), true ) ) {\n        return esc_html( $atts['fallback'] );\n    }\n\n    $value = xprofile_get_field_data( $field_id, $user_id, 'comma' );\n\n    return $value ? wp_kses_post( $value ) : esc_html( $atts['fallback'] );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Add user=\"displayed\" to show the field of the profile being viewed instead of the logged-in member."
        },
        {
            "id": "buddypress-disable-mentions",
            "version": 1,
            "title": "BuddyPress: Turn Off @Mentions",
            "description": "Switches off @mention links, notifications and the mention autocomplete in BuddyPress for communities that don't want them.",
            "category": "users",
            "tags": [
                "buddypress",
                "mentions",
                "activity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_filter( 'bp_activity_do_mentions', '__return_false' );\nadd_filter( 'bp_activity_maybe_load_mentions_scripts', '__return_false' );",
            "requires": [
                "buddypress"
            ],
            "notes": "Existing mention links in old updates are left as they are."
        },
        {
            "id": "buddypress-no-gravatar-fallback",
            "version": 1,
            "title": "BuddyPress: Don't Load Gravatar for Member Avatars",
            "description": "Stops BuddyPress from requesting Gravatar images for members without an uploaded photo and uses the local mystery-person image instead, for privacy and speed.",
            "category": "users",
            "tags": [
                "buddypress",
                "avatars",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_filter( 'bp_core_fetch_avatar_no_grav', '__return_true' );",
            "requires": [
                "buddypress"
            ],
            "notes": "Members who upload their own profile photo are not affected."
        },
        {
            "id": "buddypress-disable-cover-images",
            "version": 1,
            "title": "BuddyPress: Turn Off Cover Images",
            "description": "Disables cover image uploads for member profiles and groups, keeping profiles simple and saving storage.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n$wpide_bp_no_member_covers = true;\n$wpide_bp_no_group_covers  = true;\n\nif ( $wpide_bp_no_member_covers ) {\n    add_filter( 'bp_disable_cover_image_uploads', '__return_true' );\n}\n\nif ( $wpide_bp_no_group_covers ) {\n    add_filter( 'bp_disable_group_cover_image_uploads', '__return_true' );\n}",
            "requires": [
                "buddypress"
            ],
            "notes": "Set either variable to false to keep that kind of cover image."
        },
        {
            "id": "buddypress-limit-activity-length",
            "version": 1,
            "title": "BuddyPress: Limit the Length of Activity Updates",
            "description": "Shortens activity updates and comments longer than a set number of characters, which keeps the stream readable and discourages wall-of-text spam.",
            "category": "users",
            "tags": [
                "buddypress",
                "activity",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Maximum characters per update.\n$wpide_bp_max_chars = 1000;\n\nadd_action( 'bp_activity_before_save', function ( $activity ) use ( $wpide_bp_max_chars ) {\n    if ( empty( $activity->content ) || user_can( (int) $activity->user_id, 'bp_moderate' ) || ! in_array( $activity->type, array( 'activity_update', 'activity_comment' ), true ) ) {\n        return;\n    }\n\n    if ( mb_strlen( wp_strip_all_tags( $activity->content ) ) > $wpide_bp_max_chars ) {\n        $activity->content = mb_substr( wp_strip_all_tags( $activity->content ), 0, $wpide_bp_max_chars ) . '&hellip;';\n    }\n} );\n\nadd_action( 'wp_footer', function () use ( $wpide_bp_max_chars ) {\n    if ( ! is_buddypress() || ! is_user_logged_in() ) {\n        return;\n    }\n\n    printf( '<script>document.querySelectorAll(\"#whats-new, .ac-input\").forEach(function(el){el.setAttribute(\"maxlength\",\"%d\");});</script>', (int) $wpide_bp_max_chars );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Moderators can post longer updates. A maxlength is also added to the update box so members see the limit while typing."
        },
        {
            "id": "buddypress-email-admin-new-member",
            "version": 1,
            "title": "BuddyPress: Email the Admin When a Member Activates",
            "description": "Emails the site admin when a new member activates their BuddyPress account, with a link to their profile.",
            "category": "users",
            "tags": [
                "buddypress",
                "registration",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_action( 'bp_core_activated_user', function ( $user_id ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $profile = function_exists( 'bp_members_get_user_url' ) ? bp_members_get_user_url( $user_id ) : bp_core_get_user_domain( $user_id );\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] New member: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $user->display_name ),\n        sprintf( \"%s (%s) has activated their account.\\n\\nProfile: %s\", $user->display_name, $user->user_email, $profile )\n    );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Fires once the member has clicked their activation link, so unconfirmed spam sign-ups are not reported."
        },
        {
            "id": "buddypress-auto-join-groups",
            "version": 1,
            "title": "BuddyPress: Add New Members to Groups Automatically",
            "description": "Adds every newly activated member to the BuddyPress groups you choose, such as a \"Welcome\" or \"Announcements\" group.",
            "category": "users",
            "tags": [
                "buddypress",
                "groups",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Group IDs.\n$wpide_bp_welcome_groups = array( 1 );\n\nadd_action( 'bp_core_activated_user', function ( $user_id ) use ( $wpide_bp_welcome_groups ) {\n    if ( ! bp_is_active( 'groups' ) ) {\n        return;\n    }\n\n    foreach ( $wpide_bp_welcome_groups as $group_id ) {\n        if ( ! groups_is_user_member( $user_id, $group_id ) ) {\n            groups_join_group( (int) $group_id, (int) $user_id );\n        }\n    }\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Use group IDs (shown in the Groups admin list). Works with public, private and hidden groups."
        },
        {
            "id": "buddypress-notification-count-shortcode",
            "version": 1,
            "title": "BuddyPress: Unread Notifications Shortcode",
            "description": "Adds a [wpide_bp_notifications] shortcode that shows a bell link with the member's unread BuddyPress notification count, for headers and menus built with blocks.",
            "category": "users",
            "tags": [
                "buddypress",
                "notifications",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_bp_notifications', function () {\n    $user_id = bp_loggedin_user_id();\n\n    if ( ! $user_id || ! bp_is_active( 'notifications' ) ) {\n        return '';\n    }\n\n    $count = (int) bp_notifications_get_unread_notification_count( $user_id );\n    $label = sprintf( _n( '%d unread notification', '%d unread notifications', $count ), $count );\n\n    return sprintf(\n        '<a class=\"wpide-bp-bell\" href=\"%1$s\" aria-label=\"%2$s\"><span aria-hidden=\"true\">&#128276;</span>%3$s</a>',\n        esc_url( bp_get_notifications_permalink( $user_id ) ),\n        esc_attr( $label ),\n        $count ? '<span class=\"wpide-bp-bell__count\" aria-hidden=\"true\" style=\"display:inline-block;min-width:1.4em;margin-left:.25em;padding:0 .35em;border-radius:999px;background:#d63638;color:#fff;font-size:.75em;text-align:center\">' . esc_html( number_format_i18n( $count ) ) . '</span>' : ''\n    );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Shows nothing to visitors. Style the badge with .wpide-bp-bell__count."
        },
        {
            "id": "buddypress-members-per-page",
            "version": 1,
            "title": "BuddyPress: Members per Page in the Directory",
            "description": "Changes how many members the BuddyPress members directory shows per page, and can switch the default order to alphabetical.",
            "category": "users",
            "tags": [
                "buddypress",
                "directory",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n$wpide_bp_members_per_page = 48;\n// \"alphabetical\", \"newest\", \"active\" or \"\" for the default.\n$wpide_bp_default_order    = 'alphabetical';\n\nadd_filter( 'bp_after_has_members_parse_args', function ( $args ) use ( $wpide_bp_members_per_page, $wpide_bp_default_order ) {\n    if ( ! bp_is_members_directory() && ! wp_doing_ajax() ) {\n        return $args;\n    }\n\n    $args['per_page'] = $wpide_bp_members_per_page;\n\n    // Only change the order when the visitor has not picked one.\n    if ( $wpide_bp_default_order && empty( $_REQUEST['type'] ) && empty( $_REQUEST['filter'] ) ) {\n        $args['type'] = $wpide_bp_default_order;\n    }\n\n    return $args;\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Set $wpide_bp_default_order to \"\" to keep BuddyPress's own \"last active\" order."
        },
        {
            "id": "buddypress-custom-profile-tab",
            "version": 1,
            "title": "BuddyPress: Add a Custom Tab to Member Profiles",
            "description": "Adds a new tab to every BuddyPress member profile with your own content, for example \"Portfolio\" showing the member's posts.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n$wpide_bp_tab_name = 'Articles';\n$wpide_bp_tab_slug = 'articles';\n\nadd_action( 'bp_setup_nav', function () use ( $wpide_bp_tab_name, $wpide_bp_tab_slug ) {\n    bp_core_new_nav_item( array(\n        'name'                    => $wpide_bp_tab_name,\n        'slug'                    => $wpide_bp_tab_slug,\n        'position'                => 70,\n        'default_subnav_slug'     => $wpide_bp_tab_slug,\n        'show_for_displayed_user' => true,\n        'screen_function'         => function () {\n            add_action( 'bp_template_content', function () {\n                $posts = get_posts( array( 'author' => bp_displayed_user_id(), 'numberposts' => 10, 'no_found_rows' => true ) );\n\n                if ( ! $posts ) {\n                    echo '<p>No articles yet.</p>';\n                    return;\n                }\n\n                echo '<ul class=\"wpide-bp-articles\">';\n                foreach ( $posts as $post ) {\n                    printf( '<li><a href=\"%1$s\">%2$s</a> <small>%3$s</small></li>', esc_url( get_permalink( $post ) ), esc_html( get_the_title( $post ) ), esc_html( get_the_date( '', $post ) ) );\n                }\n                echo '</ul>';\n            } );\n\n            bp_core_load_template( 'members/single/plugins' );\n        },\n    ) );\n}, 100 );",
            "requires": [
                "buddypress"
            ],
            "notes": "The example lists the member's published posts; replace the content callback with your own output."
        },
        {
            "id": "buddypress-last-active-users-column",
            "version": 1,
            "title": "BuddyPress: Last Active Column on the Users Screen",
            "description": "Adds a \"Last active\" column to the WordPress Users screen using BuddyPress's activity tracking, so you can see who is actually using the community.",
            "category": "users",
            "tags": [
                "buddypress",
                "users",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_bp_active'] = 'Last active';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_bp_active' !== $column ) {\n        return $output;\n    }\n\n    $last = bp_get_user_last_activity( $user_id );\n\n    return $last ? esc_html( sprintf( '%s ago', human_time_diff( strtotime( $last . ' UTC' ) ) ) ) : 'Never';\n}, 10, 3 );",
            "requires": [
                "buddypress"
            ],
            "notes": "Shows \"Never\" for members who have not been active since BuddyPress was installed."
        },
        {
            "id": "buddypress-username-rules",
            "version": 1,
            "title": "BuddyPress: Username Rules on the Sign-Up Form",
            "description": "Requires BuddyPress usernames to be at least a few characters long and not just numbers, giving members a clear error message on the sign-up form.",
            "category": "users",
            "tags": [
                "buddypress",
                "registration",
                "usernames"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n$wpide_bp_username_min = 5;\n\nadd_filter( 'bp_core_validate_user_signup', function ( $result ) use ( $wpide_bp_username_min ) {\n    if ( empty( $result['user_name'] ) || ! isset( $result['errors'] ) || ! is_wp_error( $result['errors'] ) ) {\n        return $result;\n    }\n\n    $name = $result['user_name'];\n\n    if ( strlen( $name ) < $wpide_bp_username_min ) {\n        $result['errors']->add( 'user_name', sprintf( 'Usernames must be at least %d characters long.', $wpide_bp_username_min ) );\n    } elseif ( ctype_digit( $name ) ) {\n        $result['errors']->add( 'user_name', 'Usernames must contain at least one letter.' );\n    }\n\n    return $result;\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "BuddyPress already limits usernames to letters and numbers; this adds length and \"not only digits\" checks."
        },
        {
            "id": "buddypress-friends-only-messages",
            "version": 1,
            "title": "BuddyPress: Only Friends Can Send Private Messages",
            "description": "Limits BuddyPress private messages so members can only message people who have accepted their friend request, which cuts down unwanted messages.",
            "category": "users",
            "tags": [
                "buddypress",
                "messages",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_action( 'messages_message_before_save', function ( $message ) {\n    if ( ! bp_is_active( 'friends' ) || empty( $message->recipients ) || user_can( (int) $message->sender_id, 'bp_moderate' ) ) {\n        return;\n    }\n\n    foreach ( (array) $message->recipients as $key => $recipient ) {\n        $recipient_id = is_object( $recipient ) ? (int) $recipient->user_id : (int) $recipient;\n\n        if ( $recipient_id !== (int) $message->sender_id && ! friends_check_friendship( (int) $message->sender_id, $recipient_id ) && ! user_can( $recipient_id, 'bp_moderate' ) ) {\n            unset( $message->recipients[ $key ] );\n        }\n    }\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Moderators can still message anyone. If none of the recipients are friends the message is not sent and BuddyPress shows its error notice."
        },
        {
            "id": "buddypress-limit-group-creation",
            "version": 1,
            "title": "BuddyPress: Let Only Certain Roles Create Groups",
            "description": "Restricts creating BuddyPress groups to the roles you choose, such as editors or paying members, while everyone can still join groups.",
            "category": "users",
            "tags": [
                "buddypress",
                "groups",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Roles allowed to create groups.\n$wpide_bp_group_creators = array( 'editor', 'author' );\n\nadd_filter( 'bp_user_can_create_groups', function ( $can_create ) use ( $wpide_bp_group_creators ) {\n    if ( ! is_user_logged_in() ) {\n        return false;\n    }\n\n    if ( bp_current_user_can( 'bp_moderate' ) ) {\n        return true;\n    }\n\n    return $can_create && (bool) array_intersect( $wpide_bp_group_creators, (array) wp_get_current_user()->roles );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Administrators can always create groups."
        },
        {
            "id": "buddypress-hide-admin-activity",
            "version": 1,
            "title": "BuddyPress: Hide Administrators' Activity From the Stream",
            "description": "Keeps activity created by administrators (such as testing posts and profile edits) out of the public activity streams.",
            "category": "users",
            "tags": [
                "buddypress",
                "activity",
                "admins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\nadd_filter( 'bp_after_has_activities_parse_args', function ( $args ) {\n    if ( bp_is_user() && bp_is_my_profile() ) {\n        return $args;\n    }\n\n    $admins = get_transient( 'wpide_bp_admin_ids' );\n\n    if ( false === $admins ) {\n        $admins = array_map( 'intval', get_users( array( 'role' => 'administrator', 'fields' => 'ID', 'number' => 200 ) ) );\n        set_transient( 'wpide_bp_admin_ids', $admins, HOUR_IN_SECONDS );\n    }\n\n    if ( $admins ) {\n        $query   = empty( $args['filter_query'] ) ? array() : (array) $args['filter_query'];\n        $query[] = array( 'column' => 'user_id', 'value' => $admins, 'compare' => 'NOT IN' );\n        $args['filter_query'] = $query;\n    }\n\n    return $args;\n} );\n\nadd_action( 'set_user_role', function () {\n    delete_transient( 'wpide_bp_admin_ids' );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "Administrators still see everything on their own profile. The ID list is cached for an hour."
        },
        {
            "id": "buddypress-profile-completion-reminder",
            "version": 1,
            "title": "BuddyPress: Remind Members to Complete Their Profile",
            "description": "Shows a friendly notice on BuddyPress pages to members who have not filled in important profile fields or uploaded a profile photo, with a link to edit their profile.",
            "category": "users",
            "tags": [
                "buddypress",
                "profile",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'buddypress' ) ) {\n    return;\n}\n\n// Profile field names that should be filled in.\n$wpide_bp_required_fields = array( 'Location', 'About me' );\n\nadd_action( 'bp_before_member_header', function () use ( $wpide_bp_required_fields ) {\n    $user_id = bp_loggedin_user_id();\n\n    if ( ! $user_id || ! bp_is_my_profile() || ! bp_is_active( 'xprofile' ) || bp_is_user_profile_edit() ) {\n        return;\n    }\n\n    $missing = get_transient( 'wpide_bp_missing_' . $user_id );\n\n    if ( false === $missing ) {\n        $missing = array();\n\n        foreach ( $wpide_bp_required_fields as $field ) {\n            if ( '' === trim( (string) xprofile_get_field_data( $field, $user_id, 'comma' ) ) ) {\n                $missing[] = $field;\n            }\n        }\n\n        if ( ! bp_get_user_has_avatar( $user_id ) ) {\n            $missing[] = 'profile photo';\n        }\n\n        set_transient( 'wpide_bp_missing_' . $user_id, $missing, HOUR_IN_SECONDS );\n    }\n\n    if ( $missing ) {\n        printf(\n            '<div class=\"bp-feedback info wpide-bp-complete\"><span class=\"bp-icon\" aria-hidden=\"true\"></span><p>%1$s <a href=\"%2$s\">Complete your profile</a></p></div>',\n            esc_html( sprintf( 'Your profile is missing: %s.', implode( ', ', $missing ) ) ),\n            esc_url( trailingslashit( function_exists( 'bp_members_get_user_url' ) ? bp_members_get_user_url( $user_id ) : bp_core_get_user_domain( $user_id ) ) . 'profile/edit/' )\n        );\n    }\n} );\n\nadd_action( 'xprofile_updated_profile', function ( $user_id ) {\n    delete_transient( 'wpide_bp_missing_' . (int) $user_id );\n} );\nadd_action( 'bp_members_avatar_uploaded', function ( $item_id ) {\n    delete_transient( 'wpide_bp_missing_' . (int) $item_id );\n} );",
            "requires": [
                "buddypress"
            ],
            "notes": "List the field names to check in $wpide_bp_required_fields. The result is cached per member for an hour."
        },
        {
            "id": "bbpress-moderate-new-members-posts",
            "version": 1,
            "title": "bbPress: Hold Posts From New Members for Moderation",
            "description": "Puts topics and replies from members who joined recently, or who have few approved posts, into the pending queue until a moderator approves them.",
            "category": "users",
            "tags": [
                "bbpress",
                "moderation",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_pending_status_id' ) ) {\n    return;\n}\n\n// Hold posts until the member is this many days old or has this many approved replies.\n$wpide_bbp_trust_days    = 3;\n$wpide_bbp_trust_replies = 2;\n\n$wpide_bbp_hold = function ( $data ) use ( $wpide_bbp_trust_days, $wpide_bbp_trust_replies ) {\n    $author = isset( $data['post_author'] ) ? (int) $data['post_author'] : 0;\n\n    if ( ! $author || user_can( $author, 'moderate' ) ) {\n        return $data;\n    }\n\n    $user = get_userdata( $author );\n\n    if ( ! $user || time() - strtotime( $user->user_registered . ' UTC' ) > $wpide_bbp_trust_days * DAY_IN_SECONDS ) {\n        return $data;\n    }\n\n    if ( (int) bbp_get_user_reply_count( $author, true ) >= $wpide_bbp_trust_replies ) {\n        return $data;\n    }\n\n    $data['post_status'] = bbp_get_pending_status_id();\n\n    return $data;\n};\n\nadd_filter( 'bbp_new_topic_pre_insert', $wpide_bbp_hold );\nadd_filter( 'bbp_new_reply_pre_insert', $wpide_bbp_hold );",
            "requires": [
                "bbpress"
            ],
            "notes": "Members are trusted once they pass either limit. Moderators and keymasters are never held."
        },
        {
            "id": "bbpress-block-links-new-members",
            "version": 1,
            "title": "bbPress: Block Links in Posts From New Members",
            "description": "Stops members who joined in the last few days from posting links in forum topics and replies, with a clear message explaining why.",
            "category": "users",
            "tags": [
                "bbpress",
                "spam",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_add_error' ) ) {\n    return;\n}\n\n$wpide_bbp_link_days = 7;\n\n$wpide_bbp_check_links = function ( $content ) use ( $wpide_bbp_link_days ) {\n    $user = wp_get_current_user();\n\n    if ( ! $user->exists() || current_user_can( 'moderate' ) ) {\n        return $content;\n    }\n\n    $is_new = time() - strtotime( $user->user_registered . ' UTC' ) < $wpide_bbp_link_days * DAY_IN_SECONDS;\n\n    if ( $is_new && preg_match( '#(https?://|www\\.|<a\\s)#i', $content ) ) {\n        bbp_add_error( 'wpide_bbp_links', sprintf( '<strong>Error:</strong> New members can post links after %d days. Please remove the link and try again.', $wpide_bbp_link_days ) );\n    }\n\n    return $content;\n};\n\nadd_filter( 'bbp_new_topic_pre_content', $wpide_bbp_check_links );\nadd_filter( 'bbp_new_reply_pre_content', $wpide_bbp_check_links );",
            "requires": [
                "bbpress"
            ],
            "notes": "Moderators and keymasters can always post links."
        },
        {
            "id": "bbpress-blocked-words",
            "version": 1,
            "title": "bbPress: Reject Posts With Blocked Words",
            "description": "Refuses forum topics and replies that contain words or phrases from your blocklist, showing the member a polite error instead of publishing them.",
            "category": "users",
            "tags": [
                "bbpress",
                "moderation",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_add_error' ) ) {\n    return;\n}\n\n// Words or phrases to block.\n$wpide_bbp_blocked = array( 'casino', 'cheap pills', 'crypto giveaway' );\n\n$wpide_bbp_scan = function ( $text ) use ( $wpide_bbp_blocked ) {\n    if ( current_user_can( 'moderate' ) ) {\n        return $text;\n    }\n\n    $plain = wp_strip_all_tags( (string) $text );\n\n    foreach ( $wpide_bbp_blocked as $word ) {\n        if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $plain ) ) {\n            bbp_add_error( 'wpide_bbp_blocked', '<strong>Error:</strong> Your post contains words that are not allowed in these forums.' );\n            break;\n        }\n    }\n\n    return $text;\n};\n\nadd_filter( 'bbp_new_topic_pre_title', $wpide_bbp_scan );\nadd_filter( 'bbp_new_topic_pre_content', $wpide_bbp_scan );\nadd_filter( 'bbp_new_reply_pre_content', $wpide_bbp_scan );",
            "requires": [
                "bbpress"
            ],
            "notes": "Matching ignores case and checks both the title and the content. Moderators are not checked."
        },
        {
            "id": "bbpress-email-admin-new-topic",
            "version": 1,
            "title": "bbPress: Email the Admin About New Topics",
            "description": "Sends the site admin (or a moderator address) an email with the title, author and link whenever a new forum topic is posted.",
            "category": "users",
            "tags": [
                "bbpress",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_topic_permalink' ) ) {\n    return;\n}\n\n$wpide_bbp_notify_to     = get_option( 'admin_email' );\n$wpide_bbp_notify_forums = array();\n\nadd_action( 'bbp_new_topic', function ( $topic_id, $forum_id ) use ( $wpide_bbp_notify_to, $wpide_bbp_notify_forums ) {\n    if ( $wpide_bbp_notify_forums && ! in_array( (int) $forum_id, $wpide_bbp_notify_forums, true ) ) {\n        return;\n    }\n\n    wp_mail(\n        $wpide_bbp_notify_to,\n        sprintf( 'New topic in %s: %s', bbp_get_forum_title( $forum_id ), bbp_get_topic_title( $topic_id ) ),\n        sprintf(\n            \"%s started a new topic.\\n\\n%s\\n\\n%s\",\n            bbp_get_topic_author_display_name( $topic_id ),\n            wp_trim_words( wp_strip_all_tags( bbp_get_topic_content( $topic_id ) ), 60 ),\n            bbp_get_topic_permalink( $topic_id )\n        )\n    );\n}, 10, 2 );",
            "requires": [
                "bbpress"
            ],
            "notes": "Limit it to some forums with $wpide_bbp_notify_forums; leave empty for all forums."
        },
        {
            "id": "bbpress-forums-for-members-only",
            "version": 1,
            "title": "bbPress: Forums for Logged-In Members Only",
            "description": "Sends logged-out visitors who open any forum, topic or forum profile to the login screen, making the whole bbPress forum private.",
            "category": "users",
            "tags": [
                "bbpress",
                "private",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'is_bbpress' ) ) {\n    return;\n}\n\nadd_action( 'template_redirect', function () {\n    if ( is_user_logged_in() || ! is_bbpress() ) {\n        return;\n    }\n\n    wp_safe_redirect( wp_login_url( home_url( add_query_arg( array() ) ) ) );\n    exit;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_user_logged_in() || ! $query->is_feed() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    $types = (array) $query->get( 'post_type' );\n\n    if ( array_intersect( $types, array( bbp_get_forum_post_type(), bbp_get_topic_post_type(), bbp_get_reply_post_type() ) ) ) {\n        $query->set( 'post__in', array( 0 ) );\n    }\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Forum content is also kept out of feeds for visitors."
        },
        {
            "id": "bbpress-forums-by-role",
            "version": 1,
            "title": "bbPress: Limit Certain Forums to Specific Roles",
            "description": "Lets only chosen roles open particular forums and their topics, for example a \"Staff\" forum for editors or a \"VIP\" forum for paying members.",
            "category": "users",
            "tags": [
                "bbpress",
                "roles",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_forum_id' ) ) {\n    return;\n}\n\n// Forum ID => roles allowed in it.\n$wpide_bbp_forum_roles = array(\n    123 => array( 'editor', 'administrator' ),\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_bbp_forum_roles ) {\n    if ( ! is_bbpress() || current_user_can( 'keep_gate' ) ) {\n        return;\n    }\n\n    if ( bbp_is_single_topic() ) {\n        $forum_id = (int) bbp_get_topic_forum_id( bbp_get_topic_id() );\n    } elseif ( bbp_is_single_forum() ) {\n        $forum_id = (int) bbp_get_forum_id();\n    } else {\n        return;\n    }\n\n    // Check the forum and its parent forums.\n    $chain = array_merge( array( $forum_id ), array_map( 'intval', get_post_ancestors( $forum_id ) ) );\n\n    foreach ( $chain as $id ) {\n        if ( empty( $wpide_bbp_forum_roles[ $id ] ) ) {\n            continue;\n        }\n\n        if ( ! is_user_logged_in() ) {\n            wp_safe_redirect( wp_login_url( get_permalink( $id ) ) );\n            exit;\n        }\n\n        if ( ! array_intersect( $wpide_bbp_forum_roles[ $id ], (array) wp_get_current_user()->roles ) ) {\n            wp_safe_redirect( bbp_get_forums_url() );\n            exit;\n        }\n    }\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Other visitors are sent to the login screen (if logged out) or the forum index. Keymasters always have access."
        },
        {
            "id": "bbpress-rename-forum-roles",
            "version": 1,
            "title": "bbPress: Rename Forum Roles",
            "description": "Changes the names of bbPress forum roles shown next to members' posts, for example \"Participant\" to \"Member\" and \"Keymaster\" to \"Admin\".",
            "category": "users",
            "tags": [
                "bbpress",
                "roles",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_dynamic_roles' ) ) {\n    return;\n}\n\n// bbPress role => new name.\n$wpide_bbp_role_names = array(\n    'bbp_keymaster'   => 'Admin',\n    'bbp_moderator'   => 'Moderator',\n    'bbp_participant' => 'Member',\n    'bbp_spectator'   => 'Reader',\n);\n\nadd_filter( 'bbp_get_dynamic_roles', function ( $roles ) use ( $wpide_bbp_role_names ) {\n    foreach ( $wpide_bbp_role_names as $role => $name ) {\n        if ( isset( $roles[ $role ] ) ) {\n            $roles[ $role ]['name'] = $name;\n        }\n    }\n\n    return $roles;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Only the labels change; capabilities stay the same."
        },
        {
            "id": "bbpress-topics-replies-per-page",
            "version": 1,
            "title": "bbPress: Topics and Replies per Page",
            "description": "Changes how many topics a forum page lists and how many replies a topic page shows, independent of the bbPress settings screen.",
            "category": "users",
            "tags": [
                "bbpress",
                "pagination",
                "forums"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_topics_per_page' ) ) {\n    return;\n}\n\n$wpide_bbp_topics_per_page  = 30;\n$wpide_bbp_replies_per_page = 25;\n\nadd_filter( 'bbp_get_topics_per_page', function () use ( $wpide_bbp_topics_per_page ) {\n    return $wpide_bbp_topics_per_page;\n} );\n\nadd_filter( 'bbp_get_replies_per_page', function () use ( $wpide_bbp_replies_per_page ) {\n    return $wpide_bbp_replies_per_page;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Large numbers make pages heavier; 20 to 50 is a good range."
        },
        {
            "id": "bbpress-hide-breadcrumbs-and-notices",
            "version": 1,
            "title": "bbPress: Hide Breadcrumbs and Forum Info Notices",
            "description": "Removes the bbPress breadcrumb trail and the grey \"This forum has X topics\" info boxes for a cleaner forum layout.",
            "category": "users",
            "tags": [
                "bbpress",
                "layout",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'is_bbpress' ) ) {\n    return;\n}\n\n$wpide_bbp_hide_breadcrumb = true;\n$wpide_bbp_hide_info       = true;\n\nif ( $wpide_bbp_hide_breadcrumb ) {\n    add_filter( 'bbp_no_breadcrumb', '__return_true' );\n}\n\nif ( $wpide_bbp_hide_info ) {\n    add_filter( 'bbp_get_single_forum_description', '__return_empty_string' );\n    add_filter( 'bbp_get_single_topic_description', '__return_empty_string' );\n}",
            "requires": [
                "bbpress"
            ],
            "notes": "Set either option to false to keep that element."
        },
        {
            "id": "bbpress-author-post-count-join-date",
            "version": 1,
            "title": "bbPress: Post Count and Join Date Under Forum Authors",
            "description": "Shows how many posts each member has made and when they joined under their name in forum topics and replies.",
            "category": "users",
            "tags": [
                "bbpress",
                "profile",
                "forums"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_reply_author_id' ) ) {\n    return;\n}\n\nadd_action( 'bbp_theme_after_reply_author_details', function () {\n    $user_id = (int) bbp_get_reply_author_id();\n\n    if ( ! $user_id ) {\n        return;\n    }\n\n    $user  = get_userdata( $user_id );\n    $posts = (int) bbp_get_user_topic_count( $user_id, true ) + (int) bbp_get_user_reply_count( $user_id, true );\n\n    printf(\n        '<div class=\"wpide-bbp-author-meta\" style=\"font-size:.85em;margin-top:.3em\">%1$s<br>%2$s</div>',\n        esc_html( sprintf( _n( '%s post', '%s posts', $posts ), number_format_i18n( $posts ) ) ),\n        $user ? esc_html( sprintf( 'Joined %s', wp_date( 'M Y', strtotime( $user->user_registered . ' UTC' ) ) ) ) : ''\n    );\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Counts come from bbPress's own cached totals, so this adds no heavy queries."
        },
        {
            "id": "bbpress-minimum-post-length",
            "version": 1,
            "title": "bbPress: Minimum Length for Topics and Replies",
            "description": "Refuses very short forum posts such as \"thanks\" or \"+1\" by requiring a minimum number of characters in topics and replies.",
            "category": "users",
            "tags": [
                "bbpress",
                "moderation",
                "quality"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_add_error' ) ) {\n    return;\n}\n\n$wpide_bbp_min_topic = 40;\n$wpide_bbp_min_reply = 10;\n\nadd_filter( 'bbp_new_topic_pre_content', function ( $content ) use ( $wpide_bbp_min_topic ) {\n    if ( mb_strlen( trim( wp_strip_all_tags( (string) $content ) ) ) < $wpide_bbp_min_topic ) {\n        bbp_add_error( 'wpide_bbp_short', sprintf( '<strong>Error:</strong> Please describe your topic in at least %d characters.', $wpide_bbp_min_topic ) );\n    }\n\n    return $content;\n} );\n\nadd_filter( 'bbp_new_reply_pre_content', function ( $content ) use ( $wpide_bbp_min_reply ) {\n    if ( mb_strlen( trim( wp_strip_all_tags( (string) $content ) ) ) < $wpide_bbp_min_reply ) {\n        bbp_add_error( 'wpide_bbp_short', sprintf( '<strong>Error:</strong> Replies need at least %d characters.', $wpide_bbp_min_reply ) );\n    }\n\n    return $content;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Set separate limits for topics and replies at the top."
        },
        {
            "id": "bbpress-topic-title-length",
            "version": 1,
            "title": "bbPress: Change the Maximum Topic Title Length",
            "description": "Raises or lowers the maximum number of characters allowed in bbPress topic titles (80 by default).",
            "category": "users",
            "tags": [
                "bbpress",
                "topics",
                "forums"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_title_max_length' ) ) {\n    return;\n}\n\n$wpide_bbp_title_length = 120;\n\nadd_filter( 'bbp_get_title_max_length', function () use ( $wpide_bbp_title_length ) {\n    return $wpide_bbp_title_length;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Very long titles can break forum layouts on phones; 100 to 120 is a sensible maximum."
        },
        {
            "id": "bbpress-lock-inactive-topics",
            "version": 1,
            "title": "bbPress: Stop Replies to Old, Inactive Topics",
            "description": "Hides the reply form on topics with no activity for a set number of days and shows a note instead, which prevents necro-posting and old-thread spam.",
            "category": "users",
            "tags": [
                "bbpress",
                "topics",
                "spam"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_topic_id' ) ) {\n    return;\n}\n\n// Days without activity before replies stop.\n$wpide_bbp_lock_days = 180;\n\n$wpide_bbp_is_stale = function ( $topic_id ) use ( $wpide_bbp_lock_days ) {\n    $last = get_post_meta( $topic_id, '_bbp_last_active_time', true );\n\n    return $last && time() - strtotime( $last . ' UTC' ) > $wpide_bbp_lock_days * DAY_IN_SECONDS;\n};\n\nadd_filter( 'bbp_current_user_can_access_create_reply_form', function ( $can ) use ( $wpide_bbp_is_stale ) {\n    if ( ! $can || current_user_can( 'moderate' ) || ! bbp_is_single_topic() ) {\n        return $can;\n    }\n\n    return $wpide_bbp_is_stale( bbp_get_topic_id() ) ? false : $can;\n} );\n\nadd_action( 'bbp_template_after_single_topic', function () use ( $wpide_bbp_is_stale ) {\n    if ( ! current_user_can( 'moderate' ) && $wpide_bbp_is_stale( bbp_get_topic_id() ) ) {\n        echo '<div class=\"bbp-template-notice\"><ul><li>This topic has been quiet for a long time, so replies are closed. Feel free to start a new topic.</li></ul></div>';\n    }\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Topics are not changed in the database; the reply form simply stops showing. Moderators can still reply."
        },
        {
            "id": "bbpress-subscribe-by-default",
            "version": 1,
            "title": "bbPress: Tick \"Notify Me of Follow-Up Replies\" by Default",
            "description": "Pre-ticks the subscription checkbox on new topics and replies, so members get email notifications about their own discussions unless they opt out.",
            "category": "users",
            "tags": [
                "bbpress",
                "subscriptions",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_is_subscriptions_active' ) ) {\n    return;\n}\n\nadd_filter( 'bbp_get_form_topic_subscribed', function ( $checked ) {\n    // Only pre-tick when creating, not when editing an existing post.\n    if ( bbp_is_topic_edit() || bbp_is_reply_edit() ) {\n        return $checked;\n    }\n\n    return checked( true, true, false );\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Members can still untick the box; existing subscriptions are not changed."
        },
        {
            "id": "bbpress-notify-moderators-pending",
            "version": 1,
            "title": "bbPress: Email Moderators About Posts Waiting for Approval",
            "description": "Emails forum moderators and keymasters when a topic or reply lands in the pending queue (for example from Akismet or moderation rules), so nothing waits for days.",
            "category": "users",
            "tags": [
                "bbpress",
                "moderation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_pending_status_id' ) ) {\n    return;\n}\n\n$wpide_bbp_pending_alert = function ( $post_id ) {\n    if ( bbp_get_pending_status_id() !== get_post_status( $post_id ) || get_transient( 'wpide_bbp_pending_alert' ) ) {\n        return;\n    }\n\n    set_transient( 'wpide_bbp_pending_alert', 1, 15 * MINUTE_IN_SECONDS );\n\n    $emails = get_users( array( 'role__in' => array( 'bbp_keymaster', 'bbp_moderator' ), 'fields' => array( 'user_email' ), 'number' => 20 ) );\n    $emails = wp_list_pluck( $emails, 'user_email' );\n\n    if ( ! $emails ) {\n        $emails = array( get_option( 'admin_email' ) );\n    }\n\n    wp_mail(\n        $emails,\n        'Forum posts are waiting for approval',\n        sprintf( \"A new forum post is waiting for moderation:\\n%s\\n\\nAll pending topics: %s\", get_permalink( $post_id ), admin_url( 'edit.php?post_type=' . bbp_get_topic_post_type() . '&post_status=' . bbp_get_pending_status_id() ) )\n    );\n};\n\nadd_action( 'bbp_new_topic', $wpide_bbp_pending_alert );\nadd_action( 'bbp_new_reply', $wpide_bbp_pending_alert );",
            "requires": [
                "bbpress"
            ],
            "notes": "Sends at most one email every 15 minutes to avoid floods during spam waves."
        },
        {
            "id": "bbpress-exclude-from-site-search",
            "version": 1,
            "title": "bbPress: Keep Forum Posts Out of Site Search",
            "description": "Removes forums, topics and replies from your main WordPress search results so blog searches stay focused; bbPress's own forum search still works.",
            "category": "users",
            "tags": [
                "bbpress",
                "search",
                "forums"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_topic_post_type' ) ) {\n    return;\n}\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || bbp_is_search() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( ! $types || 'any' === $types ) {\n        $types = get_post_types( array( 'exclude_from_search' => false ) );\n    }\n\n    $query->set( 'post_type', array_values( array_diff( (array) $types, array( bbp_get_forum_post_type(), bbp_get_topic_post_type(), bbp_get_reply_post_type() ) ) ) );\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "The forum search box (bbPress search) is not affected."
        },
        {
            "id": "bbpress-noindex-profiles",
            "version": 1,
            "title": "bbPress: Stop Search Engines Indexing Forum Profiles",
            "description": "Adds noindex to bbPress user profile pages, which are thin, duplicate content and a common target for spam sign-ups trying to get links indexed.",
            "category": "users",
            "tags": [
                "bbpress",
                "seo",
                "profiles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_is_single_user' ) ) {\n    return;\n}\n\nadd_filter( 'wp_robots', function ( $robots ) {\n    if ( bbp_is_single_user() ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Forums and topics stay indexable."
        },
        {
            "id": "bbpress-allow-code-tags",
            "version": 1,
            "title": "bbPress: Allow Headings and Code Blocks in Posts",
            "description": "Lets forum members use more formatting in topics and replies, such as headings, preformatted code blocks and tables, which is handy for technical forums.",
            "category": "users",
            "tags": [
                "bbpress",
                "formatting",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_kses_allowed_tags' ) ) {\n    return;\n}\n\nadd_filter( 'bbp_kses_allowed_tags', function ( $tags ) {\n    foreach ( array( 'h3', 'h4', 'pre', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'kbd', 'hr' ) as $tag ) {\n        if ( ! isset( $tags[ $tag ] ) ) {\n            $tags[ $tag ] = array();\n        }\n    }\n\n    $tags['code'] = array( 'class' => true );\n\n    return $tags;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Only harmless formatting tags are added; no scripts, styles or attributes that could be abused."
        },
        {
            "id": "bbpress-mark-topic-solved",
            "version": 1,
            "title": "bbPress: \"Mark as Solved\" for Support Topics",
            "description": "Lets topic authors and moderators mark a topic as solved with one click; solved topics get a \"[Solved]\" label in their title so others find answers quickly.",
            "category": "users",
            "tags": [
                "bbpress",
                "support",
                "topics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_topic_id' ) ) {\n    return;\n}\n\nadd_action( 'bbp_template_before_single_topic', function () {\n    $topic_id = (int) bbp_get_topic_id();\n\n    if ( ! $topic_id || ! is_user_logged_in() ) {\n        return;\n    }\n\n    if ( get_current_user_id() !== (int) bbp_get_topic_author_id( $topic_id ) && ! current_user_can( 'moderate', $topic_id ) ) {\n        return;\n    }\n\n    $solved = (bool) get_post_meta( $topic_id, '_wpide_solved', true );\n    $url    = wp_nonce_url( add_query_arg( array( 'wpide_solved' => $topic_id ), get_permalink( $topic_id ) ), 'wpide_solved_' . $topic_id );\n\n    printf( '<p class=\"wpide-bbp-solved\"><a class=\"button\" href=\"%1$s\">%2$s</a></p>', esc_url( $url ), esc_html( $solved ? 'Mark as unsolved' : 'Mark as solved' ) );\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_GET['wpide_solved'] ) || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $topic_id = absint( $_GET['wpide_solved'] );\n\n    if ( ! isset( $_GET['_wpnonce'] ) || ! wp_verify_nonce( sanitize_key( $_GET['_wpnonce'] ), 'wpide_solved_' . $topic_id ) ) {\n        return;\n    }\n\n    if ( bbp_get_topic_post_type() !== get_post_type( $topic_id ) || ( get_current_user_id() !== (int) bbp_get_topic_author_id( $topic_id ) && ! current_user_can( 'moderate', $topic_id ) ) ) {\n        return;\n    }\n\n    if ( get_post_meta( $topic_id, '_wpide_solved', true ) ) {\n        delete_post_meta( $topic_id, '_wpide_solved' );\n    } else {\n        update_post_meta( $topic_id, '_wpide_solved', 1 );\n    }\n\n    wp_safe_redirect( get_permalink( $topic_id ) );\n    exit;\n} );\n\nadd_filter( 'bbp_get_topic_title', function ( $title, $topic_id ) {\n    return get_post_meta( $topic_id, '_wpide_solved', true ) ? '[Solved] ' . $title : $title;\n}, 10, 2 );",
            "requires": [
                "bbpress"
            ],
            "notes": "The button appears above the topic for its author and moderators. Clicking again marks the topic as unsolved."
        },
        {
            "id": "bbpress-announcements-forum-staff-only",
            "version": 1,
            "title": "bbPress: Only Staff Can Start Topics in Announcement Forums",
            "description": "Stops regular members from creating new topics in chosen forums, such as Announcements, while still letting them reply.",
            "category": "users",
            "tags": [
                "bbpress",
                "forums",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_get_forum_id' ) ) {\n    return;\n}\n\n// Forum IDs where only staff start topics.\n$wpide_bbp_staff_forums  = array( 123 );\n$wpide_bbp_allow_replies = true;\n\nadd_filter( 'bbp_current_user_can_access_create_topic_form', function ( $can ) use ( $wpide_bbp_staff_forums ) {\n    if ( current_user_can( 'moderate' ) ) {\n        return $can;\n    }\n\n    return in_array( (int) bbp_get_forum_id(), $wpide_bbp_staff_forums, true ) ? false : $can;\n} );\n\nadd_filter( 'bbp_current_user_can_access_create_reply_form', function ( $can ) use ( $wpide_bbp_staff_forums, $wpide_bbp_allow_replies ) {\n    if ( $wpide_bbp_allow_replies || current_user_can( 'moderate' ) ) {\n        return $can;\n    }\n\n    return in_array( (int) bbp_get_topic_forum_id( bbp_get_topic_id() ), $wpide_bbp_staff_forums, true ) ? false : $can;\n} );\n\n// Also refuse topics posted to those forums directly.\nadd_filter( 'bbp_new_topic_pre_content', function ( $content ) use ( $wpide_bbp_staff_forums ) {\n    $forum_id = isset( $_POST['bbp_forum_id'] ) ? absint( $_POST['bbp_forum_id'] ) : 0;\n\n    if ( ! current_user_can( 'moderate' ) && in_array( $forum_id, $wpide_bbp_staff_forums, true ) ) {\n        bbp_add_error( 'wpide_bbp_staff_only', '<strong>Error:</strong> Only staff can start topics in this forum.' );\n    }\n\n    return $content;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Staff means anyone with bbPress moderator rights. Set $wpide_bbp_allow_replies to false to block replies too."
        },
        {
            "id": "bbpress-topic-flood-limit",
            "version": 1,
            "title": "bbPress: Limit New Topics per Member per Day",
            "description": "Caps how many new topics a member can start in 24 hours, which stops spammers and over-eager posters from flooding the forums.",
            "category": "users",
            "tags": [
                "bbpress",
                "spam",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_add_error' ) ) {\n    return;\n}\n\n$wpide_bbp_topics_per_day = 5;\n\nadd_filter( 'bbp_new_topic_pre_content', function ( $content ) use ( $wpide_bbp_topics_per_day ) {\n    $user_id = get_current_user_id();\n\n    if ( ! $user_id || current_user_can( 'moderate' ) ) {\n        return $content;\n    }\n\n    $recent = get_posts( array(\n        'post_type'     => bbp_get_topic_post_type(),\n        'post_status'   => 'any',\n        'author'        => $user_id,\n        'date_query'    => array( array( 'after' => '24 hours ago', 'column' => 'post_date_gmt' ) ),\n        'numberposts'   => $wpide_bbp_topics_per_day,\n        'fields'        => 'ids',\n        'no_found_rows' => true,\n    ) );\n\n    if ( count( $recent ) >= $wpide_bbp_topics_per_day ) {\n        bbp_add_error( 'wpide_bbp_flood', sprintf( '<strong>Error:</strong> You can start up to %d topics a day. Please try again tomorrow.', $wpide_bbp_topics_per_day ) );\n    }\n\n    return $content;\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Replies are not limited. Moderators are exempt."
        },
        {
            "id": "bbpress-forum-index-notice",
            "version": 1,
            "title": "bbPress: Welcome Notice Above the Forum Index",
            "description": "Shows a short welcome or rules notice above the list of forums, with a link to your community guidelines.",
            "category": "users",
            "tags": [
                "bbpress",
                "forums",
                "notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'is_bbpress' ) ) {\n    return;\n}\n\n$wpide_bbp_notice_text = 'Welcome to our community! Please be kind, search before posting, and read the guidelines.';\n$wpide_bbp_notice_link = home_url( '/community-guidelines/' );\n\nadd_action( 'bbp_template_before_forums_index', function () use ( $wpide_bbp_notice_text, $wpide_bbp_notice_link ) {\n    printf(\n        '<div class=\"bbp-template-notice info wpide-bbp-welcome\" role=\"note\"><ul><li>%1$s %2$s</li></ul></div>',\n        esc_html( $wpide_bbp_notice_text ),\n        $wpide_bbp_notice_link ? '<a href=\"' . esc_url( $wpide_bbp_notice_link ) . '\">Read the guidelines</a>' : ''\n    );\n} );",
            "requires": [
                "bbpress"
            ],
            "notes": "Edit the text and link at the top; leave the link empty to show the text only."
        },
        {
            "id": "bbpress-disable-topic-tags",
            "version": 1,
            "title": "bbPress: Turn Off Topic Tags",
            "description": "Removes the topic tags field from the new topic form and hides tag clouds and tag lists, for forums that don't use tags.",
            "category": "users",
            "tags": [
                "bbpress",
                "tags",
                "forums"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'bbp_allow_topic_tags' ) ) {\n    return;\n}\n\nadd_filter( 'bbp_allow_topic_tags', '__return_false' );",
            "requires": [
                "bbpress"
            ],
            "notes": "Existing tags stay in the database and come back if you remove the snippet."
        },
        {
            "id": "memberpress-login-redirect-by-membership",
            "version": 1,
            "title": "MemberPress: Different Login Destinations per Membership",
            "description": "Sends members to a different page after logging in depending on the membership they hold, for example Gold members to the Gold dashboard.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\n// Membership ID => URL, highest tier first.\n$wpide_mepr_login_pages = array(\n    456 => home_url( '/gold-dashboard/' ),\n    123 => home_url( '/members/' ),\n);\n\n$wpide_mepr_login_target = function ( $url, $user ) use ( $wpide_mepr_login_pages ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $url;\n    }\n\n    $member = new MeprUser( $user->ID );\n\n    foreach ( $wpide_mepr_login_pages as $membership_id => $page ) {\n        if ( $member->is_already_subscribed_to( $membership_id ) ) {\n            return $page;\n        }\n    }\n\n    return $url;\n};\n\nadd_filter( 'mepr-process-login-redirect-url', $wpide_mepr_login_target, 10, 2 );\nadd_filter( 'login_redirect', function ( $url, $requested, $user ) use ( $wpide_mepr_login_target ) {\n    return $wpide_mepr_login_target( $url, $user );\n}, 20, 3 );",
            "requires": [
                "memberpress"
            ],
            "notes": "Memberships are checked in the order listed, so put your highest tier first. Works with the MemberPress login form and wp-login.php."
        },
        {
            "id": "memberpress-my-memberships-shortcode",
            "version": 1,
            "title": "MemberPress: List the Member's Active Memberships",
            "description": "Adds a [wpide_mepr_memberships] shortcode that lists the logged-in member's active memberships by name, for welcome boxes and account pages.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "shortcode",
                "account"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_mepr_memberships', function ( $atts ) {\n    $atts = shortcode_atts( array( 'none' => 'You do not have an active membership.' ), $atts, 'wpide_mepr_memberships' );\n\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $member = new MeprUser( get_current_user_id() );\n    $ids    = array_unique( array_map( 'intval', (array) $member->active_product_subscriptions( 'ids' ) ) );\n\n    if ( ! $ids ) {\n        return '<p class=\"wpide-mepr-none\">' . esc_html( $atts['none'] ) . '</p>';\n    }\n\n    $html = '<ul class=\"wpide-mepr-memberships\">';\n    foreach ( $ids as $id ) {\n        $html .= '<li>' . esc_html( get_the_title( $id ) ) . '</li>';\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Shows the optional none=\"...\" text when the member has no active membership."
        },
        {
            "id": "memberpress-membership-role-sync",
            "version": 1,
            "title": "MemberPress: Add a Role While a Membership Is Active",
            "description": "Gives members an extra WordPress role when they buy a membership and removes it when that membership expires, so role-based plugins can follow MemberPress.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "roles",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\n// Membership ID => role.\n$wpide_mepr_roles = array(\n    123 => 'contributor',\n);\n\nadd_action( 'mepr-event-transaction-completed', function ( $event ) use ( $wpide_mepr_roles ) {\n    $txn  = $event->get_data();\n    $role = $wpide_mepr_roles[ (int) $txn->product_id ] ?? '';\n    $user = get_userdata( (int) $txn->user_id );\n\n    if ( $role && $user && get_role( $role ) ) {\n        $user->add_role( $role );\n    }\n} );\n\nadd_action( 'mepr-event-transaction-expired', function ( $event ) use ( $wpide_mepr_roles ) {\n    $txn  = $event->get_data();\n    $role = $wpide_mepr_roles[ (int) $txn->product_id ] ?? '';\n    $user = get_userdata( (int) $txn->user_id );\n\n    if ( ! $role || ! $user || count( $user->roles ) < 2 ) {\n        return;\n    }\n\n    $member = new MeprUser( $user->ID );\n\n    if ( ! $member->is_already_subscribed_to( (int) $txn->product_id ) ) {\n        $user->remove_role( $role );\n    }\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "The role must exist. It is only removed when the member no longer has any active transaction for that membership."
        },
        {
            "id": "memberpress-email-admin-on-expiry",
            "version": 1,
            "title": "MemberPress: Email the Admin When a Membership Expires",
            "description": "Sends you an email when a member's MemberPress transaction expires, so you can reach out personally before they forget about you.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "expiry",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_action( 'mepr-event-transaction-expired', function ( $event ) {\n    $txn  = $event->get_data();\n    $user = get_userdata( (int) $txn->user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $member = new MeprUser( $user->ID );\n\n    if ( $member->is_already_subscribed_to( (int) $txn->product_id ) ) {\n        return; // Renewed or has another active transaction.\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'Membership expired: %s', $user->display_name ),\n        sprintf( \"%s (%s) no longer has an active \\\"%s\\\" membership.\\n\\nProfile: %s\", $user->display_name, $user->user_email, get_the_title( (int) $txn->product_id ), admin_url( 'user-edit.php?user_id=' . $user->ID ) )\n    );\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "MemberPress also fires this event for one-off transactions that reach their end date. Members who renewed in time are skipped."
        },
        {
            "id": "memberpress-account-custom-tab",
            "version": 1,
            "title": "MemberPress: Add a Custom Tab to the Account Page",
            "description": "Adds a new tab, such as \"Resources\" or \"Support\", to the MemberPress account page navigation with your own content.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "account",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\n$wpide_mepr_tab_slug    = 'resources';\n$wpide_mepr_tab_label   = 'Resources';\n$wpide_mepr_tab_content = '<h2>Member resources</h2><p>Download the latest guides and templates here.</p>';\n\nadd_action( 'mepr-account-nav', function () use ( $wpide_mepr_tab_slug, $wpide_mepr_tab_label ) {\n    $active = isset( $_GET['action'] ) && $wpide_mepr_tab_slug === sanitize_key( $_GET['action'] );\n\n    printf(\n        '<span class=\"mepr-nav-item wpide-mepr-%1$s%2$s\"><a href=\"%3$s\"%4$s>%5$s</a></span>',\n        esc_attr( $wpide_mepr_tab_slug ),\n        $active ? ' mepr-active-nav-tab' : '',\n        esc_url( add_query_arg( 'action', $wpide_mepr_tab_slug ) ),\n        $active ? ' aria-current=\"page\"' : '',\n        esc_html( $wpide_mepr_tab_label )\n    );\n} );\n\nadd_action( 'mepr-account-nav-content', function ( $action ) use ( $wpide_mepr_tab_slug, $wpide_mepr_tab_content ) {\n    if ( $wpide_mepr_tab_slug === $action ) {\n        echo '<div class=\"wpide-mepr-tab\">' . wp_kses_post( do_shortcode( $wpide_mepr_tab_content ) ) . '</div>';\n    }\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Put your content in $wpide_mepr_tab_content; shortcodes are allowed."
        },
        {
            "id": "memberpress-signup-email-domains",
            "version": 1,
            "title": "MemberPress: Only Allow Sign-Ups From Certain Email Domains",
            "description": "Rejects MemberPress registrations unless the email address belongs to one of your allowed domains, ideal for company or school memberships.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "registration",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\n$wpide_mepr_allowed_domains = array( 'example.com', 'example.edu' );\n\nadd_filter( 'mepr-validate-signup', function ( $errors ) use ( $wpide_mepr_allowed_domains ) {\n    if ( is_user_logged_in() || empty( $_POST['user_email'] ) ) {\n        return $errors;\n    }\n\n    $email  = sanitize_email( wp_unslash( $_POST['user_email'] ) );\n    $domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );\n\n    foreach ( $wpide_mepr_allowed_domains as $allowed ) {\n        if ( $domain === $allowed || substr( $domain, -strlen( '.' . $allowed ) ) === '.' . $allowed ) {\n            return $errors;\n        }\n    }\n\n    $errors[] = sprintf( 'Please sign up with your %s email address.', implode( ' or ', $wpide_mepr_allowed_domains ) );\n\n    return $errors;\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Existing members logging in are not affected. Subdomains count, so \"example.edu\" also allows \"mail.example.edu\"."
        },
        {
            "id": "memberpress-secure-checkout-note",
            "version": 1,
            "title": "MemberPress: Reassurance Note Above the Sign-Up Button",
            "description": "Shows a short \"secure checkout, cancel any time\" note with a padlock just above the MemberPress sign-up button to reduce hesitation.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "checkout",
                "conversion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "mepr-checkout-before-submit",
            "insert_number": 1,
            "code": "<p class=\"wpide-mepr-reassure\" style=\"display:flex;gap:.5em;align-items:center;font-size:.9em;margin:0 0 1em\">\n    <span aria-hidden=\"true\">&#128274;</span>\n    <span>Secure checkout. Cancel any time from your account page.</span>\n</p>",
            "requires": [
                "memberpress"
            ],
            "notes": "Edit the wording to match your refund and cancellation policy."
        },
        {
            "id": "memberpress-users-membership-column",
            "version": 1,
            "title": "MemberPress: Active Memberships Column in Users",
            "description": "Adds a \"Memberships\" column to the WordPress Users screen showing each user's active MemberPress memberships at a glance.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "users",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_mepr'] = 'Memberships';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_mepr' !== $column ) {\n        return $output;\n    }\n\n    $member = new MeprUser( $user_id );\n    $ids    = array_unique( array_map( 'intval', (array) $member->active_product_subscriptions( 'ids' ) ) );\n\n    if ( ! $ids ) {\n        return '&mdash;';\n    }\n\n    return esc_html( implode( ', ', array_map( 'get_the_title', $ids ) ) );\n}, 10, 3 );",
            "requires": [
                "memberpress"
            ],
            "notes": "Users without an active membership show a dash."
        },
        {
            "id": "memberpress-notify-admin-new-member",
            "version": 1,
            "title": "MemberPress: Email the Admin About New Members",
            "description": "Sends you a short email with the member's name, email and membership as soon as someone completes a MemberPress sign-up.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "email",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_action( 'mepr-event-transaction-completed', function ( $event ) {\n    $txn  = $event->get_data();\n    $user = get_userdata( (int) $txn->user_id );\n\n    // Only first purchases of a membership, not renewals.\n    if ( ! $user || get_user_meta( $user->ID, 'wpide_mepr_notified_' . (int) $txn->product_id, true ) ) {\n        return;\n    }\n\n    update_user_meta( $user->ID, 'wpide_mepr_notified_' . (int) $txn->product_id, time() );\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'New member: %s joined %s', $user->display_name, get_the_title( (int) $txn->product_id ) ),\n        sprintf( \"Name: %s\\nEmail: %s\\nMembership: %s\\nProfile: %s\", $user->display_name, $user->user_email, get_the_title( (int) $txn->product_id ), admin_url( 'user-edit.php?user_id=' . $user->ID ) )\n    );\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Complements MemberPress's own admin emails with a plain summary you can forward or filter."
        },
        {
            "id": "memberpress-menu-items-by-membership",
            "version": 1,
            "title": "MemberPress: Show Menu Items Only to Certain Memberships",
            "description": "Hides navigation menu items from people who don't hold a given MemberPress membership, using a CSS class like wpide-mepr-123 on the menu item.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "menus",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_filter( 'wp_nav_menu_objects', function ( $items ) {\n    $member  = is_user_logged_in() ? new MeprUser( get_current_user_id() ) : null;\n    $removed = array();\n\n    foreach ( $items as $key => $item ) {\n        $wanted = array();\n\n        foreach ( (array) $item->classes as $class ) {\n            if ( preg_match( '/^wpide-mepr-(\\d+)$/', $class, $m ) ) {\n                $wanted[] = (int) $m[1];\n            }\n        }\n\n        $hide = in_array( (int) $item->menu_item_parent, $removed, true );\n\n        if ( $wanted && ! current_user_can( 'manage_options' ) ) {\n            $allowed = false;\n\n            foreach ( $wanted as $membership_id ) {\n                if ( $member && $member->is_already_subscribed_to( $membership_id ) ) {\n                    $allowed = true;\n                    break;\n                }\n            }\n\n            $hide = $hide || ! $allowed;\n        }\n\n        if ( $hide ) {\n            $removed[] = (int) $item->ID;\n            unset( $items[ $key ] );\n        }\n    }\n\n    return $items;\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Enable \"CSS Classes\" under Screen Options in Appearance > Menus, then add wpide-mepr-{membership ID}. Several classes mean \"any of these\"."
        },
        {
            "id": "memberpress-hide-coupon-field",
            "version": 1,
            "title": "MemberPress: Hide the Coupon Field on Sign-Up Forms",
            "description": "Hides the coupon code field on MemberPress registration forms so visitors without a code don't leave to hunt for one; coupon links keep working.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "checkout",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".mepr-signup-form .mepr_coupon,\n.mepr-signup-form .mepr-coupon-code,\n.mepr-signup-form .have-coupon-link {\n    display: none !important;\n}",
            "requires": [
                "memberpress"
            ],
            "notes": "Coupons can still be applied with a link that adds ?coupon=CODE to the sign-up page."
        },
        {
            "id": "memberpress-unauthorized-join-links",
            "version": 1,
            "title": "MemberPress: Membership Options Under the Access-Denied Message",
            "description": "Lists your memberships with \"Join\" links right below the MemberPress unauthorized message, so visitors can upgrade without searching for the pricing page.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "conversion",
                "access"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "mepr_unauthorized_message_after",
            "insert_number": 1,
            "code": "<?php\n// Membership IDs to offer.\n$wpide_mepr_offer = array( 123, 456 );\n$wpide_mepr_links       = array();\n\nforeach ( $wpide_mepr_offer as $wpide_mepr_id ) {\n    if ( 'publish' === get_post_status( $wpide_mepr_id ) ) {\n        $wpide_mepr_links[] = '<li><a href=\"' . esc_url( get_permalink( $wpide_mepr_id ) ) . '\">' . esc_html( sprintf( 'Join %s', get_the_title( $wpide_mepr_id ) ) ) . '</a></li>';\n    }\n}\n\nif ( $wpide_mepr_links ) :\n    ?>\n    <div class=\"wpide-mepr-join\">\n        <p><strong>Choose a membership to unlock this content:</strong></p>\n        <ul><?php echo wp_kses_post( implode( '', $wpide_mepr_links ) ); ?></ul>\n    </div>\n    <?php\nendif;",
            "requires": [
                "memberpress"
            ],
            "notes": "Edit $wpide_mepr_offer with your membership IDs; links go to each membership's sign-up page."
        },
        {
            "id": "memberpress-membership-body-classes",
            "version": 1,
            "title": "MemberPress: Body Classes for Active Memberships",
            "description": "Adds body classes like wpide-mepr-member and wpide-mepr-123 for each active membership, so you can show, hide or restyle parts of your theme with CSS.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    if ( ! is_user_logged_in() ) {\n        $classes[] = 'wpide-mepr-non-member';\n        return $classes;\n    }\n\n    $member = new MeprUser( get_current_user_id() );\n    $ids    = array_unique( array_map( 'intval', (array) $member->active_product_subscriptions( 'ids' ) ) );\n\n    if ( ! $ids ) {\n        $classes[] = 'wpide-mepr-non-member';\n        return $classes;\n    }\n\n    $classes[] = 'wpide-mepr-member';\n\n    foreach ( $ids as $id ) {\n        $classes[] = 'wpide-mepr-' . $id;\n    }\n\n    return $classes;\n} );",
            "requires": [
                "memberpress"
            ],
            "notes": "Visitors without a membership get wpide-mepr-non-member. Use CSS for presentation only; protect real content with MemberPress rules."
        },
        {
            "id": "memberpress-learndash-course-access",
            "version": 1,
            "title": "MemberPress + LearnDash: Enroll Members in Courses",
            "description": "Gives MemberPress members access to the LearnDash courses tied to their membership when they pay, and removes it when the membership expires.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "learndash",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) || ! function_exists( 'ld_update_course_access' ) ) {\n    return;\n}\n\n// Membership ID => LearnDash course IDs.\n$wpide_mepr_ld_courses = array(\n    123 => array( 1001, 1002 ),\n);\n\nadd_action( 'mepr-event-transaction-completed', function ( $event ) use ( $wpide_mepr_ld_courses ) {\n    $txn     = $event->get_data();\n    $user_id = (int) $txn->user_id;\n    $granted = array_map( 'intval', (array) get_user_meta( $user_id, 'wpide_mepr_ld_granted', true ) );\n\n    foreach ( $wpide_mepr_ld_courses[ (int) $txn->product_id ] ?? array() as $course_id ) {\n        if ( ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n            ld_update_course_access( $user_id, (int) $course_id );\n            $granted[] = (int) $course_id;\n        }\n    }\n\n    update_user_meta( $user_id, 'wpide_mepr_ld_granted', array_values( array_unique( $granted ) ) );\n} );\n\nadd_action( 'mepr-event-transaction-expired', function ( $event ) use ( $wpide_mepr_ld_courses ) {\n    $txn     = $event->get_data();\n    $user_id = (int) $txn->user_id;\n    $member  = new MeprUser( $user_id );\n\n    if ( $member->is_already_subscribed_to( (int) $txn->product_id ) ) {\n        return;\n    }\n\n    // Courses still covered by another active membership stay.\n    $keep = array();\n    foreach ( array_map( 'intval', (array) $member->active_product_subscriptions( 'ids' ) ) as $active ) {\n        $keep = array_merge( $keep, array_map( 'intval', $wpide_mepr_ld_courses[ $active ] ?? array() ) );\n    }\n\n    // Only access this snippet granted is removed, never courses bought separately.\n    $granted = array_map( 'intval', (array) get_user_meta( $user_id, 'wpide_mepr_ld_granted', true ) );\n    $remove  = array_intersect( array_diff( array_map( 'intval', $wpide_mepr_ld_courses[ (int) $txn->product_id ] ?? array() ), $keep ), $granted );\n\n    foreach ( $remove as $course_id ) {\n        ld_update_course_access( $user_id, $course_id, true );\n    }\n\n    update_user_meta( $user_id, 'wpide_mepr_ld_granted', array_values( array_diff( $granted, $remove ) ) );\n} );",
            "requires": [
                "memberpress",
                "learndash"
            ],
            "notes": "Map membership IDs to course IDs. Only access granted by this snippet is removed, and only when no other active membership includes the course."
        },
        {
            "id": "memberpress-buddypress-group-join",
            "version": 1,
            "title": "MemberPress + BuddyPress: Add Members to a Group",
            "description": "Adds MemberPress members to a BuddyPress group for their membership when they pay, giving each tier its own private community space.",
            "category": "memberships",
            "tags": [
                "memberpress",
                "buddypress",
                "groups"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'MeprUser' ) || ! function_exists( 'groups_join_group' ) ) {\n    return;\n}\n\n// Membership ID => BuddyPress group ID.\n$wpide_mepr_bp_groups = array(\n    123 => 2,\n);\n\nadd_action( 'mepr-event-transaction-completed', function ( $event ) use ( $wpide_mepr_bp_groups ) {\n    $txn      = $event->get_data();\n    $group_id = $wpide_mepr_bp_groups[ (int) $txn->product_id ] ?? 0;\n\n    if ( $group_id && ! groups_is_user_member( (int) $txn->user_id, $group_id ) ) {\n        groups_join_group( $group_id, (int) $txn->user_id );\n    }\n} );",
            "requires": [
                "memberpress",
                "buddypress"
            ],
            "notes": "Map membership IDs to BuddyPress group IDs. Members are not removed from groups automatically when they leave."
        },
        {
            "id": "pmpro-login-redirect-by-level",
            "version": 1,
            "title": "Paid Memberships Pro: Login Destination per Level",
            "description": "Sends members to a different page after logging in depending on their membership level, for example Premium members to the premium library.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "login",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\n// Level ID => URL, highest level first.\n$wpide_pmpro_login_pages = array(\n    2 => home_url( '/premium/' ),\n    1 => home_url( '/members/' ),\n);\n\n$wpide_pmpro_login_target = function ( $url, $user ) use ( $wpide_pmpro_login_pages ) {\n    if ( ! $user instanceof WP_User || user_can( $user, 'edit_posts' ) ) {\n        return $url;\n    }\n\n    foreach ( $wpide_pmpro_login_pages as $level_id => $page ) {\n        if ( pmpro_hasMembershipLevel( $level_id, $user->ID ) ) {\n            return $page;\n        }\n    }\n\n    return $url;\n};\n\nadd_filter( 'pmpro_login_redirect_url', function ( $url, $request, $user ) use ( $wpide_pmpro_login_target ) {\n    return $wpide_pmpro_login_target( $url, $user );\n}, 10, 3 );\n\nadd_filter( 'login_redirect', function ( $url, $request, $user ) use ( $wpide_pmpro_login_target ) {\n    return $wpide_pmpro_login_target( $url, $user );\n}, 20, 3 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Levels are checked in the order listed. Members without a listed level keep the normal destination."
        },
        {
            "id": "pmpro-level-role-sync",
            "version": 1,
            "title": "Paid Memberships Pro: Give Each Level Its Own Role",
            "description": "Adds a WordPress role that matches the member's level when they join and removes it when they cancel, expire or change level.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "roles",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\n// Level ID => role.\n$wpide_pmpro_level_roles = array(\n    1 => 'contributor',\n    2 => 'author',\n);\n\nadd_action( 'pmpro_after_change_membership_level', function ( $level_id, $user_id ) use ( $wpide_pmpro_level_roles ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user || user_can( $user, 'manage_options' ) ) {\n        return;\n    }\n\n    foreach ( $wpide_pmpro_level_roles as $level => $role ) {\n        if ( ! get_role( $role ) ) {\n            continue;\n        }\n\n        if ( pmpro_hasMembershipLevel( $level, $user_id ) ) {\n            $user->add_role( $role );\n        } elseif ( in_array( $role, (array) $user->roles, true ) && count( $user->roles ) > 1 ) {\n            $user->remove_role( $role );\n        }\n    }\n}, 10, 2 );",
            "requires": [
                "pmpro"
            ],
            "notes": "The roles must exist. The member's main role is kept, and administrators are never changed."
        },
        {
            "id": "pmpro-expiration-date-shortcode",
            "version": 1,
            "title": "Paid Memberships Pro: Membership End Date Shortcode",
            "description": "Adds a [wpide_pmpro_expires] shortcode that shows when the member's current level ends, or \"never\" for levels without an end date.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "expiry",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getMembershipLevelForUser' ) ) {\n    return;\n}\n\nadd_shortcode( 'wpide_pmpro_expires', function ( $atts ) {\n    $atts = shortcode_atts( array( 'never' => 'never', 'none' => '' ), $atts, 'wpide_pmpro_expires' );\n\n    if ( ! is_user_logged_in() ) {\n        return '';\n    }\n\n    $level = pmpro_getMembershipLevelForUser( get_current_user_id() );\n\n    if ( empty( $level ) ) {\n        return esc_html( $atts['none'] );\n    }\n\n    return esc_html( empty( $level->enddate ) ? $atts['never'] : wp_date( get_option( 'date_format' ), (int) $level->enddate ) );\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Recurring levels usually have no end date and show the \"never\" text; change it with never=\"renews automatically\"."
        },
        {
            "id": "pmpro-email-admin-on-expiry",
            "version": 1,
            "title": "Paid Memberships Pro: Email the Admin When a Membership Expires",
            "description": "Sends you a short email when Paid Memberships Pro expires a member's level, so you can follow up personally.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "expiry",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getLevel' ) ) {\n    return;\n}\n\nadd_action( 'pmpro_membership_post_membership_expiry', function ( $user_id, $level_id ) {\n    $user  = get_userdata( $user_id );\n    $level = pmpro_getLevel( $level_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'Membership expired: %s', $user->display_name ),\n        sprintf( \"%s (%s) has reached the end of their \\\"%s\\\" membership.\\n\\nProfile: %s\", $user->display_name, $user->user_email, $level ? $level->name : '#' . (int) $level_id, admin_url( 'user-edit.php?user_id=' . (int) $user_id ) )\n    );\n}, 10, 2 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Fires from PMPro's daily expiration check."
        },
        {
            "id": "pmpro-downgrade-to-free-on-expiry",
            "version": 1,
            "title": "Paid Memberships Pro: Move Expired Members to a Free Level",
            "description": "When a paid membership expires, the member is moved to your free level instead of losing membership entirely, so they keep basic access and stay on your list.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "expiry",
                "levels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_changeMembershipLevel' ) ) {\n    return;\n}\n\n// ID of the free level.\n$wpide_pmpro_free_level = 1;\n\nadd_action( 'pmpro_membership_post_membership_expiry', function ( $user_id, $level_id ) use ( $wpide_pmpro_free_level ) {\n    if ( (int) $level_id === (int) $wpide_pmpro_free_level || ! pmpro_getLevel( $wpide_pmpro_free_level ) ) {\n        return;\n    }\n\n    if ( ! pmpro_hasMembershipLevel( null, $user_id ) ) {\n        pmpro_changeMembershipLevel( (int) $wpide_pmpro_free_level, (int) $user_id );\n    }\n}, 10, 2 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Set $wpide_pmpro_free_level to the ID of a free level that already exists."
        },
        {
            "id": "pmpro-checkout-email-domains",
            "version": 1,
            "title": "Paid Memberships Pro: Restrict Checkout to Certain Email Domains",
            "description": "Only lets people check out with an email address from your allowed domains, for company, school or association memberships.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "checkout",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_setMessage' ) ) {\n    return;\n}\n\n$wpide_pmpro_allowed_domains = array( 'example.com' );\n\nadd_filter( 'pmpro_registration_checks', function ( $continue ) use ( $wpide_pmpro_allowed_domains ) {\n    if ( ! $continue ) {\n        return $continue;\n    }\n\n    $email  = is_user_logged_in() ? wp_get_current_user()->user_email : ( isset( $_REQUEST['bemail'] ) ? sanitize_email( wp_unslash( $_REQUEST['bemail'] ) ) : '' );\n    $domain = strtolower( substr( strrchr( $email, '@' ), 1 ) );\n\n    if ( in_array( $domain, $wpide_pmpro_allowed_domains, true ) ) {\n        return $continue;\n    }\n\n    pmpro_setMessage( sprintf( 'Please use your %s email address to join.', implode( ' or ', $wpide_pmpro_allowed_domains ) ), 'pmpro_error' );\n\n    return false;\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Existing members checking out while logged in are checked against their account email."
        },
        {
            "id": "pmpro-account-page-links",
            "version": 1,
            "title": "Paid Memberships Pro: Extra Links on the Account Page",
            "description": "Adds your own links, such as \"Member resources\" or \"Book a call\", to the Member Links box on the Paid Memberships Pro account page.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "account",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\n// Label => array( URL, level ID or 0 for all members ).\n$wpide_pmpro_member_links = array(\n    'Member resources' => array( home_url( '/resources/' ), 0 ),\n    'Book a coaching call' => array( home_url( '/coaching/' ), 2 ),\n);\n\nadd_action( 'pmpro_member_links_top', function () use ( $wpide_pmpro_member_links ) {\n    foreach ( $wpide_pmpro_member_links as $label => $link ) {\n        list( $url, $level ) = $link;\n\n        if ( $level && ! pmpro_hasMembershipLevel( $level ) ) {\n            continue;\n        }\n\n        printf( '<li><a href=\"%1$s\">%2$s</a></li>', esc_url( $url ), esc_html( $label ) );\n    }\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Add a level ID to a link to show it only to that level, or 0 for every member."
        },
        {
            "id": "pmpro-confirmation-redirect-per-level",
            "version": 1,
            "title": "Paid Memberships Pro: Custom Thank-You Page per Level",
            "description": "Sends new members to a different confirmation page for each level after checkout, for example an onboarding page for your premium tier.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "checkout",
                "redirect"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_url' ) ) {\n    return;\n}\n\n// Level ID => thank-you URL.\n$wpide_pmpro_thanks = array(\n    2 => home_url( '/welcome-premium/' ),\n);\n\nadd_filter( 'pmpro_confirmation_url', function ( $url, $user_id, $level ) use ( $wpide_pmpro_thanks ) {\n    $level_id = is_object( $level ) ? (int) $level->id : (int) $level;\n\n    return $wpide_pmpro_thanks[ $level_id ] ?? $url;\n}, 10, 3 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Levels not listed keep PMPro's normal confirmation page."
        },
        {
            "id": "pmpro-users-level-column",
            "version": 1,
            "title": "Paid Memberships Pro: Membership Level Column in Users",
            "description": "Adds a \"Level\" column to the WordPress Users screen showing each user's Paid Memberships Pro level and its end date.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "users",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getMembershipLevelsForUser' ) ) {\n    return;\n}\n\nadd_filter( 'manage_users_columns', function ( $columns ) {\n    $columns['wpide_pmpro_level'] = 'Level';\n\n    return $columns;\n} );\n\nadd_filter( 'manage_users_custom_column', function ( $output, $column, $user_id ) {\n    if ( 'wpide_pmpro_level' !== $column ) {\n        return $output;\n    }\n\n    $levels = (array) pmpro_getMembershipLevelsForUser( $user_id );\n\n    if ( ! $levels ) {\n        return '&mdash;';\n    }\n\n    $parts = array();\n    foreach ( $levels as $level ) {\n        $parts[] = esc_html( $level->name ) . ( ! empty( $level->enddate ) ? ' <small>(until ' . esc_html( wp_date( get_option( 'date_format' ), (int) $level->enddate ) ) . ')</small>' : '' );\n    }\n\n    return implode( '<br>', $parts );\n}, 10, 3 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Users with several levels show all of them."
        },
        {
            "id": "pmpro-members-list-extra-column",
            "version": 1,
            "title": "Paid Memberships Pro: Extra Column on the Members List",
            "description": "Adds a column to Memberships > Members showing a value from the member's profile, such as their company or phone number.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "admin",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\n$wpide_pmpro_col_meta  = 'company';\n$wpide_pmpro_col_label = 'Company';\n\nadd_filter( 'pmpro_manage_memberslist_columns', function ( $columns ) use ( $wpide_pmpro_col_label ) {\n    $columns['wpide_extra'] = $wpide_pmpro_col_label;\n\n    return $columns;\n} );\n\nadd_action( 'pmpro_manage_memberslist_custom_column', function ( $column, $user_id ) use ( $wpide_pmpro_col_meta ) {\n    if ( 'wpide_extra' === $column ) {\n        $value = get_user_meta( (int) $user_id, $wpide_pmpro_col_meta, true );\n        echo esc_html( is_scalar( $value ) ? (string) $value : '' );\n    }\n}, 10, 2 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Set $wpide_pmpro_col_meta to the user meta key to show. Uses the members list hooks from PMPro 3."
        },
        {
            "id": "pmpro-level-body-classes",
            "version": 1,
            "title": "Paid Memberships Pro: Body Classes for Membership Levels",
            "description": "Adds body classes like wpide-pmpro-member and wpide-pmpro-level-2 so you can tailor your theme's look for each membership level with CSS.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "css",
                "body-class"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getMembershipLevelsForUser' ) ) {\n    return;\n}\n\nadd_filter( 'body_class', function ( $classes ) {\n    $levels = is_user_logged_in() ? (array) pmpro_getMembershipLevelsForUser( get_current_user_id() ) : array();\n\n    if ( ! $levels ) {\n        $classes[] = 'wpide-pmpro-non-member';\n        return $classes;\n    }\n\n    $classes[] = 'wpide-pmpro-member';\n\n    foreach ( $levels as $level ) {\n        $classes[] = 'wpide-pmpro-level-' . (int) $level->id;\n    }\n\n    return $classes;\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Visitors and members without a level get wpide-pmpro-non-member. Use PMPro's own content restriction for anything private."
        },
        {
            "id": "pmpro-menu-items-by-level",
            "version": 1,
            "title": "Paid Memberships Pro: Show Menu Items Only to Certain Levels",
            "description": "Hides navigation menu items from anyone who doesn't hold a matching membership level, using a CSS class like wpide-level-2 on the item.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "menus",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) ) {\n    return;\n}\n\nadd_filter( 'wp_nav_menu_objects', function ( $items ) {\n    $removed = array();\n\n    foreach ( $items as $key => $item ) {\n        $wanted = array();\n\n        foreach ( (array) $item->classes as $class ) {\n            if ( 'wpide-level-any' === $class ) {\n                $wanted[] = 'any';\n            } elseif ( preg_match( '/^wpide-level-(\\d+)$/', $class, $m ) ) {\n                $wanted[] = (int) $m[1];\n            }\n        }\n\n        $hide = in_array( (int) $item->menu_item_parent, $removed, true );\n\n        if ( $wanted && ! current_user_can( 'manage_options' ) ) {\n            $levels = in_array( 'any', $wanted, true ) ? null : array_filter( $wanted, 'is_int' );\n            $hide   = $hide || ! pmpro_hasMembershipLevel( $levels );\n        }\n\n        if ( $hide ) {\n            $removed[] = (int) $item->ID;\n            unset( $items[ $key ] );\n        }\n    }\n\n    return $items;\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Enable \"CSS Classes\" under Screen Options in Appearance > Menus. Use wpide-level-any for \"any level\"."
        },
        {
            "id": "pmpro-level-cost-text-suffix",
            "version": 1,
            "title": "Paid Memberships Pro: Add a Note to Level Prices",
            "description": "Appends a short note such as \"plus VAT\" or \"cancel any time\" to the price text Paid Memberships Pro shows for each level.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "pricing",
                "levels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getLevel' ) ) {\n    return;\n}\n\n// Level ID => note. \"default\" covers all other levels.\n$wpide_pmpro_price_notes = array(\n    'default' => 'Cancel any time.',\n);\n\nadd_filter( 'pmpro_level_cost_text', function ( $text, $level ) use ( $wpide_pmpro_price_notes ) {\n    $id   = is_object( $level ) && isset( $level->id ) ? (int) $level->id : 0;\n    $note = $wpide_pmpro_price_notes[ $id ] ?? ( $wpide_pmpro_price_notes['default'] ?? '' );\n\n    return $note ? $text . ' <span class=\"wpide-pmpro-price-note\">' . esc_html( $note ) . '</span>' : $text;\n}, 10, 2 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Leave a level out of $wpide_pmpro_price_notes to keep its text as it is; \"default\" applies to all other levels."
        },
        {
            "id": "pmpro-company-field-at-checkout",
            "version": 1,
            "title": "Paid Memberships Pro: Company Name Field at Checkout",
            "description": "Adds an optional \"Company\" field to the Paid Memberships Pro checkout and member profile, saved with the member's account.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "checkout",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'pmpro_add_user_field' ) || ! function_exists( 'pmpro_add_field_group' ) || ! class_exists( 'PMPro_Field' ) ) {\n        return;\n    }\n\n    pmpro_add_field_group( 'wpide_company', 'Company details' );\n    pmpro_add_user_field( 'wpide_company', new PMPro_Field( 'company', 'text', array(\n        'label'    => 'Company',\n        'profile'  => true,\n        'required' => false,\n        'size'     => 40,\n    ) ) );\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Uses PMPro's user fields API (PMPro 2.9 or newer). Set required to true to make it mandatory; the value is stored in the \"company\" user meta."
        },
        {
            "id": "pmpro-limit-signups-per-level",
            "version": 1,
            "title": "Paid Memberships Pro: Cap the Number of Members per Level",
            "description": "Closes checkout for a level once it reaches the number of active members you set, perfect for limited founding-member or cohort offers.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "checkout",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_setMessage' ) ) {\n    return;\n}\n\n// Level ID => maximum active members.\n$wpide_pmpro_level_caps = array(\n    3 => 100,\n);\n\nadd_filter( 'pmpro_registration_checks', function ( $continue ) use ( $wpide_pmpro_level_caps ) {\n    global $wpdb, $pmpro_level;\n\n    $level_id = isset( $pmpro_level->id ) ? (int) $pmpro_level->id : ( isset( $_REQUEST['level'] ) ? absint( $_REQUEST['level'] ) : 0 );\n\n    if ( ! $continue || empty( $wpide_pmpro_level_caps[ $level_id ] ) || pmpro_hasMembershipLevel( $level_id ) ) {\n        return $continue;\n    }\n\n    $key   = 'wpide_pmpro_count_' . $level_id;\n    $count = get_transient( $key );\n\n    if ( false === $count ) {\n        $count = (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(DISTINCT user_id) FROM {$wpdb->pmpro_memberships_users} WHERE membership_id = %d AND status = 'active'\", $level_id ) );\n        set_transient( $key, $count, 10 * MINUTE_IN_SECONDS );\n    }\n\n    if ( $count >= $wpide_pmpro_level_caps[ $level_id ] ) {\n        pmpro_setMessage( 'Sorry, this membership is full. Please join the waiting list or choose another level.', 'pmpro_error' );\n        return false;\n    }\n\n    return $continue;\n} );\n\nadd_action( 'pmpro_after_change_membership_level', function ( $level_id ) {\n    delete_transient( 'wpide_pmpro_count_' . (int) $level_id );\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Counts active members of the level, cached for 10 minutes. Existing members can still renew."
        },
        {
            "id": "pmpro-renewal-reminder-banner",
            "version": 1,
            "title": "Paid Memberships Pro: Renewal Reminder Banner",
            "description": "Shows a banner across the top of the site to members whose level ends within the next few days, with a link to renew.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "renewal",
                "banner"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getMembershipLevelForUser' ) ) {\n    return;\n}\n\n$wpide_pmpro_warn_days = 7;\n\nadd_action( 'wp_body_open', function () use ( $wpide_pmpro_warn_days ) {\n    if ( ! is_user_logged_in() ) {\n        return;\n    }\n\n    $level = pmpro_getMembershipLevelForUser( get_current_user_id() );\n\n    if ( empty( $level->enddate ) ) {\n        return;\n    }\n\n    $left = (int) $level->enddate - time();\n\n    if ( $left < 0 || $left > $wpide_pmpro_warn_days * DAY_IN_SECONDS ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"wpide-pmpro-renew\" role=\"status\" style=\"padding:.75em 1em;text-align:center;background:#fff4ce;color:#3c2f00\">%1$s <a href=\"%2$s\" style=\"color:inherit;font-weight:600\">Renew now</a></div>',\n        esc_html( sprintf( 'Your %1$s membership ends on %2$s.', $level->name, wp_date( get_option( 'date_format' ), (int) $level->enddate ) ) ),\n        esc_url( pmpro_url( 'checkout', '?level=' . (int) $level->id ) )\n    );\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Only levels with an end date trigger the banner; automatically renewing subscriptions don't."
        },
        {
            "id": "pmpro-notify-admin-cancellation",
            "version": 1,
            "title": "Paid Memberships Pro: Email the Admin When a Member Cancels",
            "description": "Emails you right away when a member cancels their membership, with the level they left, so you can ask why or offer help.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "cancellation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getLevel' ) ) {\n    return;\n}\n\nadd_action( 'pmpro_after_change_membership_level', function ( $level_id, $user_id, $cancel_level ) {\n    if ( 0 !== (int) $level_id || empty( $cancel_level ) ) {\n        return;\n    }\n\n    $user  = get_userdata( $user_id );\n    $level = pmpro_getLevel( is_object( $cancel_level ) ? $cancel_level->id : $cancel_level );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( 'Cancellation: %s', $user->display_name ),\n        sprintf( \"%s (%s) cancelled their \\\"%s\\\" membership on %s.\", $user->display_name, $user->user_email, $level ? $level->name : 'membership', wp_date( get_option( 'date_format' ) ) )\n    );\n}, 10, 3 );",
            "requires": [
                "pmpro"
            ],
            "notes": "Only cancellations are reported (moving to level 0), not upgrades or downgrades between levels."
        },
        {
            "id": "pmpro-members-by-level-widget",
            "version": 1,
            "title": "Paid Memberships Pro: Members per Level Dashboard Widget",
            "description": "Adds a dashboard widget that lists your membership levels with the number of active members in each, cached so the dashboard stays fast.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "dashboard",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getAllLevels' ) ) {\n    return;\n}\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'pmpro_memberslist' ) && ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_pmpro_levels', 'Members per level', function () {\n        global $wpdb;\n\n        $counts = get_transient( 'wpide_pmpro_level_counts' );\n\n        if ( false === $counts ) {\n            $rows   = $wpdb->get_results( \"SELECT membership_id, COUNT(DISTINCT user_id) AS total FROM {$wpdb->pmpro_memberships_users} WHERE status = 'active' GROUP BY membership_id\" );\n            $counts = wp_list_pluck( (array) $rows, 'total', 'membership_id' );\n            set_transient( 'wpide_pmpro_level_counts', $counts, HOUR_IN_SECONDS );\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th scope=\"col\">Level</th><th scope=\"col\">Active members</th></tr></thead><tbody>';\n\n        foreach ( pmpro_getAllLevels( true ) as $level ) {\n            printf( '<tr><td>%1$s</td><td>%2$s</td></tr>', esc_html( $level->name ), esc_html( number_format_i18n( (int) ( $counts[ $level->id ] ?? 0 ) ) ) );\n        }\n\n        echo '</tbody></table>';\n        printf( '<p><a href=\"%s\">View members</a></p>', esc_url( admin_url( 'admin.php?page=pmpro-memberslist' ) ) );\n    } );\n} );\n\nadd_action( 'pmpro_after_change_membership_level', function () {\n    delete_transient( 'wpide_pmpro_level_counts' );\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "Counts refresh every hour and whenever a membership changes."
        },
        {
            "id": "pmpro-hide-levels-from-levels-page",
            "version": 1,
            "title": "Paid Memberships Pro: Hide Levels From the Levels Page",
            "description": "Keeps chosen levels (for example a hidden staff or legacy level) off the public levels page while their checkout links keep working.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "levels",
                "pricing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_getAllLevels' ) ) {\n    return;\n}\n\n// Level IDs to hide.\n$wpide_pmpro_hidden_levels = array( 4 );\n\nadd_filter( 'pmpro_levels_array', function ( $levels ) use ( $wpide_pmpro_hidden_levels ) {\n    foreach ( $levels as $key => $level ) {\n        if ( in_array( (int) ( is_object( $level ) ? $level->id : $key ), $wpide_pmpro_hidden_levels, true ) ) {\n            unset( $levels[ $key ] );\n        }\n    }\n\n    return $levels;\n} );",
            "requires": [
                "pmpro"
            ],
            "notes": "People can still join a hidden level if you give them its checkout link."
        },
        {
            "id": "pmpro-learndash-course-access",
            "version": 1,
            "title": "Paid Memberships Pro + LearnDash: Courses per Membership Level",
            "description": "Enrolls members in the LearnDash courses tied to their Paid Memberships Pro level and removes access when they cancel or their level expires.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "learndash",
                "enrollment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) || ! function_exists( 'ld_update_course_access' ) ) {\n    return;\n}\n\n// Level ID => LearnDash course IDs.\n$wpide_pmpro_ld_courses = array(\n    1 => array( 1001 ),\n    2 => array( 1001, 1002, 1003 ),\n);\n\nadd_action( 'pmpro_after_change_membership_level', function ( $level_id, $user_id ) use ( $wpide_pmpro_ld_courses ) {\n    $keep = array();\n\n    foreach ( $wpide_pmpro_ld_courses as $level => $courses ) {\n        if ( pmpro_hasMembershipLevel( $level, $user_id ) ) {\n            $keep = array_merge( $keep, array_map( 'intval', $courses ) );\n        }\n    }\n\n    // Only courses this snippet granted are ever removed, never ones bought separately.\n    $granted = array_map( 'intval', (array) get_user_meta( $user_id, 'wpide_pmpro_ld_granted', true ) );\n\n    foreach ( array_unique( $keep ) as $course_id ) {\n        if ( ! sfwd_lms_has_access( $course_id, $user_id ) ) {\n            ld_update_course_access( (int) $user_id, $course_id );\n            $granted[] = $course_id;\n        }\n    }\n\n    foreach ( array_diff( $granted, $keep ) as $course_id ) {\n        ld_update_course_access( (int) $user_id, $course_id, true );\n    }\n\n    update_user_meta( $user_id, 'wpide_pmpro_ld_granted', array_values( array_intersect( array_unique( $granted ), $keep ) ) );\n}, 10, 2 );",
            "requires": [
                "pmpro",
                "learndash"
            ],
            "notes": "Map level IDs to course IDs. Only access granted by this snippet is removed, so courses bought separately are never taken away."
        },
        {
            "id": "pmpro-bbpress-forums-for-members",
            "version": 1,
            "title": "Paid Memberships Pro + bbPress: Forums for Members Only",
            "description": "Lets only members with an active Paid Memberships Pro level open your bbPress forums, sending everyone else to the levels page.",
            "category": "memberships",
            "tags": [
                "pmpro",
                "bbpress",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'pmpro_hasMembershipLevel' ) || ! function_exists( 'is_bbpress' ) ) {\n    return;\n}\n\n$wpide_pmpro_forum_levels = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_pmpro_forum_levels ) {\n    if ( ! is_bbpress() || current_user_can( 'moderate' ) ) {\n        return;\n    }\n\n    if ( pmpro_hasMembershipLevel( $wpide_pmpro_forum_levels ? $wpide_pmpro_forum_levels : null ) ) {\n        return;\n    }\n\n    wp_safe_redirect( pmpro_url( 'levels' ) );\n    exit;\n} );",
            "requires": [
                "pmpro",
                "bbpress"
            ],
            "notes": "List level IDs in $wpide_pmpro_forum_levels to allow only those levels, or leave it empty to allow any level."
        },
        {
            "id": "yoast-remove-html-comments",
            "version": 1,
            "title": "Remove the Yoast SEO Comments From the Page Source",
            "description": "Stops Yoast SEO from wrapping its meta tags in \"This site is optimized with the Yoast SEO plugin\" HTML comments. The tags themselves stay.",
            "category": "integrations",
            "tags": [
                "yoast",
                "seo",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_debug_markers', '__return_false' );",
            "requires": [
                "yoast"
            ],
            "notes": "Only the comments are removed; titles, meta descriptions and schema are untouched."
        },
        {
            "id": "yoast-disable-schema-selected-pages",
            "version": 1,
            "title": "Turn Off Yoast Schema on Selected Pages",
            "description": "Removes the Yoast SEO JSON-LD graph from the pages you list, for example landing pages where you print your own structured data.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of the posts or pages that should not get Yoast's schema.\n$wpide_no_schema_ids = array( 12, 34 );\n\nadd_filter( 'wpseo_json_ld_output', function ( $output ) use ( $wpide_no_schema_ids ) {\n    if ( is_singular() && in_array( get_queried_object_id(), array_map( 'intval', $wpide_no_schema_ids ), true ) ) {\n        return false;\n    }\n\n    return $output;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "List page or post IDs in $wpide_no_schema_ids. Yoast's other meta tags still print."
        },
        {
            "id": "yoast-remove-sitelinks-searchbox",
            "version": 1,
            "title": "Remove the Search Action From Yoast's WebSite Schema",
            "description": "Drops the SearchAction (sitelinks search box markup) from the WebSite piece of the Yoast SEO graph, useful when your site search is disabled or poor.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "search"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'disable_wpseo_json_ld_search', '__return_true' );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-webpage-type-per-page",
            "version": 1,
            "title": "Set About and Contact Page Types in Yoast Schema",
            "description": "Changes the WebPage type in the Yoast SEO graph to AboutPage, ContactPage or another type for the page slugs you choose.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Page slug => schema.org WebPage type.\n$wpide_page_types = array(\n    'about'   => 'AboutPage',\n    'contact' => 'ContactPage',\n);\n\nadd_filter( 'wpseo_schema_webpage_type', function ( $type ) use ( $wpide_page_types ) {\n    if ( ! is_page() ) {\n        return $type;\n    }\n\n    $slug = get_post_field( 'post_name', get_queried_object_id() );\n\n    if ( isset( $wpide_page_types[ $slug ] ) ) {\n        // Keep WebPage alongside the subtype so Yoast's other pieces still match.\n        return array( 'WebPage', $wpide_page_types[ $slug ] );\n    }\n\n    return $type;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Map your own page slugs to schema.org WebPage subtypes such as FAQPage, CollectionPage or CheckoutPage."
        },
        {
            "id": "yoast-news-article-type",
            "version": 1,
            "title": "Mark Posts in a Category as NewsArticle in Yoast",
            "description": "Switches the Yoast SEO Article schema type to NewsArticle for posts in the categories you list, which suits news and press sections.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "news"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Category slugs whose posts are news articles.\n$wpide_news_categories = array( 'news', 'press' );\n\nadd_filter( 'wpseo_schema_article_type', function ( $type, $indexable = null ) use ( $wpide_news_categories ) {\n    $post_id = ( is_object( $indexable ) && isset( $indexable->object_type, $indexable->object_id ) && 'post' === $indexable->object_type ) ? (int) $indexable->object_id : ( is_singular() ? get_queried_object_id() : 0 );\n\n    if ( $post_id && 'post' === get_post_type( $post_id ) && has_category( $wpide_news_categories, $post_id ) ) {\n        return 'NewsArticle';\n    }\n\n    return $type;\n}, 10, 2 );",
            "requires": [
                "yoast"
            ],
            "notes": "Use category slugs. Other posts keep the type set in Yoast's settings."
        },
        {
            "id": "yoast-organization-contact-details",
            "version": 1,
            "title": "Add Address and Contact Point to Yoast's Organization Schema",
            "description": "Extends the Organization piece of the Yoast SEO graph with your postal address, phone number, email and a customer service contact point.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your organization's details.\n$wpide_org = array(\n    'telephone' => '+1-555-010-0100',\n    'email'     => 'hello@example.com',\n    'street'    => '123 Main Street',\n    'city'      => 'Springfield',\n    'region'    => 'IL',\n    'postcode'  => '62701',\n    'country'   => 'US',\n    'support'   => '+1-555-010-0101', // Customer service phone.\n);\n\nadd_filter( 'wpseo_schema_organization', function ( $data ) use ( $wpide_org ) {\n    if ( ! is_array( $data ) ) {\n        return $data;\n    }\n\n    if ( $wpide_org['telephone'] ) {\n        $data['telephone'] = $wpide_org['telephone'];\n    }\n\n    if ( $wpide_org['email'] ) {\n        $data['email'] = $wpide_org['email'];\n    }\n\n    if ( $wpide_org['street'] ) {\n        $data['address'] = array(\n            '@type'           => 'PostalAddress',\n            'streetAddress'   => $wpide_org['street'],\n            'addressLocality' => $wpide_org['city'],\n            'addressRegion'   => $wpide_org['region'],\n            'postalCode'      => $wpide_org['postcode'],\n            'addressCountry'  => $wpide_org['country'],\n        );\n    }\n\n    if ( $wpide_org['support'] ) {\n        $data['contactPoint'] = array(\n            '@type'       => 'ContactPoint',\n            'telephone'   => $wpide_org['support'],\n            'contactType' => 'customer service',\n        );\n    }\n\n    return $data;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Fill in your details at the top; leave a value empty to skip it."
        },
        {
            "id": "yoast-person-job-title",
            "version": 1,
            "title": "Add Job Title and Expertise to Yoast's Person Schema",
            "description": "Adds a job title and \"knows about\" topics from each author's profile to the Person pieces Yoast SEO outputs, which supports E-E-A-T signals.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'user_contactmethods', function ( $methods ) {\n    $methods['wpide_job_title']   = 'Job title (schema)';\n    $methods['wpide_knows_about'] = 'Topics of expertise, comma separated (schema)';\n\n    return $methods;\n} );\n\nadd_filter( 'wpseo_schema_person', function ( $data ) {\n    if ( ! is_array( $data ) || empty( $data['@id'] ) ) {\n        return $data;\n    }\n\n    // Find the user this Person piece describes: the post author or the author archive.\n    $user_id = 0;\n\n    if ( is_author() ) {\n        $user_id = get_queried_object_id();\n    } elseif ( is_singular() ) {\n        $user_id = (int) get_post_field( 'post_author', get_queried_object_id() );\n    }\n\n    if ( ! $user_id ) {\n        return $data;\n    }\n\n    $job = get_the_author_meta( 'wpide_job_title', $user_id );\n    $knows = array_filter( array_map( 'trim', explode( ',', (string) get_the_author_meta( 'wpide_knows_about', $user_id ) ) ) );\n\n    if ( $job ) {\n        $data['jobTitle'] = $job;\n    }\n\n    if ( $knows ) {\n        $data['knowsAbout'] = array_values( $knows );\n    }\n\n    return $data;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Adds two fields to user profiles. Separate topics with commas."
        },
        {
            "id": "yoast-reviewed-by-schema",
            "version": 1,
            "title": "Add \"Reviewed By\" and Last Reviewed Date to Yoast's WebPage Schema",
            "description": "Lets editors pick a reviewer and a review date for a post, then adds reviewedBy and lastReviewed to the WebPage piece of the Yoast SEO graph.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "medical"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the reviewer box.\n$wpide_review_types = array( 'post', 'page' );\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_review_types ) {\n    add_meta_box( 'wpide-reviewed-by', 'Expert review', function ( $post ) {\n        wp_nonce_field( 'wpide_review_save', 'wpide_review_nonce' );\n        $reviewer = (int) get_post_meta( $post->ID, '_wpide_reviewer', true );\n        $date     = get_post_meta( $post->ID, '_wpide_reviewed_on', true );\n\n        echo '<p><label for=\"wpide-reviewer\">Reviewed by</label><br>';\n        wp_dropdown_users( array( 'name' => 'wpide_reviewer', 'id' => 'wpide-reviewer', 'selected' => $reviewer, 'show_option_none' => '— None —', 'option_none_value' => 0, 'capability' => array( 'edit_posts' ) ) );\n        echo '</p><p><label for=\"wpide-reviewed-on\">Last reviewed</label><br>';\n        printf( '<input type=\"date\" id=\"wpide-reviewed-on\" name=\"wpide_reviewed_on\" value=\"%s\"></p>', esc_attr( $date ) );\n    }, $wpide_review_types, 'side' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) use ( $wpide_review_types ) {\n    if ( ! isset( $_POST['wpide_review_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_review_nonce'] ), 'wpide_review_save' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || ! in_array( get_post_type( $post_id ), $wpide_review_types, true ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $reviewer = isset( $_POST['wpide_reviewer'] ) ? absint( $_POST['wpide_reviewer'] ) : 0;\n    $date     = isset( $_POST['wpide_reviewed_on'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_reviewed_on'] ) ) : '';\n\n    update_post_meta( $post_id, '_wpide_reviewer', $reviewer );\n    update_post_meta( $post_id, '_wpide_reviewed_on', preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $date ) ? $date : '' );\n} );\n\nadd_filter( 'wpseo_schema_webpage', function ( $data ) {\n    if ( ! is_array( $data ) || ! is_singular() ) {\n        return $data;\n    }\n\n    $post_id  = get_queried_object_id();\n    $reviewer = (int) get_post_meta( $post_id, '_wpide_reviewer', true );\n    $date     = get_post_meta( $post_id, '_wpide_reviewed_on', true );\n\n    if ( $reviewer && get_userdata( $reviewer ) ) {\n        $data['reviewedBy'] = array(\n            '@type' => 'Person',\n            'name'  => get_the_author_meta( 'display_name', $reviewer ),\n            'url'   => get_author_posts_url( $reviewer ),\n        );\n    }\n\n    if ( $date ) {\n        $data['lastReviewed'] = $date;\n    }\n\n    return $data;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Useful for health, finance and legal content. The reviewer must be a user on the site."
        },
        {
            "id": "yoast-no-breadcrumb-schema-home",
            "version": 1,
            "title": "Remove Yoast's Breadcrumb Schema From the Homepage",
            "description": "Skips the BreadcrumbList piece of the Yoast SEO graph on the front page, where a one-item trail adds nothing.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "breadcrumbs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_schema_needs_breadcrumb', function ( $needed ) {\n    return is_front_page() ? false : $needed;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-local-business-graph",
            "version": 1,
            "title": "Add a LocalBusiness to the Yoast Schema Graph",
            "description": "Adds a LocalBusiness node with address, phone and opening hours to the Yoast SEO graph on the homepage and links it to Yoast's Organization.",
            "category": "integrations",
            "tags": [
                "yoast",
                "schema",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your business details.\n$wpide_local = array(\n    'type'   => 'LocalBusiness',\n    'name'   => get_bloginfo( 'name' ),\n    'phone'  => '+1-555-010-0199',\n    'street' => '123 Main Street',\n    'city'   => 'Springfield',\n    'region' => 'IL',\n    'zip'    => '62701',\n    'country' => 'US',\n    'hours'  => array( 'Mo-Fr 09:00-17:00', 'Sa 10:00-14:00' ),\n    'price'  => '$$',\n);\n\nadd_filter( 'wpseo_schema_graph', function ( $graph ) use ( $wpide_local ) {\n    if ( ! is_array( $graph ) || ! is_front_page() ) {\n        return $graph;\n    }\n\n    $graph[] = array(\n        '@type'          => $wpide_local['type'],\n        '@id'            => home_url( '/#localbusiness' ),\n        'name'           => $wpide_local['name'],\n        'url'            => home_url( '/' ),\n        'telephone'      => $wpide_local['phone'],\n        'priceRange'     => $wpide_local['price'],\n        'openingHours'   => $wpide_local['hours'],\n        'parentOrganization' => array( '@id' => home_url( '/#organization' ) ),\n        'address'        => array(\n            '@type'           => 'PostalAddress',\n            'streetAddress'   => $wpide_local['street'],\n            'addressLocality' => $wpide_local['city'],\n            'addressRegion'   => $wpide_local['region'],\n            'postalCode'      => $wpide_local['zip'],\n            'addressCountry'  => $wpide_local['country'],\n        ),\n    );\n\n    return $graph;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Use a more specific type such as Restaurant or Dentist. Yoast Local SEO users already have this."
        },
        {
            "id": "yoast-breadcrumb-hide-current",
            "version": 1,
            "title": "Remove the Current Page From Yoast Breadcrumbs",
            "description": "Drops the last item (the page you are on) from the Yoast SEO breadcrumb trail, so the trail only shows the path leading to it.",
            "category": "integrations",
            "tags": [
                "yoast",
                "breadcrumbs",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_breadcrumb_links', function ( $links ) {\n    if ( is_array( $links ) && count( $links ) > 1 && ! is_front_page() ) {\n        array_pop( $links );\n    }\n\n    return $links;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "The homepage keeps its single crumb."
        },
        {
            "id": "yoast-breadcrumb-blog-page",
            "version": 1,
            "title": "Add the Blog Page to Yoast Breadcrumbs on Posts",
            "description": "Inserts your blog page (Settings > Reading > Posts page) after \"Home\" in the Yoast SEO breadcrumbs of single posts and post archives.",
            "category": "integrations",
            "tags": [
                "yoast",
                "breadcrumbs",
                "blog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_breadcrumb_links', function ( $links ) {\n    $blog_id = (int) get_option( 'page_for_posts' );\n\n    if ( ! $blog_id || ! is_array( $links ) || ! ( is_singular( 'post' ) || is_category() || is_tag() || is_date() ) ) {\n        return $links;\n    }\n\n    foreach ( $links as $link ) {\n        if ( isset( $link['id'] ) && (int) $link['id'] === $blog_id ) {\n            return $links;\n        }\n    }\n\n    array_splice( $links, 1, 0, array( array( 'url' => get_permalink( $blog_id ), 'text' => get_the_title( $blog_id ), 'id' => $blog_id ) ) );\n\n    return $links;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Does nothing when no posts page is set or Yoast already shows it."
        },
        {
            "id": "yoast-breadcrumb-post-type-parent",
            "version": 1,
            "title": "Add a Parent Page to Yoast Breadcrumbs for a Post Type",
            "description": "Puts a regular page in front of a custom post type in the Yoast SEO breadcrumbs, for example Home > Services > Web Design when services have no archive.",
            "category": "integrations",
            "tags": [
                "yoast",
                "breadcrumbs",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => ID of the page to show before it.\n$wpide_crumb_parents = array(\n    'service' => 42,\n);\n\nadd_filter( 'wpseo_breadcrumb_links', function ( $links ) use ( $wpide_crumb_parents ) {\n    if ( ! is_array( $links ) || ! is_singular( array_keys( $wpide_crumb_parents ) ) ) {\n        return $links;\n    }\n\n    $page_id = (int) $wpide_crumb_parents[ get_post_type() ];\n\n    if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {\n        return $links;\n    }\n\n    array_splice( $links, 1, 0, array( array( 'url' => get_permalink( $page_id ), 'text' => get_the_title( $page_id ), 'id' => $page_id ) ) );\n\n    return $links;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Map each post type to the ID of the page that should appear as its parent."
        },
        {
            "id": "yoast-breadcrumb-accessible-nav",
            "version": 1,
            "title": "Accessible Markup for Yoast Breadcrumbs",
            "description": "Wraps the Yoast SEO breadcrumbs in a nav landmark with an aria-label and uses a separator that screen readers skip, so the trail is announced cleanly.",
            "category": "integrations",
            "tags": [
                "yoast",
                "breadcrumbs",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Label read by screen readers and the visible separator.\n$wpide_crumb_label = 'Breadcrumb';\n$wpide_crumb_sep   = '›';\n\nadd_filter( 'wpseo_breadcrumb_output_wrapper', function () {\n    return 'nav';\n} );\n\nadd_filter( 'wpseo_breadcrumb_output', function ( $output ) use ( $wpide_crumb_label ) {\n    // Add the aria-label to the opening <nav> tag.\n    return preg_replace( '/^<nav\\b/', '<nav aria-label=\"' . esc_attr( $wpide_crumb_label ) . '\"', (string) $output, 1 );\n} );\n\nadd_filter( 'wpseo_breadcrumb_separator', function () use ( $wpide_crumb_sep ) {\n    return '<span class=\"breadcrumb-sep\" aria-hidden=\"true\">' . esc_html( $wpide_crumb_sep ) . '</span>';\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Change $wpide_crumb_label or the separator character to suit your site."
        },
        {
            "id": "yoast-breadcrumbs-above-content",
            "version": 1,
            "title": "Show Yoast Breadcrumbs Above Post Content",
            "description": "Prints the Yoast SEO breadcrumb trail at the top of posts and pages for themes that have no breadcrumb spot of their own.",
            "category": "integrations",
            "tags": [
                "yoast",
                "breadcrumbs",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that get the breadcrumbs.\n$wpide_crumb_types = array( 'post', 'page' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_crumb_types ) {\n    if ( ! function_exists( 'yoast_breadcrumb' ) || is_front_page() || ! is_singular( $wpide_crumb_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $trail = yoast_breadcrumb( '<div class=\"wpide-breadcrumbs\">', '</div>', false );\n\n    return $trail ? $trail . $content : $content;\n}, 5 );",
            "requires": [
                "yoast"
            ],
            "notes": "Turn on breadcrumbs under Yoast SEO > Settings > Advanced > Breadcrumbs. Skip this snippet if your theme already shows them."
        },
        {
            "id": "yoast-sitemap-exclude-ids",
            "version": 1,
            "title": "Exclude Specific Posts and Pages From the Yoast Sitemap",
            "description": "Keeps the posts and pages you list out of the Yoast SEO XML sitemaps while leaving them indexable.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs to leave out of the sitemap.\n$wpide_sitemap_exclude = array( 12, 34, 56 );\n\nadd_filter( 'wpseo_exclude_from_sitemap_by_post_ids', function ( $ids ) use ( $wpide_sitemap_exclude ) {\n    return array_unique( array_merge( (array) $ids, array_map( 'intval', $wpide_sitemap_exclude ) ) );\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Yoast caches sitemaps; save any post afterwards to refresh them."
        },
        {
            "id": "yoast-sitemap-exclude-post-types",
            "version": 1,
            "title": "Exclude Post Types From the Yoast Sitemap",
            "description": "Removes whole post types, such as landing pages or a plugin's internal post type, from the Yoast SEO sitemap index.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type names to leave out of the sitemap.\n$wpide_sitemap_skip_types = array( 'landing', 'attachment' );\n\nadd_filter( 'wpseo_sitemap_exclude_post_type', function ( $excluded, $post_type ) use ( $wpide_sitemap_skip_types ) {\n    return in_array( $post_type, $wpide_sitemap_skip_types, true ) ? true : $excluded;\n}, 10, 2 );",
            "requires": [
                "yoast"
            ],
            "notes": "To also hide them from search, set them to noindex in Yoast's Content types settings."
        },
        {
            "id": "yoast-sitemap-exclude-taxonomies",
            "version": 1,
            "title": "Exclude Taxonomies From the Yoast Sitemap",
            "description": "Leaves taxonomy sitemaps, such as tags or a plugin's filter attributes, out of the Yoast SEO sitemap index.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Taxonomy names to leave out of the sitemap.\n$wpide_sitemap_skip_tax = array( 'post_tag', 'post_format' );\n\nadd_filter( 'wpseo_sitemap_exclude_taxonomy', function ( $excluded, $taxonomy ) use ( $wpide_sitemap_skip_tax ) {\n    return in_array( $taxonomy, $wpide_sitemap_skip_tax, true ) ? true : $excluded;\n}, 10, 2 );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-sitemap-skip-old-posts",
            "version": 1,
            "title": "Leave Old Posts Out of the Yoast Sitemap",
            "description": "Removes posts that have not been updated for a number of years from the Yoast SEO post sitemap, so crawlers spend time on fresher content.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "crawl-budget"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Posts not modified for this many years are left out.\n$wpide_sitemap_max_age_years = 3;\n\nadd_filter( 'wpseo_sitemap_entry', function ( $url, $type, $object ) use ( $wpide_sitemap_max_age_years ) {\n    if ( 'post' !== $type || ! $object instanceof WP_Post || 'post' !== $object->post_type ) {\n        return $url;\n    }\n\n    $cutoff = strtotime( '-' . absint( $wpide_sitemap_max_age_years ) . ' years' );\n\n    return strtotime( $object->post_modified_gmt . ' UTC' ) < $cutoff ? false : $url;\n}, 10, 3 );",
            "requires": [
                "yoast"
            ],
            "notes": "The posts stay published and indexable; only the sitemap entry goes."
        },
        {
            "id": "yoast-sitemap-entries-per-page",
            "version": 1,
            "title": "Change the Number of Links per Yoast Sitemap",
            "description": "Sets how many URLs each Yoast SEO sitemap page holds. Lower it if large sitemaps time out on your server.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// URLs per sitemap page.\n$wpide_sitemap_per_page = 500;\n\nadd_filter( 'wpseo_sitemap_entries_per_page', function () use ( $wpide_sitemap_per_page ) {\n    return absint( $wpide_sitemap_per_page );\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Yoast's default is 1000; Google accepts up to 50,000."
        },
        {
            "id": "yoast-sitemap-index-external",
            "version": 1,
            "title": "Add Your Own Sitemaps to the Yoast Sitemap Index",
            "description": "Lists extra sitemap files, such as one from a shop, forum or static section, in the Yoast SEO sitemap_index.xml so search engines find them in one place.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "index"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Full URLs of extra sitemap files.\n$wpide_extra_sitemaps = array(\n    home_url( '/shop-sitemap.xml' ),\n    home_url( '/forum/sitemap.xml' ),\n);\n\nadd_filter( 'wpseo_sitemap_index', function ( $xml ) use ( $wpide_extra_sitemaps ) {\n    $lastmod = gmdate( 'c' );\n\n    foreach ( $wpide_extra_sitemaps as $url ) {\n        $xml .= \"<sitemap>\\n\\t<loc>\" . esc_url( $url ) . \"</loc>\\n\\t<lastmod>\" . esc_html( $lastmod ) . \"</lastmod>\\n</sitemap>\\n\";\n    }\n\n    return $xml;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Use full URLs on your own domain; search engines ignore cross-site sitemaps without verification."
        },
        {
            "id": "yoast-sitemap-exclude-author-roles",
            "version": 1,
            "title": "Keep Some User Roles Out of Yoast's Author Sitemap",
            "description": "Removes users with the roles you choose, such as shop managers or contributors, from the Yoast SEO author sitemap.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Roles that should not appear in the author sitemap.\n$wpide_sitemap_hidden_roles = array( 'shop_manager', 'contributor' );\n\nadd_filter( 'wpseo_sitemap_exclude_author', function ( $users ) use ( $wpide_sitemap_hidden_roles ) {\n    return array_values( array_filter( (array) $users, function ( $user ) use ( $wpide_sitemap_hidden_roles ) {\n        return ! ( $user instanceof WP_User && array_intersect( (array) $user->roles, $wpide_sitemap_hidden_roles ) );\n    } ) );\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Only matters when the author sitemap is enabled in Yoast."
        },
        {
            "id": "yoast-sitemap-field-images",
            "version": 1,
            "title": "Add Gallery Images From a Custom Field to the Yoast Sitemap",
            "description": "Includes images stored in a custom field (a list of attachment IDs, such as a gallery) in the image entries of the Yoast SEO sitemap.",
            "category": "integrations",
            "tags": [
                "yoast",
                "sitemap",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Meta key holding attachment IDs.\n$wpide_gallery_field = 'gallery';\n\nadd_filter( 'wpseo_sitemap_urlimages', function ( $images, $post_id ) use ( $wpide_gallery_field ) {\n    $ids = get_post_meta( $post_id, $wpide_gallery_field, true );\n    $ids = is_array( $ids ) ? $ids : explode( ',', (string) $ids );\n\n    foreach ( array_filter( array_map( 'absint', $ids ) ) as $id ) {\n        $src = wp_get_attachment_image_url( $id, 'full' );\n\n        if ( $src ) {\n            $images[] = array( 'src' => $src );\n        }\n    }\n\n    return $images;\n}, 10, 2 );",
            "requires": [
                "yoast"
            ],
            "notes": "The field may hold an array of IDs or a comma-separated list, as ACF and most gallery fields do."
        },
        {
            "id": "yoast-reading-time-variable",
            "version": 1,
            "title": "Reading Time Variable for Yoast Titles and Descriptions",
            "description": "Adds a %%wpide_reading_time%% variable to Yoast SEO, so meta descriptions or social titles can say \"5 min read\".",
            "category": "integrations",
            "tags": [
                "yoast",
                "variables",
                "reading-time"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed.\n$wpide_words_per_minute = 220;\n\nadd_action( 'wpseo_register_extra_replacements', function () use ( $wpide_words_per_minute ) {\n    if ( ! function_exists( 'wpseo_register_var_replacement' ) ) {\n        return;\n    }\n\n    wpseo_register_var_replacement( '%%wpide_reading_time%%', function () use ( $wpide_words_per_minute ) {\n        $post = get_post();\n\n        if ( ! $post ) {\n            return '';\n        }\n\n        $words = str_word_count( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) );\n\n        return sprintf( '%d min read', max( 1, (int) ceil( $words / max( 1, $wpide_words_per_minute ) ) ) );\n    }, 'advanced', 'Estimated reading time of the post, for example \"5 min read\".' );\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Type %%wpide_reading_time%% in any Yoast title or description field. Change the words per minute to suit your readers."
        },
        {
            "id": "yoast-canonical-from-field",
            "version": 1,
            "title": "Point the Yoast Canonical to an Original Source",
            "description": "Uses a URL saved in a custom field as the canonical link on posts republished from another site, overriding Yoast's default.",
            "category": "integrations",
            "tags": [
                "yoast",
                "canonical",
                "syndication"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Custom field holding the original URL.\n$wpide_canonical_field = 'original_url';\n\nadd_filter( 'wpseo_canonical', function ( $canonical ) use ( $wpide_canonical_field ) {\n    if ( ! is_singular() ) {\n        return $canonical;\n    }\n\n    $source = esc_url_raw( (string) get_post_meta( get_queried_object_id(), $wpide_canonical_field, true ) );\n\n    return wp_http_validate_url( $source ) ? $source : $canonical;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Add a custom field named original_url with the full source URL to a post. Yoast Premium's canonical field also works per post."
        },
        {
            "id": "yoast-noindex-paged-archives",
            "version": 1,
            "title": "Noindex Page 2 and Beyond With Yoast",
            "description": "Tells search engines not to index paginated archive pages (page 2, 3 and so on) while still following their links, using Yoast SEO's robots meta.",
            "category": "integrations",
            "tags": [
                "yoast",
                "noindex",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_robots_array', function ( $robots ) {\n    if ( is_paged() && is_array( $robots ) ) {\n        $robots['index']  = 'noindex';\n        $robots['follow'] = 'follow';\n    }\n\n    return $robots;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "The first page of every archive stays indexable."
        },
        {
            "id": "yoast-remove-admin-columns",
            "version": 1,
            "title": "Remove Yoast Columns From Admin Lists",
            "description": "Hides the SEO score, readability, title, meta description, keyphrase and link count columns Yoast SEO adds to post and page lists.",
            "category": "integrations",
            "tags": [
                "yoast",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Yoast column keys to hide.\n$wpide_yoast_columns = array( 'wpseo-score', 'wpseo-score-readability', 'wpseo-title', 'wpseo-metadesc', 'wpseo-focuskw', 'wpseo-links', 'wpseo-linked' );\n\nadd_action( 'current_screen', function ( $screen ) use ( $wpide_yoast_columns ) {\n    if ( ! $screen || 'edit' !== $screen->base ) {\n        return;\n    }\n\n    add_filter( \"manage_{$screen->id}_columns\", function ( $columns ) use ( $wpide_yoast_columns ) {\n        return array_diff_key( $columns, array_flip( $wpide_yoast_columns ) );\n    }, 100 );\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Remove a column name from the list to keep that one."
        },
        {
            "id": "yoast-metabox-low-priority",
            "version": 1,
            "title": "Move the Yoast Box to the Bottom of the Editor",
            "description": "Lowers the priority of the Yoast SEO meta box so it sits below your other boxes instead of right under the content.",
            "category": "integrations",
            "tags": [
                "yoast",
                "admin",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_metabox_prio', function () {\n    return 'low';\n} );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-hide-metabox-roles",
            "version": 1,
            "title": "Hide the Yoast Box From Authors and Contributors",
            "description": "Removes the Yoast SEO meta box for users who cannot publish pages, so only editors and administrators manage SEO settings.",
            "category": "integrations",
            "tags": [
                "yoast",
                "admin",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Users without this capability do not see the Yoast box.\n$wpide_yoast_cap = 'publish_pages';\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_yoast_cap ) {\n    if ( current_user_can( $wpide_yoast_cap ) ) {\n        return;\n    }\n\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        remove_meta_box( 'wpseo_meta', $post_type, 'normal' );\n    }\n}, 100 );",
            "requires": [
                "yoast"
            ],
            "notes": "Change publish_pages to another capability to widen or narrow who sees the box. In the block editor Yoast's sidebar icon can remain; this removes the main box."
        },
        {
            "id": "yoast-hide-metabox-post-types",
            "version": 1,
            "title": "Hide the Yoast Box on Selected Post Types",
            "description": "Removes the Yoast SEO meta box from post types that never appear in search, such as internal records, while leaving it on everything else.",
            "category": "integrations",
            "tags": [
                "yoast",
                "admin",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types without the Yoast box.\n$wpide_yoast_hidden_types = array( 'testimonial', 'faq' );\n\nadd_action( 'add_meta_boxes', function ( $post_type ) use ( $wpide_yoast_hidden_types ) {\n    if ( in_array( $post_type, $wpide_yoast_hidden_types, true ) ) {\n        remove_meta_box( 'wpseo_meta', $post_type, 'normal' );\n    }\n}, 100 );",
            "requires": [
                "yoast"
            ],
            "notes": "To exclude a post type from Yoast entirely, also set it to \"noindex\" in Yoast's settings."
        },
        {
            "id": "yoast-remove-admin-bar-menu",
            "version": 1,
            "title": "Remove the Yoast SEO Menu From the Admin Bar",
            "description": "Takes the Yoast SEO item out of the toolbar on the front end and in the admin for everyone.",
            "category": "integrations",
            "tags": [
                "yoast",
                "admin-bar",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    $bar->remove_node( 'wpseo-menu' );\n}, 999 );",
            "requires": [
                "yoast"
            ],
            "notes": "Yoast also has a setting for this under Settings > Site features; this snippet keeps it off for every user."
        },
        {
            "id": "yoast-remove-dashboard-widgets",
            "version": 1,
            "title": "Remove Yoast Dashboard Widgets",
            "description": "Takes the Yoast SEO posts overview and Wincher widgets off the WordPress dashboard.",
            "category": "integrations",
            "tags": [
                "yoast",
                "dashboard",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'wpseo-dashboard-overview', 'dashboard', 'normal' );\n    remove_meta_box( 'wpseo-wincher-dashboard-overview', 'dashboard', 'normal' );\n}, 100 );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-og-image-fallback-post-type",
            "version": 1,
            "title": "Default Social Image per Post Type in Yoast",
            "description": "Gives Yoast SEO a fallback Open Graph image for each post type when a post has neither a social image nor a featured image.",
            "category": "integrations",
            "tags": [
                "yoast",
                "open-graph",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => fallback image URL.\n$wpide_og_fallbacks = array(\n    'post'    => home_url( '/wp-content/uploads/social-blog.jpg' ),\n    'product' => home_url( '/wp-content/uploads/social-shop.jpg' ),\n);\n\nadd_filter( 'wpseo_opengraph_image', function ( $image ) use ( $wpide_og_fallbacks ) {\n    if ( $image || ! is_singular( array_keys( $wpide_og_fallbacks ) ) ) {\n        return $image;\n    }\n\n    return $wpide_og_fallbacks[ get_post_type( get_queried_object_id() ) ];\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Use images at least 1200 × 630 pixels. Yoast's site-wide default image still applies to other types."
        },
        {
            "id": "yoast-og-extra-images",
            "version": 1,
            "title": "Add Gallery Images as Extra Open Graph Images in Yoast",
            "description": "Adds the images from a gallery custom field as additional og:image tags through Yoast SEO, so social networks can offer a choice of preview.",
            "category": "integrations",
            "tags": [
                "yoast",
                "open-graph",
                "gallery"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Meta key holding attachment IDs.\n$wpide_og_gallery_field = 'gallery';\n// Maximum number of extra images.\n$wpide_og_gallery_max = 3;\n\nadd_action( 'wpseo_add_opengraph_additional_images', function ( $container ) use ( $wpide_og_gallery_field, $wpide_og_gallery_max ) {\n    if ( ! is_singular() || ! is_object( $container ) || ! method_exists( $container, 'add_image_by_url' ) ) {\n        return;\n    }\n\n    $ids = get_post_meta( get_queried_object_id(), $wpide_og_gallery_field, true );\n    $ids = array_slice( array_filter( array_map( 'absint', is_array( $ids ) ? $ids : explode( ',', (string) $ids ) ) ), 0, $wpide_og_gallery_max );\n\n    foreach ( $ids as $id ) {\n        $url = wp_get_attachment_image_url( $id, 'large' );\n\n        if ( $url ) {\n            $container->add_image_by_url( $url );\n        }\n    }\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "The field may hold an array of attachment IDs or a comma-separated list. Most networks use only the first image."
        },
        {
            "id": "yoast-twitter-summary-card",
            "version": 1,
            "title": "Use the Small Twitter Card in Yoast",
            "description": "Switches the X (Twitter) card Yoast SEO outputs from the large image card to the compact summary card with a square thumbnail.",
            "category": "integrations",
            "tags": [
                "yoast",
                "twitter",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_twitter_card_type', function () {\n    return 'summary';\n} );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-remove-slack-data",
            "version": 1,
            "title": "Remove the Author and Reading Time From Yoast's Slack Previews",
            "description": "Stops Yoast SEO from adding \"Written by\" and \"Est. reading time\" labels to link previews in Slack and similar apps.",
            "category": "integrations",
            "tags": [
                "yoast",
                "social",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wpseo_enhanced_slack_data', '__return_empty_array' );",
            "requires": [
                "yoast"
            ],
            "notes": ""
        },
        {
            "id": "yoast-metadesc-excerpt-fallback",
            "version": 1,
            "title": "Use the Excerpt When a Yoast Meta Description Is Empty",
            "description": "Fills an empty Yoast SEO meta description with the post's excerpt (or its opening words), trimmed to a search-friendly length.",
            "category": "integrations",
            "tags": [
                "yoast",
                "meta-description",
                "excerpt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Maximum description length in characters.\n$wpide_desc_length = 155;\n\nadd_filter( 'wpseo_metadesc', function ( $description ) use ( $wpide_desc_length ) {\n    if ( '' !== trim( (string) $description ) || ! is_singular() ) {\n        return $description;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post ) {\n        return $description;\n    }\n\n    $text = has_excerpt( $post ) ? $post->post_excerpt : strip_shortcodes( $post->post_content );\n    $text = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( $text ) ) );\n\n    if ( mb_strlen( $text ) > $wpide_desc_length ) {\n        $text = rtrim( mb_substr( $text, 0, $wpide_desc_length - 1 ), \" ,.;:-\" ) . '…';\n    }\n\n    return $text;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Descriptions set in Yoast, and Yoast's template descriptions, always win."
        },
        {
            "id": "yoast-primary-category-shortcode",
            "version": 1,
            "title": "Primary Category Shortcode for Yoast",
            "description": "Adds a [primary_category] shortcode that shows the post's Yoast SEO primary category as a link, falling back to its first category.",
            "category": "integrations",
            "tags": [
                "yoast",
                "categories",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'primary_category', function ( $atts ) {\n    $atts = shortcode_atts( array( 'taxonomy' => 'category', 'link' => 'yes' ), $atts, 'primary_category' );\n    $post_id  = get_the_ID();\n    $taxonomy = sanitize_key( $atts['taxonomy'] );\n\n    if ( ! $post_id || ! taxonomy_exists( $taxonomy ) ) {\n        return '';\n    }\n\n    $term_id = function_exists( 'yoast_get_primary_term_id' ) ? (int) yoast_get_primary_term_id( $taxonomy, $post_id ) : 0;\n    $term    = $term_id ? get_term( $term_id, $taxonomy ) : null;\n\n    if ( ! $term instanceof WP_Term ) {\n        $terms = get_the_terms( $post_id, $taxonomy );\n        $term  = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0] : null;\n    }\n\n    if ( ! $term ) {\n        return '';\n    }\n\n    if ( 'no' === $atts['link'] ) {\n        return '<span class=\"primary-category\">' . esc_html( $term->name ) . '</span>';\n    }\n\n    return '<a class=\"primary-category\" href=\"' . esc_url( get_term_link( $term ) ) . '\">' . esc_html( $term->name ) . '</a>';\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Use [primary_category link=\"no\"] for plain text or taxonomy=\"product_cat\" for another taxonomy."
        },
        {
            "id": "yoast-og-type-post-type",
            "version": 1,
            "title": "Set the Open Graph Type for a Post Type in Yoast",
            "description": "Changes the og:type Yoast SEO outputs for a custom post type, for example \"article\" for a news or recipe post type that Yoast marks as a website.",
            "category": "integrations",
            "tags": [
                "yoast",
                "open-graph",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => og:type.\n$wpide_og_types = array(\n    'recipe' => 'article',\n    'news'   => 'article',\n);\n\nadd_filter( 'wpseo_opengraph_type', function ( $type ) use ( $wpide_og_types ) {\n    if ( is_singular( array_keys( $wpide_og_types ) ) ) {\n        return $wpide_og_types[ get_post_type( get_queried_object_id() ) ];\n    }\n\n    return $type;\n} );",
            "requires": [
                "yoast"
            ],
            "notes": "Facebook accepts website, article, profile, book and the video and music types."
        },
        {
            "id": "rankmath-remove-credit-comment",
            "version": 1,
            "title": "Remove the Rank Math Comments From the Page Source",
            "description": "Stops Rank Math from printing its \"Search Engine Optimization by Rank Math\" HTML comments around the meta tags in your page source.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "seo",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/remove_credit_notice', '__return_true' );",
            "requires": [
                "rankmath"
            ],
            "notes": "Only the comments go; titles, meta tags and schema are unchanged."
        },
        {
            "id": "rankmath-breadcrumb-hide-current",
            "version": 1,
            "title": "Rank Math Breadcrumbs Without the Current Page",
            "description": "Drops the last item (the page being viewed) from the Rank Math breadcrumb trail, leaving only the links that lead to it.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "breadcrumbs",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/breadcrumb/items', function ( $crumbs ) {\n    if ( is_array( $crumbs ) && count( $crumbs ) > 1 && ! is_front_page() ) {\n        array_pop( $crumbs );\n    }\n\n    return $crumbs;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Rank Math also has a \"Hide Post Title\" option for posts; this works on every page type."
        },
        {
            "id": "rankmath-breadcrumb-blog-page",
            "version": 1,
            "title": "Blog Page Crumb in Rank Math Breadcrumbs",
            "description": "Inserts the page set as your posts page after \"Home\" in Rank Math breadcrumbs on single posts, categories and tags.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "breadcrumbs",
                "blog"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/breadcrumb/items', function ( $crumbs ) {\n    $blog_id = (int) get_option( 'page_for_posts' );\n\n    if ( ! $blog_id || ! is_array( $crumbs ) || ! ( is_singular( 'post' ) || is_category() || is_tag() ) ) {\n        return $crumbs;\n    }\n\n    $blog_url = get_permalink( $blog_id );\n\n    foreach ( $crumbs as $crumb ) {\n        if ( isset( $crumb[1] ) && $crumb[1] === $blog_url ) {\n            return $crumbs;\n        }\n    }\n\n    array_splice( $crumbs, 1, 0, array( array( get_the_title( $blog_id ), $blog_url, 'hide_in_schema' => false ) ) );\n\n    return $crumbs;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Does nothing when Settings > Reading has no posts page."
        },
        {
            "id": "rankmath-breadcrumb-post-type-parent",
            "version": 1,
            "title": "Rank Math Breadcrumbs: Parent Page for a Post Type",
            "description": "Shows a regular page before custom post type entries in Rank Math breadcrumbs, such as Home > Our Team > Jane Doe.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "breadcrumbs",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => ID of the page to show before it.\n$wpide_rm_crumb_parents = array(\n    'team' => 42,\n);\n\nadd_filter( 'rank_math/frontend/breadcrumb/items', function ( $crumbs ) use ( $wpide_rm_crumb_parents ) {\n    if ( ! is_array( $crumbs ) || ! is_singular( array_keys( $wpide_rm_crumb_parents ) ) ) {\n        return $crumbs;\n    }\n\n    $page_id = (int) $wpide_rm_crumb_parents[ get_post_type() ];\n\n    if ( $page_id && 'publish' === get_post_status( $page_id ) ) {\n        array_splice( $crumbs, 1, 0, array( array( get_the_title( $page_id ), get_permalink( $page_id ), 'hide_in_schema' => false ) ) );\n    }\n\n    return $crumbs;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Map each post type to the ID of the page that acts as its parent."
        },
        {
            "id": "rankmath-breadcrumb-accessible-nav",
            "version": 1,
            "title": "Accessible Wrapper for Rank Math Breadcrumbs",
            "description": "Sets your own aria-label on the Rank Math breadcrumb navigation and wraps the separator so screen readers skip it.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "breadcrumbs",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Label announced by screen readers.\n$wpide_rm_crumb_label = 'Breadcrumb';\n\nadd_filter( 'rank_math/frontend/breadcrumb/args', function ( $args ) use ( $wpide_rm_crumb_label ) {\n    if ( ! is_array( $args ) ) {\n        return $args;\n    }\n\n    $args['wrap_before'] = '<nav aria-label=\"' . esc_attr( $wpide_rm_crumb_label ) . '\" class=\"rank-math-breadcrumb\"><p>';\n    $args['wrap_after']  = '</p></nav>';\n\n    if ( isset( $args['delimiter'] ) ) {\n        $args['delimiter'] = '<span class=\"separator\" aria-hidden=\"true\">' . wp_strip_all_tags( $args['delimiter'] ) . '</span>';\n    }\n\n    return $args;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Rank Math's separator setting still controls the character itself."
        },
        {
            "id": "rankmath-breadcrumbs-above-content",
            "version": 1,
            "title": "Rank Math Breadcrumbs at the Top of Posts and Pages",
            "description": "Prints the Rank Math breadcrumb trail at the top of single posts and pages, for themes with no built-in breadcrumb area.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "breadcrumbs",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that get the breadcrumbs.\n$wpide_rm_crumb_types = array( 'post', 'page' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_rm_crumb_types ) {\n    if ( ! function_exists( 'rank_math_get_breadcrumbs' ) || is_front_page() || ! is_singular( $wpide_rm_crumb_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    return rank_math_get_breadcrumbs() . $content;\n}, 5 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Enable breadcrumbs under Rank Math > General Settings > Breadcrumbs first."
        },
        {
            "id": "rankmath-json-ld-local-business",
            "version": 1,
            "title": "Add a LocalBusiness Entity to Rank Math Schema",
            "description": "Adds a LocalBusiness node with your address, phone and hours to Rank Math's JSON-LD on the homepage, linked to the site's organization.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your business details.\n$wpide_rm_local = array(\n    'type'    => 'LocalBusiness',\n    'phone'   => '+1-555-010-0199',\n    'street'  => '123 Main Street',\n    'city'    => 'Springfield',\n    'region'  => 'IL',\n    'zip'     => '62701',\n    'country' => 'US',\n    'hours'   => array( 'Mo-Fr 09:00-17:00' ),\n);\n\nadd_filter( 'rank_math/json_ld', function ( $data ) use ( $wpide_rm_local ) {\n    if ( ! is_array( $data ) || ! is_front_page() ) {\n        return $data;\n    }\n\n    $data['wpideLocalBusiness'] = array(\n        '@type'        => $wpide_rm_local['type'],\n        '@id'          => home_url( '/#localbusiness' ),\n        'name'         => get_bloginfo( 'name' ),\n        'url'          => home_url( '/' ),\n        'telephone'    => $wpide_rm_local['phone'],\n        'openingHours' => $wpide_rm_local['hours'],\n        'parentOrganization' => array( '@id' => home_url( '/#organization' ) ),\n        'address'      => array(\n            '@type'           => 'PostalAddress',\n            'streetAddress'   => $wpide_rm_local['street'],\n            'addressLocality' => $wpide_rm_local['city'],\n            'addressRegion'   => $wpide_rm_local['region'],\n            'postalCode'      => $wpide_rm_local['zip'],\n            'addressCountry'  => $wpide_rm_local['country'],\n        ),\n    );\n\n    return $data;\n}, 99 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Rank Math PRO's Local SEO module does this too; use one or the other."
        },
        {
            "id": "rankmath-json-ld-off-selected-pages",
            "version": 1,
            "title": "Turn Off Rank Math Schema on Selected Pages",
            "description": "Removes all Rank Math JSON-LD from the posts or pages you list, so you can add hand-written structured data there instead.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of posts or pages without Rank Math schema.\n$wpide_rm_no_schema = array( 12, 34 );\n\nadd_filter( 'rank_math/json_ld', function ( $data ) use ( $wpide_rm_no_schema ) {\n    if ( is_singular() && in_array( get_queried_object_id(), array_map( 'intval', $wpide_rm_no_schema ), true ) ) {\n        return array();\n    }\n\n    return $data;\n}, 999 );",
            "requires": [
                "rankmath"
            ],
            "notes": "List post or page IDs. Meta tags from Rank Math still print."
        },
        {
            "id": "rankmath-news-article-category",
            "version": 1,
            "title": "Use NewsArticle Schema for a Category in Rank Math",
            "description": "Changes Article and BlogPosting entities in Rank Math's schema to NewsArticle on posts filed under the categories you choose.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "news"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Category slugs whose posts are news.\n$wpide_rm_news_cats = array( 'news' );\n\nadd_filter( 'rank_math/json_ld', function ( $data ) use ( $wpide_rm_news_cats ) {\n    if ( ! is_array( $data ) || ! is_singular( 'post' ) || ! has_category( $wpide_rm_news_cats, get_queried_object_id() ) ) {\n        return $data;\n    }\n\n    foreach ( $data as $key => $entity ) {\n        if ( is_array( $entity ) && isset( $entity['@type'] ) && in_array( $entity['@type'], array( 'Article', 'BlogPosting' ), true ) ) {\n            $data[ $key ]['@type'] = 'NewsArticle';\n        }\n    }\n\n    return $data;\n}, 99 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Use category slugs. Other posts keep the type chosen in Rank Math."
        },
        {
            "id": "rankmath-disable-breadcrumb-schema",
            "version": 1,
            "title": "Remove Breadcrumb Schema From Rank Math",
            "description": "Stops Rank Math from adding BreadcrumbList structured data, for example when your theme or another plugin already outputs it.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "breadcrumbs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/json_ld/breadcrumbs_enabled', '__return_false' );",
            "requires": [
                "rankmath"
            ],
            "notes": "The visible breadcrumbs are unaffected."
        },
        {
            "id": "rankmath-remove-search-action",
            "version": 1,
            "title": "Drop the Sitelinks Search Box From Rank Math Schema",
            "description": "Deletes the SearchAction (sitelinks search box) from the WebSite entity in Rank Math's JSON-LD.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "search"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/json_ld', function ( $data ) {\n    if ( isset( $data['WebSite'] ) && is_array( $data['WebSite'] ) ) {\n        unset( $data['WebSite']['potentialAction'] );\n    }\n\n    return $data;\n}, 99 );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-organization-contact-point",
            "version": 1,
            "title": "Add a Customer Service Contact to Rank Math's Organization",
            "description": "Adds a ContactPoint with phone, email and languages to the Organization entity Rank Math outputs on your site.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "schema",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Contact details.\n$wpide_rm_contact = array(\n    'telephone'         => '+1-555-010-0101',\n    'email'             => 'support@example.com',\n    'contactType'       => 'customer service',\n    'availableLanguage' => array( 'English', 'Spanish' ),\n);\n\nadd_filter( 'rank_math/json_ld', function ( $data ) use ( $wpide_rm_contact ) {\n    if ( ! is_array( $data ) ) {\n        return $data;\n    }\n\n    foreach ( $data as $key => $entity ) {\n        $types = isset( $entity['@type'] ) ? (array) $entity['@type'] : array();\n\n        if ( in_array( 'Organization', $types, true ) ) {\n            $data[ $key ]['contactPoint'] = array_merge( array( '@type' => 'ContactPoint' ), array_filter( $wpide_rm_contact ) );\n        }\n    }\n\n    return $data;\n}, 99 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Only applies when Rank Math describes the site as an organization, not a person."
        },
        {
            "id": "rankmath-sitemap-exclude-ids",
            "version": 1,
            "title": "Exclude Specific Posts From the Rank Math Sitemap",
            "description": "Leaves the posts and pages you list out of Rank Math's XML sitemaps without changing their robots settings.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs to leave out.\n$wpide_rm_sitemap_exclude = array( 12, 34 );\n\nadd_filter( 'rank_math/sitemap/entry', function ( $url, $type, $object ) use ( $wpide_rm_sitemap_exclude ) {\n    if ( 'post' === $type && $object instanceof WP_Post && in_array( (int) $object->ID, array_map( 'intval', $wpide_rm_sitemap_exclude ), true ) ) {\n        return false;\n    }\n\n    return $url;\n}, 10, 3 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Rank Math caches sitemaps; clear the cache or update a post to see the change."
        },
        {
            "id": "rankmath-sitemap-exclude-post-types",
            "version": 1,
            "title": "Rank Math Sitemap: Leave Out Whole Post Types",
            "description": "Removes entire post types from the Rank Math sitemap index regardless of the sitemap settings screen.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types to leave out.\n$wpide_rm_skip_types = array( 'landing', 'attachment' );\n\nadd_filter( 'rank_math/sitemap/exclude_post_type', function ( $exclude, $type ) use ( $wpide_rm_skip_types ) {\n    return in_array( $type, $wpide_rm_skip_types, true ) ? true : $exclude;\n}, 10, 2 );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-sitemap-exclude-taxonomies",
            "version": 1,
            "title": "Rank Math Sitemap: Leave Out Taxonomies",
            "description": "Keeps taxonomy archives such as tags or product attributes out of the Rank Math sitemap index.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Taxonomies to leave out.\n$wpide_rm_skip_tax = array( 'post_tag', 'post_format' );\n\nadd_filter( 'rank_math/sitemap/exclude_taxonomy', function ( $exclude, $taxonomy ) use ( $wpide_rm_skip_tax ) {\n    return in_array( $taxonomy, $wpide_rm_skip_tax, true ) ? true : $exclude;\n}, 10, 2 );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-sitemap-index-external",
            "version": 1,
            "title": "List Extra Sitemap Files in Rank Math's Index",
            "description": "Lists extra sitemap files from other parts of your site in Rank Math's sitemap_index.xml.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "index"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Full URLs of extra sitemaps.\n$wpide_rm_extra_sitemaps = array(\n    home_url( '/shop-sitemap.xml' ),\n);\n\nadd_filter( 'rank_math/sitemap/index', function ( $xml ) use ( $wpide_rm_extra_sitemaps ) {\n    foreach ( $wpide_rm_extra_sitemaps as $url ) {\n        $xml .= \"\\t<sitemap>\\n\\t\\t<loc>\" . esc_url( $url ) . \"</loc>\\n\\t\\t<lastmod>\" . esc_html( gmdate( 'c' ) ) . \"</lastmod>\\n\\t</sitemap>\\n\";\n    }\n\n    return $xml;\n}, 11 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Use full URLs on the same domain."
        },
        {
            "id": "rankmath-sitemap-field-images",
            "version": 1,
            "title": "Add Custom Field Images to the Rank Math Sitemap",
            "description": "Adds images from a gallery custom field (attachment IDs) to each post's image entries in the Rank Math sitemap.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Meta key holding attachment IDs.\n$wpide_rm_gallery_field = 'gallery';\n\nadd_filter( 'rank_math/sitemap/urlimages', function ( $images, $post_id ) use ( $wpide_rm_gallery_field ) {\n    $ids = get_post_meta( $post_id, $wpide_rm_gallery_field, true );\n    $ids = is_array( $ids ) ? $ids : explode( ',', (string) $ids );\n\n    foreach ( array_filter( array_map( 'absint', $ids ) ) as $id ) {\n        $src = wp_get_attachment_image_url( $id, 'full' );\n\n        if ( $src ) {\n            $images[] = array( 'src' => $src, 'title' => get_the_title( $id ), 'alt' => (string) get_post_meta( $id, '_wp_attachment_image_alt', true ) );\n        }\n    }\n\n    return $images;\n}, 10, 2 );",
            "requires": [
                "rankmath"
            ],
            "notes": "The field may be an array of IDs or a comma-separated list."
        },
        {
            "id": "rankmath-sitemap-no-cache-staging",
            "version": 1,
            "title": "Turn Off Rank Math Sitemap Caching on Staging",
            "description": "Disables Rank Math's sitemap cache on local, development and staging sites, so sitemap changes show up immediately while you work.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "sitemap",
                "staging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/sitemap/enable_caching', function ( $enabled ) {\n    return 'production' === wp_get_environment_type() ? $enabled : false;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Relies on WP_ENVIRONMENT_TYPE; production sites keep the cache."
        },
        {
            "id": "rankmath-reading-time-variable",
            "version": 1,
            "title": "Reading Time Variable for Rank Math",
            "description": "Adds a %wpide_reading_time% variable to Rank Math, so titles, descriptions and social text can include an estimated reading time.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "variables",
                "reading-time"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed.\n$wpide_rm_wpm = 220;\n\nadd_action( 'rank_math/vars/register_extra_replacements', function () use ( $wpide_rm_wpm ) {\n    if ( ! function_exists( 'rank_math_register_var_replacement' ) ) {\n        return;\n    }\n\n    rank_math_register_var_replacement(\n        'wpide_reading_time',\n        array(\n            'name'        => 'Reading time',\n            'description' => 'Estimated reading time of the post',\n            'variable'    => 'wpide_reading_time',\n            'example'     => '4 min read',\n        ),\n        function () use ( $wpide_rm_wpm ) {\n            $post = get_post();\n\n            if ( ! $post ) {\n                return '';\n            }\n\n            $words = str_word_count( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) );\n\n            return sprintf( '%d min read', max( 1, (int) ceil( $words / max( 1, $wpide_rm_wpm ) ) ) );\n        }\n    );\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Type %wpide_reading_time% in any Rank Math title or description field."
        },
        {
            "id": "rankmath-noindex-paged-archives",
            "version": 1,
            "title": "Noindex Paginated Archives With Rank Math",
            "description": "Sets page 2 and later of archives to noindex, follow through Rank Math's robots meta, keeping the first page indexable.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "noindex",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/robots', function ( $robots ) {\n    if ( is_paged() && is_array( $robots ) ) {\n        $robots['index']  = 'noindex';\n        $robots['follow'] = 'follow';\n    }\n\n    return $robots;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-canonical-from-field",
            "version": 1,
            "title": "Rank Math Canonical From an \"Original URL\" Field",
            "description": "Replaces the canonical URL with one saved in a custom field on posts republished from elsewhere.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "canonical",
                "syndication"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Custom field holding the original URL.\n$wpide_rm_canonical_field = 'original_url';\n\nadd_filter( 'rank_math/frontend/canonical', function ( $canonical ) use ( $wpide_rm_canonical_field ) {\n    if ( ! is_singular() ) {\n        return $canonical;\n    }\n\n    $source = esc_url_raw( (string) get_post_meta( get_queried_object_id(), $wpide_rm_canonical_field, true ) );\n\n    return wp_http_validate_url( $source ) ? $source : $canonical;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Add a custom field named original_url with the full source URL."
        },
        {
            "id": "rankmath-description-excerpt-fallback",
            "version": 1,
            "title": "Rank Math Description Fallback From the Excerpt",
            "description": "Fills an empty Rank Math meta description on posts and pages with the excerpt or opening text, trimmed to about 155 characters.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "meta-description",
                "excerpt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Maximum description length.\n$wpide_rm_desc_length = 155;\n\nadd_filter( 'rank_math/frontend/description', function ( $description ) use ( $wpide_rm_desc_length ) {\n    if ( '' !== trim( (string) $description ) || ! is_singular() ) {\n        return $description;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post ) {\n        return $description;\n    }\n\n    $text = has_excerpt( $post ) ? $post->post_excerpt : strip_shortcodes( $post->post_content );\n    $text = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( $text ) ) );\n\n    if ( mb_strlen( $text ) > $wpide_rm_desc_length ) {\n        $text = rtrim( mb_substr( $text, 0, $wpide_rm_desc_length - 1 ), \" ,.;:-\" ) . '…';\n    }\n\n    return $text;\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Descriptions you write in Rank Math always win."
        },
        {
            "id": "rankmath-remove-admin-columns",
            "version": 1,
            "title": "Remove Rank Math Columns From Admin Lists",
            "description": "Hides the SEO details, SEO title and description columns Rank Math adds to post, page and product lists.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Rank Math column keys to hide.\n$wpide_rm_columns = array( 'rank_math_seo_details', 'rank_math_title', 'rank_math_description' );\n\nadd_action( 'current_screen', function ( $screen ) use ( $wpide_rm_columns ) {\n    if ( ! $screen || 'edit' !== $screen->base ) {\n        return;\n    }\n\n    add_filter( \"manage_{$screen->id}_columns\", function ( $columns ) use ( $wpide_rm_columns ) {\n        return array_diff_key( $columns, array_flip( $wpide_rm_columns ) );\n    }, 100 );\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Remove a key from the list to keep that column."
        },
        {
            "id": "rankmath-metabox-low-priority",
            "version": 1,
            "title": "Move the Rank Math Box Below Other Boxes",
            "description": "Lowers the Rank Math meta box priority in the classic editor so it appears after your other meta boxes.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "admin",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/metabox/priority', function () {\n    return 'low';\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-exclude-post-types",
            "version": 1,
            "title": "Hide Rank Math on Selected Post Types",
            "description": "Removes the Rank Math meta box and settings from post types that are never shown in search, such as internal records or form entries.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "admin",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types Rank Math should ignore.\n$wpide_rm_hidden_types = array( 'testimonial', 'faq' );\n\nadd_filter( 'rank_math/excluded_post_types', function ( $post_types ) use ( $wpide_rm_hidden_types ) {\n    return array_diff_key( (array) $post_types, array_flip( $wpide_rm_hidden_types ) );\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Use post type names; the type disappears from Rank Math's Titles & Meta settings too."
        },
        {
            "id": "rankmath-hide-metabox-roles",
            "version": 1,
            "title": "Rank Math Box for Editors and Administrators Only",
            "description": "Removes the Rank Math meta box for users who cannot publish pages, leaving SEO settings to editors and administrators.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "admin",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Users without this capability do not see the box.\n$wpide_rm_cap = 'publish_pages';\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_rm_cap ) {\n    if ( current_user_can( $wpide_rm_cap ) ) {\n        return;\n    }\n\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        remove_meta_box( 'rank_math_metabox', $post_type, 'normal' );\n    }\n}, 100 );",
            "requires": [
                "rankmath"
            ],
            "notes": "Rank Math's Role Manager module offers finer control if you enable it."
        },
        {
            "id": "rankmath-remove-admin-bar-menu",
            "version": 1,
            "title": "Remove the Rank Math Menu From the Admin Bar",
            "description": "Removes the Rank Math SEO item from the toolbar on the front end and in the admin.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "admin-bar",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    $bar->remove_node( 'rank-math' );\n}, 999 );",
            "requires": [
                "rankmath"
            ],
            "notes": ""
        },
        {
            "id": "rankmath-og-image-fallback-post-type",
            "version": 1,
            "title": "Rank Math Fallback Open Graph Image by Post Type",
            "description": "Supplies a fallback Open Graph image for chosen post types when Rank Math finds no social or featured image on a post.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "open-graph",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => fallback image URL.\n$wpide_rm_og_fallbacks = array(\n    'post' => home_url( '/wp-content/uploads/social-blog.jpg' ),\n);\n\nadd_filter( 'rank_math/opengraph/facebook/image', function ( $image ) use ( $wpide_rm_og_fallbacks ) {\n    if ( $image || ! is_singular( array_keys( $wpide_rm_og_fallbacks ) ) ) {\n        return $image;\n    }\n\n    return $wpide_rm_og_fallbacks[ get_post_type( get_queried_object_id() ) ];\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Use images of at least 1200 × 630 pixels."
        },
        {
            "id": "rankmath-show-meta-keywords",
            "version": 1,
            "title": "Output Rank Math Focus Keywords as Meta Keywords",
            "description": "Prints a meta keywords tag built from each post's Rank Math focus keywords, for internal search tools and older engines that still read it.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "meta",
                "keywords"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/show_keywords', '__return_true' );",
            "requires": [
                "rankmath"
            ],
            "notes": "Google ignores meta keywords, and competitors can read them."
        },
        {
            "id": "rankmath-remove-adjacent-links",
            "version": 1,
            "title": "Remove rel=\"next\" and rel=\"prev\" Links From Rank Math",
            "description": "Stops Rank Math from adding rel next and prev link tags on paginated archives and multi-page posts.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "pagination",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'rank_math/frontend/disable_adjacent_rel_links', '__return_true' );",
            "requires": [
                "rankmath"
            ],
            "notes": "Google no longer uses these links; Bing still reads them."
        },
        {
            "id": "rankmath-primary-category-shortcode",
            "version": 1,
            "title": "Primary Category Shortcode for Rank Math",
            "description": "Adds a [rm_primary_category] shortcode that shows the primary category chosen in Rank Math, or the first category when none is set.",
            "category": "integrations",
            "tags": [
                "rank-math",
                "categories",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'rm_primary_category', function ( $atts ) {\n    $atts     = shortcode_atts( array( 'taxonomy' => 'category', 'link' => 'yes' ), $atts, 'rm_primary_category' );\n    $post_id  = get_the_ID();\n    $taxonomy = sanitize_key( $atts['taxonomy'] );\n\n    if ( ! $post_id || ! taxonomy_exists( $taxonomy ) ) {\n        return '';\n    }\n\n    // Rank Math stores the primary term as rank_math_primary_{taxonomy}.\n    $term_id = (int) get_post_meta( $post_id, 'rank_math_primary_' . $taxonomy, true );\n    $term    = $term_id ? get_term( $term_id, $taxonomy ) : null;\n\n    if ( ! $term instanceof WP_Term || ! has_term( $term->term_id, $taxonomy, $post_id ) ) {\n        $terms = get_the_terms( $post_id, $taxonomy );\n        $term  = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0] : null;\n    }\n\n    if ( ! $term ) {\n        return '';\n    }\n\n    if ( 'no' === $atts['link'] ) {\n        return '<span class=\"primary-category\">' . esc_html( $term->name ) . '</span>';\n    }\n\n    return '<a class=\"primary-category\" href=\"' . esc_url( get_term_link( $term ) ) . '\">' . esc_html( $term->name ) . '</a>';\n} );",
            "requires": [
                "rankmath"
            ],
            "notes": "Use link=\"no\" for plain text, or taxonomy=\"product_cat\" for WooCommerce products."
        },
        {
            "id": "aioseo-disable-selected-pages",
            "version": 1,
            "title": "Turn Off All in One SEO Output on Selected Pages",
            "description": "Stops All in One SEO from printing any meta tags or schema on the pages you list, for pages managed by a landing page builder or another tool.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "seo",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of posts or pages without AIOSEO output.\n$wpide_aioseo_off_ids = array( 12, 34 );\n\nadd_filter( 'aioseo_disable', function ( $disabled ) use ( $wpide_aioseo_off_ids ) {\n    if ( is_singular() && in_array( get_queried_object_id(), array_map( 'intval', $wpide_aioseo_off_ids ), true ) ) {\n        return true;\n    }\n\n    return $disabled;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "List post or page IDs. The page title falls back to the theme's default."
        },
        {
            "id": "aioseo-schema-off-selected-pages",
            "version": 1,
            "title": "Turn Off All in One SEO Schema on Selected Pages",
            "description": "Removes the All in One SEO JSON-LD graph from the pages you list while keeping its titles and meta tags.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "schema",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs of posts or pages without AIOSEO schema.\n$wpide_aioseo_no_schema = array( 12, 34 );\n\nadd_filter( 'aioseo_schema_disable', function ( $disabled ) use ( $wpide_aioseo_no_schema ) {\n    if ( is_singular() && in_array( get_queried_object_id(), array_map( 'intval', $wpide_aioseo_no_schema ), true ) ) {\n        return true;\n    }\n\n    return $disabled;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": ""
        },
        {
            "id": "aioseo-schema-news-article",
            "version": 1,
            "title": "Use NewsArticle Schema for a Category in All in One SEO",
            "description": "Changes Article and BlogPosting graphs in All in One SEO's schema to NewsArticle for posts in the categories you choose.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "schema",
                "news"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Category slugs whose posts are news.\n$wpide_aioseo_news_cats = array( 'news' );\n\nadd_filter( 'aioseo_schema_output', function ( $graphs ) use ( $wpide_aioseo_news_cats ) {\n    if ( ! is_array( $graphs ) || ! is_singular( 'post' ) || ! has_category( $wpide_aioseo_news_cats, get_queried_object_id() ) ) {\n        return $graphs;\n    }\n\n    foreach ( $graphs as $i => $graph ) {\n        if ( isset( $graph['@type'] ) && in_array( $graph['@type'], array( 'Article', 'BlogPosting' ), true ) ) {\n            $graphs[ $i ]['@type'] = 'NewsArticle';\n        }\n    }\n\n    return $graphs;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Use category slugs."
        },
        {
            "id": "aioseo-schema-organization-contact",
            "version": 1,
            "title": "Add Address and Contact Point to All in One SEO's Organization",
            "description": "Extends the Organization graph in All in One SEO's JSON-LD with a postal address and a customer service contact point.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "schema",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your details.\n$wpide_aioseo_org = array(\n    'street'  => '123 Main Street',\n    'city'    => 'Springfield',\n    'region'  => 'IL',\n    'zip'     => '62701',\n    'country' => 'US',\n    'support' => '+1-555-010-0101',\n);\n\nadd_filter( 'aioseo_schema_output', function ( $graphs ) use ( $wpide_aioseo_org ) {\n    if ( ! is_array( $graphs ) ) {\n        return $graphs;\n    }\n\n    foreach ( $graphs as $i => $graph ) {\n        if ( ! isset( $graph['@type'] ) || 'Organization' !== $graph['@type'] ) {\n            continue;\n        }\n\n        if ( $wpide_aioseo_org['street'] ) {\n            $graphs[ $i ]['address'] = array(\n                '@type'           => 'PostalAddress',\n                'streetAddress'   => $wpide_aioseo_org['street'],\n                'addressLocality' => $wpide_aioseo_org['city'],\n                'addressRegion'   => $wpide_aioseo_org['region'],\n                'postalCode'      => $wpide_aioseo_org['zip'],\n                'addressCountry'  => $wpide_aioseo_org['country'],\n            );\n        }\n\n        if ( $wpide_aioseo_org['support'] ) {\n            $graphs[ $i ]['contactPoint'] = array(\n                '@type'       => 'ContactPoint',\n                'telephone'   => $wpide_aioseo_org['support'],\n                'contactType' => 'customer service',\n            );\n        }\n    }\n\n    return $graphs;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Fill in your details; empty values are skipped."
        },
        {
            "id": "aioseo-breadcrumb-hide-current",
            "version": 1,
            "title": "Remove the Current Page From All in One SEO Breadcrumbs",
            "description": "Drops the final crumb (the current page) from the All in One SEO breadcrumb trail.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "breadcrumbs",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'aioseo_breadcrumbs_trail', function ( $crumbs ) {\n    if ( is_array( $crumbs ) && count( $crumbs ) > 1 && ! is_front_page() ) {\n        array_pop( $crumbs );\n    }\n\n    return $crumbs;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "The homepage keeps its single crumb."
        },
        {
            "id": "aioseo-breadcrumb-rename-home",
            "version": 1,
            "title": "Rename \"Home\" in All in One SEO Breadcrumbs",
            "description": "Replaces the \"Home\" label in All in One SEO breadcrumbs with your own wording, such as your brand name.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "breadcrumbs",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// New label for the homepage crumb.\n$wpide_aioseo_home_label = 'Start';\n\nadd_filter( 'aioseo_breadcrumbs_trail', function ( $crumbs ) use ( $wpide_aioseo_home_label ) {\n    if ( ! is_array( $crumbs ) ) {\n        return $crumbs;\n    }\n\n    $home = untrailingslashit( home_url( '/' ) );\n\n    foreach ( $crumbs as $i => $crumb ) {\n        if ( is_array( $crumb ) && isset( $crumb['link'] ) && untrailingslashit( (string) $crumb['link'] ) === $home ) {\n            $crumbs[ $i ]['label'] = $wpide_aioseo_home_label;\n        }\n    }\n\n    return $crumbs;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Matches the crumb that links to your homepage, whatever its current label."
        },
        {
            "id": "aioseo-sitemap-exclude-ids",
            "version": 1,
            "title": "Exclude Specific Posts From the All in One SEO Sitemap",
            "description": "Keeps the posts and pages you list out of All in One SEO's XML sitemap.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "sitemap",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// IDs to leave out.\n$wpide_aioseo_sitemap_exclude = array( 12, 34 );\n\nadd_filter( 'aioseo_sitemap_exclude_posts', function ( $ids, $type ) use ( $wpide_aioseo_sitemap_exclude ) {\n    return array_unique( array_merge( (array) $ids, array_map( 'intval', $wpide_aioseo_sitemap_exclude ) ) );\n}, 10, 2 );",
            "requires": [
                "aioseo"
            ],
            "notes": "The posts stay indexable; only the sitemap entry is removed."
        },
        {
            "id": "aioseo-sitemap-exclude-terms",
            "version": 1,
            "title": "Leave Chosen Categories Out of the AIOSEO Sitemap",
            "description": "Removes the category or tag archives you list (by term ID) from All in One SEO's sitemap.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "sitemap",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Term IDs to leave out.\n$wpide_aioseo_terms_exclude = array( 5, 9 );\n\nadd_filter( 'aioseo_sitemap_exclude_terms', function ( $ids, $type ) use ( $wpide_aioseo_terms_exclude ) {\n    return array_unique( array_merge( (array) $ids, array_map( 'intval', $wpide_aioseo_terms_exclude ) ) );\n}, 10, 2 );",
            "requires": [
                "aioseo"
            ],
            "notes": "Find a term's ID in the edit link on Posts > Categories."
        },
        {
            "id": "aioseo-sitemap-additional-urls",
            "version": 1,
            "title": "Add Custom URLs to the All in One SEO Sitemap",
            "description": "Adds pages that WordPress does not manage, such as static files or app routes, to All in One SEO's sitemap.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "sitemap",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Paths to add, relative to the site root.\n$wpide_aioseo_extra_paths = array( '/app/', '/pricing-calculator/' );\n\nadd_filter( 'aioseo_sitemap_additional_pages', function ( $pages ) use ( $wpide_aioseo_extra_paths ) {\n    foreach ( $wpide_aioseo_extra_paths as $path ) {\n        $pages[] = array(\n            'loc'        => home_url( $path ),\n            'lastmod'    => gmdate( 'Y-m-d H:i:s' ),\n            'changefreq' => 'weekly',\n            'priority'   => 0.6,\n        );\n    }\n\n    return $pages;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Paths are relative to your homepage. AIOSEO Pro also has an \"Additional Pages\" setting."
        },
        {
            "id": "aioseo-sitemap-skip-old-posts",
            "version": 1,
            "title": "Leave Old Posts Out of the All in One SEO Sitemap",
            "description": "Removes post entries that have not been modified for several years from All in One SEO's post sitemap.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "sitemap",
                "crawl-budget"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Entries not modified for this many years are dropped.\n$wpide_aioseo_max_age = 3;\n\nadd_filter( 'aioseo_sitemap_posts', function ( $entries, $post_type ) use ( $wpide_aioseo_max_age ) {\n    if ( 'post' !== $post_type || ! is_array( $entries ) ) {\n        return $entries;\n    }\n\n    $cutoff = strtotime( '-' . absint( $wpide_aioseo_max_age ) . ' years' );\n\n    return array_values( array_filter( $entries, function ( $entry ) use ( $cutoff ) {\n        $time = isset( $entry['lastmod'] ) ? strtotime( (string) $entry['lastmod'] ) : false;\n\n        return ! $time || $time >= $cutoff;\n    } ) );\n}, 10, 2 );",
            "requires": [
                "aioseo"
            ],
            "notes": "Only the \"post\" type is filtered; pages and other types are kept."
        },
        {
            "id": "aioseo-description-excerpt-fallback",
            "version": 1,
            "title": "Use the Excerpt When an All in One SEO Description Is Empty",
            "description": "Fills an empty All in One SEO meta description on single posts and pages with the excerpt or opening text.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "meta-description",
                "excerpt"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Maximum description length.\n$wpide_aioseo_desc_length = 155;\n\nadd_filter( 'aioseo_description', function ( $description ) use ( $wpide_aioseo_desc_length ) {\n    if ( '' !== trim( (string) $description ) || ! is_singular() ) {\n        return $description;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post ) {\n        return $description;\n    }\n\n    $text = has_excerpt( $post ) ? $post->post_excerpt : strip_shortcodes( $post->post_content );\n    $text = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( $text ) ) );\n\n    if ( mb_strlen( $text ) > $wpide_aioseo_desc_length ) {\n        $text = rtrim( mb_substr( $text, 0, $wpide_aioseo_desc_length - 1 ), \" ,.;:-\" ) . '…';\n    }\n\n    return $text;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Descriptions you write in AIOSEO always win."
        },
        {
            "id": "aioseo-noindex-paged-archives",
            "version": 1,
            "title": "Noindex Paginated Archives With All in One SEO",
            "description": "Adds noindex to page 2 and later of archives through All in One SEO's robots meta while keeping links followed.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "noindex",
                "pagination"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'aioseo_robots_meta', function ( $attributes ) {\n    if ( is_paged() && is_array( $attributes ) ) {\n        $attributes['noindex'] = 'noindex';\n        unset( $attributes['index'], $attributes['nofollow'] );\n    }\n\n    return $attributes;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": ""
        },
        {
            "id": "aioseo-canonical-from-field",
            "version": 1,
            "title": "Point the All in One SEO Canonical to an Original Source",
            "description": "Uses a URL stored in a custom field as the canonical link on republished posts instead of All in One SEO's default.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "canonical",
                "syndication"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Custom field holding the original URL.\n$wpide_aioseo_canonical_field = 'original_url';\n\nadd_filter( 'aioseo_canonical_url', function ( $url ) use ( $wpide_aioseo_canonical_field ) {\n    if ( ! is_singular() ) {\n        return $url;\n    }\n\n    $source = esc_url_raw( (string) get_post_meta( get_queried_object_id(), $wpide_aioseo_canonical_field, true ) );\n\n    return wp_http_validate_url( $source ) ? $source : $url;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Add a custom field named original_url with the full source URL."
        },
        {
            "id": "aioseo-og-image-fallback-post-type",
            "version": 1,
            "title": "Default Social Image per Post Type in All in One SEO",
            "description": "Sets a fallback og:image for chosen post types when All in One SEO finds no image for a post.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "open-graph",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type => fallback image URL.\n$wpide_aioseo_og_fallbacks = array(\n    'post' => home_url( '/wp-content/uploads/social-blog.jpg' ),\n);\n\nadd_filter( 'aioseo_facebook_tags', function ( $tags ) use ( $wpide_aioseo_og_fallbacks ) {\n    if ( ! is_array( $tags ) || ! empty( $tags['og:image'] ) || ! is_singular( array_keys( $wpide_aioseo_og_fallbacks ) ) ) {\n        return $tags;\n    }\n\n    $tags['og:image'] = $wpide_aioseo_og_fallbacks[ get_post_type( get_queried_object_id() ) ];\n\n    return $tags;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "Use images of at least 1200 × 630 pixels."
        },
        {
            "id": "aioseo-twitter-summary-card",
            "version": 1,
            "title": "Use the Small Twitter Card in All in One SEO",
            "description": "Switches the X (Twitter) card All in One SEO outputs to the compact summary card with a square thumbnail.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "twitter",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'aioseo_twitter_tags', function ( $tags ) {\n    if ( is_array( $tags ) ) {\n        $tags['twitter:card'] = 'summary';\n    }\n\n    return $tags;\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": ""
        },
        {
            "id": "aioseo-hide-admin-bar-menu",
            "version": 1,
            "title": "Remove the All in One SEO Menu From the Admin Bar",
            "description": "Hides the AIOSEO item in the toolbar on the front end and in the admin.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "admin-bar",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'aioseo_show_in_admin_bar', '__return_false' );",
            "requires": [
                "aioseo"
            ],
            "notes": ""
        },
        {
            "id": "aioseo-hide-flyout-menu",
            "version": 1,
            "title": "Hide the All in One SEO Help Flyout",
            "description": "Removes the floating help and upgrade button All in One SEO shows in the corner of its admin screens.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "admin",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'aioseo_flyout_menu_enable', '__return_false' );",
            "requires": [
                "aioseo"
            ],
            "notes": ""
        },
        {
            "id": "aioseo-remove-admin-columns",
            "version": 1,
            "title": "Remove the All in One SEO Column From Admin Lists",
            "description": "Hides the AIOSEO details column (score, title and description) from post, page and custom post type lists.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'current_screen', function ( $screen ) {\n    if ( ! $screen || 'edit' !== $screen->base ) {\n        return;\n    }\n\n    add_filter( \"manage_{$screen->id}_columns\", function ( $columns ) {\n        unset( $columns['aioseo-details'] );\n\n        return $columns;\n    }, 100 );\n} );",
            "requires": [
                "aioseo"
            ],
            "notes": "You can also hide it per user in Screen Options; this hides it for everyone."
        },
        {
            "id": "aioseo-hide-metabox-roles",
            "version": 1,
            "title": "Hide the All in One SEO Box From Authors and Contributors",
            "description": "Removes the AIOSEO settings box from the editor for users who cannot publish pages.",
            "category": "integrations",
            "tags": [
                "aioseo",
                "admin",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Users without this capability do not see the box.\n$wpide_aioseo_cap = 'publish_pages';\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_aioseo_cap ) {\n    if ( current_user_can( $wpide_aioseo_cap ) ) {\n        return;\n    }\n\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        remove_meta_box( 'aioseo-settings', $post_type, 'normal' );\n    }\n}, 100 );",
            "requires": [
                "aioseo"
            ],
            "notes": "AIOSEO Pro's Access Control settings offer finer control."
        },
        {
            "id": "acf-google-maps-key-constant",
            "version": 1,
            "title": "ACF Google Maps API Key From wp-config.php",
            "description": "Gives Advanced Custom Fields' Google Map field your API key from a constant in wp-config.php, so the key never sits in the database or theme files.",
            "category": "post-types",
            "tags": [
                "acf",
                "google-maps",
                "api-key"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'acf/fields/google_map/api', function ( $api ) {\n    if ( defined( 'WPIDE_GOOGLE_MAPS_KEY' ) && WPIDE_GOOGLE_MAPS_KEY ) {\n        $api['key'] = WPIDE_GOOGLE_MAPS_KEY;\n    }\n\n    return $api;\n} );\n\nadd_action( 'acf/init', function () {\n    if ( defined( 'WPIDE_GOOGLE_MAPS_KEY' ) && WPIDE_GOOGLE_MAPS_KEY && function_exists( 'acf_update_setting' ) ) {\n        acf_update_setting( 'google_api_key', WPIDE_GOOGLE_MAPS_KEY );\n    }\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Add define( 'WPIDE_GOOGLE_MAPS_KEY', 'your-key' ); to wp-config.php and restrict the key to your domain in Google Cloud."
        },
        {
            "id": "acf-json-sync-folder",
            "version": 1,
            "title": "Save ACF Field Groups as JSON Outside the Theme",
            "description": "Stores Advanced Custom Fields field groups, post types and options pages as JSON files in wp-content/acf-json, so they survive theme changes and can go into version control.",
            "category": "post-types",
            "tags": [
                "acf",
                "json",
                "version-control"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Folder for the JSON files.\n$wpide_acf_json_dir = WP_CONTENT_DIR . '/acf-json';\n\nadd_filter( 'acf/settings/save_json', function () use ( $wpide_acf_json_dir ) {\n    if ( ! is_dir( $wpide_acf_json_dir ) && wp_mkdir_p( $wpide_acf_json_dir ) ) {\n        // Prevent directory listings.\n        file_put_contents( $wpide_acf_json_dir . '/index.php', \"<?php\\n// Silence is golden.\\n\" );\n    }\n\n    return $wpide_acf_json_dir;\n} );\n\nadd_filter( 'acf/settings/load_json', function ( $paths ) use ( $wpide_acf_json_dir ) {\n    $paths[] = $wpide_acf_json_dir;\n\n    return array_unique( $paths );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "After activating, open each field group and click Update once to write its file. Change $wpide_acf_json_dir to use another folder."
        },
        {
            "id": "acf-options-pages",
            "version": 1,
            "title": "Add an ACF Site Settings Options Page",
            "description": "Registers a \"Site Settings\" options page with Header and Footer sub-pages in the admin, ready for ACF field groups that hold site-wide values.",
            "category": "post-types",
            "tags": [
                "acf",
                "options-page",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'acf/init', function () {\n    if ( ! function_exists( 'acf_add_options_page' ) ) {\n        return;\n    }\n\n    acf_add_options_page( array(\n        'page_title' => 'Site Settings',\n        'menu_title' => 'Site Settings',\n        'menu_slug'  => 'site-settings',\n        'capability' => 'edit_theme_options',\n        'icon_url'   => 'dashicons-admin-generic',\n        'position'   => 61,\n        'redirect'   => false,\n    ) );\n\n    foreach ( array( 'Header', 'Footer' ) as $section ) {\n        acf_add_options_sub_page( array(\n            'page_title'  => $section . ' Settings',\n            'menu_title'  => $section,\n            'parent_slug' => 'site-settings',\n            'capability'  => 'edit_theme_options',\n        ) );\n    }\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Options pages need ACF PRO. Assign field groups with the rule \"Options Page is equal to Site Settings\" and read values with get_field( 'name', 'option' )."
        },
        {
            "id": "acf-option-field-shortcode",
            "version": 1,
            "title": "Shortcode for ACF Options Page Fields",
            "description": "Adds an [acf_option name=\"phone\"] shortcode that prints a value from an ACF options page anywhere, such as a phone number or address in widgets and footers.",
            "category": "post-types",
            "tags": [
                "acf",
                "options-page",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_option', function ( $atts ) {\n    $atts = shortcode_atts( array( 'name' => '', 'default' => '' ), $atts, 'acf_option' );\n    $name = sanitize_key( $atts['name'] );\n\n    if ( '' === $name || ! function_exists( 'get_field' ) ) {\n        return '';\n    }\n\n    $value = get_field( $name, 'option' );\n\n    if ( is_array( $value ) ) {\n        $value = isset( $value['label'] ) ? $value['label'] : implode( ', ', array_filter( $value, 'is_scalar' ) );\n    }\n\n    if ( is_bool( $value ) ) {\n        $value = $value ? 'Yes' : 'No';\n    }\n\n    return esc_html( '' === (string) $value ? $atts['default'] : (string) $value );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Text, number, email, URL, select and true/false fields work. Use default=\"...\" for a fallback."
        },
        {
            "id": "acf-enable-shortcode",
            "version": 1,
            "title": "Turn the [acf] Shortcode Back On",
            "description": "Re-enables Advanced Custom Fields' own [acf field=\"...\"] shortcode, which ACF 6.3 switched off by default for security.",
            "category": "post-types",
            "tags": [
                "acf",
                "shortcode",
                "compatibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'acf/init', function () {\n    if ( function_exists( 'acf_update_setting' ) ) {\n        acf_update_setting( 'enable_shortcode', true );\n    }\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Only turn this on if you trust everyone who can write content, since the shortcode can read any field of any post."
        },
        {
            "id": "acf-smart-field-shortcode",
            "version": 1,
            "title": "Display Any ACF Field With a Safe Shortcode",
            "description": "Adds a [field_value name=\"...\"] shortcode that prints an ACF field of the current post and handles images, links, choices, dates and post objects sensibly.",
            "category": "post-types",
            "tags": [
                "acf",
                "shortcode",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'field_value', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => '', 'post_id' => 0, 'size' => 'medium', 'sep' => ', ' ), $atts, 'field_value' );\n    $name    = sanitize_key( $atts['name'] );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( '' === $name || ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $value = get_field( $name, $post_id );\n\n    $render = function ( $v ) use ( $atts, &$render ) {\n        if ( $v instanceof WP_Post ) {\n            return '<a href=\"' . esc_url( get_permalink( $v ) ) . '\">' . esc_html( get_the_title( $v ) ) . '</a>';\n        }\n\n        if ( $v instanceof WP_Term ) {\n            return '<a href=\"' . esc_url( get_term_link( $v ) ) . '\">' . esc_html( $v->name ) . '</a>';\n        }\n\n        if ( is_array( $v ) ) {\n            if ( isset( $v['ID'], $v['url'] ) && isset( $v['mime_type'] ) && 0 === strpos( $v['mime_type'], 'image/' ) ) {\n                return wp_get_attachment_image( (int) $v['ID'], sanitize_key( $atts['size'] ) );\n            }\n\n            if ( isset( $v['url'], $v['title'] ) ) {\n                $target = ! empty( $v['target'] ) ? ' target=\"_blank\" rel=\"noopener\"' : '';\n\n                return '<a href=\"' . esc_url( $v['url'] ) . '\"' . $target . '>' . esc_html( $v['title'] ? $v['title'] : $v['url'] ) . '</a>';\n            }\n\n            if ( isset( $v['label'] ) ) {\n                return esc_html( $v['label'] );\n            }\n\n            return implode( esc_html( $atts['sep'] ), array_map( $render, $v ) );\n        }\n\n        if ( is_bool( $v ) ) {\n            return $v ? 'Yes' : 'No';\n        }\n\n        return esc_html( (string) $v );\n    };\n\n    return null === $value || '' === $value || false === $value ? '' : '<span class=\"field-' . esc_attr( $name ) . '\">' . $render( $value ) . '</span>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Only fields of public posts are shown. Use post_id=\"123\" for another post and size=\"medium\" for image fields."
        },
        {
            "id": "acf-image-field-shortcode",
            "version": 1,
            "title": "ACF Image Field Shortcode",
            "description": "Adds an [acf_image name=\"hero\" size=\"large\"] shortcode that outputs a responsive image from an ACF image field, whatever return format the field uses.",
            "category": "post-types",
            "tags": [
                "acf",
                "images",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_image', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => '', 'size' => 'large', 'post_id' => 0, 'class' => '' ), $atts, 'acf_image' );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $value = get_field( sanitize_key( $atts['name'] ), $post_id, false ); // Raw value: the attachment ID.\n    $id    = is_array( $value ) ? (int) ( $value['ID'] ?? 0 ) : absint( $value );\n\n    if ( ! $id ) {\n        return '';\n    }\n\n    return wp_get_attachment_image( $id, sanitize_key( $atts['size'] ), false, array( 'class' => sanitize_html_class( $atts['class'] ) ) );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "The image's alt text comes from the Media Library. Add class=\"...\" for styling."
        },
        {
            "id": "acf-repeater-list-shortcode",
            "version": 1,
            "title": "Show an ACF Repeater as a List",
            "description": "Adds an [acf_list name=\"features\" sub=\"text\"] shortcode that turns a repeater field into a bulleted list, with an optional second sub field shown in bold.",
            "category": "post-types",
            "tags": [
                "acf",
                "repeater",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_list', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => '', 'sub' => '', 'label' => '', 'post_id' => 0 ), $atts, 'acf_list' );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $rows = get_field( sanitize_key( $atts['name'] ), $post_id );\n\n    if ( ! is_array( $rows ) || ! $rows ) {\n        return '';\n    }\n\n    $sub   = sanitize_key( $atts['sub'] );\n    $label = sanitize_key( $atts['label'] );\n    $items = '';\n\n    foreach ( $rows as $row ) {\n        if ( ! is_array( $row ) || ! isset( $row[ $sub ] ) || ! is_scalar( $row[ $sub ] ) ) {\n            continue;\n        }\n\n        $prefix = ( $label && isset( $row[ $label ] ) && is_scalar( $row[ $label ] ) ) ? '<strong>' . esc_html( $row[ $label ] ) . ':</strong> ' : '';\n        $items .= '<li>' . $prefix . esc_html( $row[ $sub ] ) . '</li>';\n    }\n\n    return $items ? '<ul class=\"acf-list acf-list-' . esc_attr( sanitize_html_class( $atts['name'] ) ) . '\">' . $items . '</ul>' : '';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Example: [acf_list name=\"specs\" label=\"name\" sub=\"value\"]. Repeaters need ACF PRO."
        },
        {
            "id": "acf-gallery-grid-shortcode",
            "version": 1,
            "title": "ACF Gallery Field as a Responsive Grid",
            "description": "Adds an [acf_gallery name=\"gallery\"] shortcode that shows an ACF gallery field as a lazy-loaded, responsive image grid linking to the full-size files.",
            "category": "post-types",
            "tags": [
                "acf",
                "gallery",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_gallery', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => 'gallery', 'columns' => 3, 'size' => 'medium_large', 'post_id' => 0 ), $atts, 'acf_gallery' );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $ids = get_field( sanitize_key( $atts['name'] ), $post_id, false ); // Raw value: attachment IDs.\n    $ids = array_filter( array_map( 'absint', is_array( $ids ) ? $ids : explode( ',', (string) $ids ) ) );\n\n    if ( ! $ids ) {\n        return '';\n    }\n\n    $cols = min( 6, max( 1, absint( $atts['columns'] ) ) );\n    $html = '<div class=\"acf-gallery-grid\" style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(min(100%,' . (int) floor( 960 / $cols ) . 'px),1fr))\">';\n\n    foreach ( $ids as $id ) {\n        $full = wp_get_attachment_image_url( $id, 'full' );\n\n        if ( $full ) {\n            $html .= '<a href=\"' . esc_url( $full ) . '\">' . wp_get_attachment_image( $id, sanitize_key( $atts['size'] ), false, array( 'loading' => 'lazy', 'style' => 'width:100%;height:auto;display:block' ) ) . '</a>';\n        }\n    }\n\n    return $html . '</div>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Gallery fields need ACF PRO. Set columns=\"4\" for a wider grid; on phones it drops to two columns."
        },
        {
            "id": "acf-link-button-shortcode",
            "version": 1,
            "title": "ACF Link Field as a Button",
            "description": "Adds an [acf_button name=\"cta\"] shortcode that renders an ACF link field as a theme-styled button, respecting the \"open in new tab\" choice.",
            "category": "post-types",
            "tags": [
                "acf",
                "buttons",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_button', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => 'cta', 'post_id' => 0 ), $atts, 'acf_button' );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $link = get_field( sanitize_key( $atts['name'] ), $post_id );\n\n    if ( is_string( $link ) && $link ) {\n        $link = array( 'url' => $link, 'title' => '', 'target' => '' );\n    }\n\n    if ( ! is_array( $link ) || empty( $link['url'] ) ) {\n        return '';\n    }\n\n    $title  = ! empty( $link['title'] ) ? $link['title'] : 'Learn more';\n    $target = ! empty( $link['target'] ) ? ' target=\"_blank\" rel=\"noopener\"' : '';\n\n    return '<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"' . esc_url( $link['url'] ) . '\"' . $target . '>' . esc_html( $title ) . '</a></div>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Uses the block editor's button classes, so it matches your theme's buttons."
        },
        {
            "id": "acf-hide-admin-production",
            "version": 1,
            "title": "Hide the ACF Menu on Production Sites",
            "description": "Removes the Advanced Custom Fields menu on the live site so field groups can only be edited on development or staging, where changes are synced through JSON.",
            "category": "post-types",
            "tags": [
                "acf",
                "admin",
                "staging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'acf/settings/show_admin', function ( $show ) {\n    return 'production' === wp_get_environment_type() ? false : $show;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Set WP_ENVIRONMENT_TYPE to \"staging\" or \"development\" on other copies of the site. Fields keep working on production."
        },
        {
            "id": "acf-restore-custom-fields-box",
            "version": 1,
            "title": "Bring Back the Custom Fields Box With ACF",
            "description": "Shows WordPress's own Custom Fields meta box again, which Advanced Custom Fields hides by default to speed up the editor.",
            "category": "post-types",
            "tags": [
                "acf",
                "custom-fields",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'acf/settings/remove_wp_meta_box', '__return_false' );",
            "requires": [
                "acf"
            ],
            "notes": "Turn on \"Custom fields\" under Preferences > Panels in the block editor too."
        },
        {
            "id": "acf-validate-phone",
            "version": 1,
            "title": "Validate Phone Numbers in an ACF Field",
            "description": "Checks a phone number field when a post is saved and shows an error unless it has 7 to 15 digits, optionally with +, spaces, dashes and brackets.",
            "category": "post-types",
            "tags": [
                "acf",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Name of the phone field.\n$wpide_acf_phone_field = 'phone';\n\nadd_filter( 'acf/validate_value/name=' . $wpide_acf_phone_field, function ( $valid, $value ) {\n    if ( true !== $valid || '' === trim( (string) $value ) ) {\n        return $valid;\n    }\n\n    $digits = preg_replace( '/\\D/', '', (string) $value );\n\n    if ( ! preg_match( '/^\\+?[\\d\\s().-]+$/', (string) $value ) || strlen( $digits ) < 7 || strlen( $digits ) > 15 ) {\n        return 'Enter a valid phone number, for example +1 555 010 0199.';\n    }\n\n    return $valid;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Change $wpide_acf_phone_field to your field's name. Empty values pass unless the field is required."
        },
        {
            "id": "acf-validate-end-after-start",
            "version": 1,
            "title": "Make an ACF End Date Come After the Start Date",
            "description": "Blocks saving when an end date field is earlier than its start date field, such as for events, offers or bookings.",
            "category": "post-types",
            "tags": [
                "acf",
                "validation",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// End date field name, and the start date field's key.\n$wpide_acf_end_field = 'end_date';\n$wpide_acf_start_key = 'field_start_date';\n\nadd_filter( 'acf/validate_value/name=' . $wpide_acf_end_field, function ( $valid, $value ) use ( $wpide_acf_start_key ) {\n    if ( true !== $valid || '' === (string) $value || empty( $_POST['acf'][ $wpide_acf_start_key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- ACF verifies its own nonce.\n        return $valid;\n    }\n\n    $start = sanitize_text_field( wp_unslash( $_POST['acf'][ $wpide_acf_start_key ] ) ); // phpcs:ignore WordPress.Security.NonceVerification\n\n    // ACF date and date-time pickers submit sortable values (Ymd or Y-m-d H:i:s).\n    if ( strtotime( (string) $value ) < strtotime( $start ) ) {\n        return 'The end date must be on or after the start date.';\n    }\n\n    return $valid;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field names and the start field's key (shown under each field when \"Field Keys\" is ticked in Screen Options)."
        },
        {
            "id": "acf-validate-unique-value",
            "version": 1,
            "title": "Require a Unique Value in an ACF Field",
            "description": "Prevents two posts from sharing the same value in a field, such as a product code, membership number or reference ID.",
            "category": "post-types",
            "tags": [
                "acf",
                "validation",
                "unique"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field that must be unique, and the post type it belongs to.\n$wpide_acf_unique_field = 'reference_code';\n$wpide_acf_unique_type  = 'post';\n\nadd_filter( 'acf/validate_value/name=' . $wpide_acf_unique_field, function ( $valid, $value ) use ( $wpide_acf_unique_field, $wpide_acf_unique_type ) {\n    if ( true !== $valid || '' === trim( (string) $value ) ) {\n        return $valid;\n    }\n\n    $current = isset( $_POST['_acf_post_id'] ) ? absint( $_POST['_acf_post_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification -- ACF verifies its own nonce.\n\n    $existing = get_posts( array(\n        'post_type'      => $wpide_acf_unique_type,\n        'post_status'    => array( 'publish', 'pending', 'draft', 'future', 'private' ),\n        'meta_key'       => $wpide_acf_unique_field,\n        'meta_value'     => sanitize_text_field( (string) $value ),\n        'post__not_in'   => $current ? array( $current ) : array(),\n        'fields'         => 'ids',\n        'posts_per_page' => 1,\n        'no_found_rows'  => true,\n    ) );\n\n    return $existing ? sprintf( 'This value is already used by \"%s\".', get_the_title( $existing[0] ) ) : $valid;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field name and post type. The check ignores trashed posts and the post being edited."
        },
        {
            "id": "acf-validate-word-limit",
            "version": 1,
            "title": "Word Limit for an ACF Text Area",
            "description": "Shows an error when a text area or WYSIWYG field goes over a set number of words, which keeps summaries and teasers short.",
            "category": "post-types",
            "tags": [
                "acf",
                "validation",
                "word-count"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field name and maximum words.\n$wpide_acf_words_field = 'summary';\n$wpide_acf_words_max   = 60;\n\nadd_filter( 'acf/validate_value/name=' . $wpide_acf_words_field, function ( $valid, $value ) use ( $wpide_acf_words_max ) {\n    if ( true !== $valid ) {\n        return $valid;\n    }\n\n    $words = str_word_count( wp_strip_all_tags( (string) $value ) );\n\n    if ( $words > $wpide_acf_words_max ) {\n        return sprintf( 'Please keep this under %1$d words (currently %2$d).', $wpide_acf_words_max, $words );\n    }\n\n    return $valid;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field name and limit. ACF's own character limit counts characters, not words."
        },
        {
            "id": "acf-image-min-dimensions",
            "version": 1,
            "title": "Minimum Image Size for an ACF Image Field",
            "description": "Rejects uploads to a chosen ACF image field when the picture is smaller than the width and height you need, such as for hero banners.",
            "category": "post-types",
            "tags": [
                "acf",
                "images",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field name and minimum size in pixels.\n$wpide_acf_image_field = 'hero_image';\n$wpide_acf_min_width   = 1600;\n$wpide_acf_min_height  = 600;\n\nadd_filter( 'acf/upload_prefilter/name=' . $wpide_acf_image_field, function ( $errors, $file ) use ( $wpide_acf_min_width, $wpide_acf_min_height ) {\n    $size = ! empty( $file['tmp_name'] ) ? wp_getimagesize( $file['tmp_name'] ) : false;\n\n    if ( $size && ( $size[0] < $wpide_acf_min_width || $size[1] < $wpide_acf_min_height ) ) {\n        $errors['min_dimensions'] = sprintf( 'Images must be at least %1$d × %2$d pixels; this one is %3$d × %4$d.', $wpide_acf_min_width, $wpide_acf_min_height, $size[0], $size[1] );\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Applies to new uploads through the field. ACF also has min width and height settings for images picked from the library."
        },
        {
            "id": "acf-sortable-admin-column",
            "version": 1,
            "title": "Sortable Admin Column From an ACF Field",
            "description": "Adds a column showing an ACF field to a post type's admin list and lets you sort the list by it, for values like price, date or priority.",
            "category": "post-types",
            "tags": [
                "acf",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type, field name, column label and how to sort.\n$wpide_acf_col = array(\n    'post_type' => 'post',\n    'field'     => 'price',\n    'label'     => 'Price',\n    'orderby'   => 'meta_value_num',\n);\n\nadd_filter( \"manage_{$wpide_acf_col['post_type']}_posts_columns\", function ( $columns ) use ( $wpide_acf_col ) {\n    $columns[ 'acf_' . $wpide_acf_col['field'] ] = $wpide_acf_col['label'];\n\n    return $columns;\n} );\n\nadd_action( \"manage_{$wpide_acf_col['post_type']}_posts_custom_column\", function ( $column, $post_id ) use ( $wpide_acf_col ) {\n    if ( 'acf_' . $wpide_acf_col['field'] !== $column || ! function_exists( 'get_field' ) ) {\n        return;\n    }\n\n    $value = get_field( $wpide_acf_col['field'], $post_id );\n\n    if ( is_array( $value ) ) {\n        $value = isset( $value['label'] ) ? $value['label'] : implode( ', ', array_filter( $value, 'is_scalar' ) );\n    }\n\n    echo '' === (string) $value ? '—' : esc_html( (string) $value );\n}, 10, 2 );\n\nadd_filter( \"manage_edit-{$wpide_acf_col['post_type']}_sortable_columns\", function ( $columns ) use ( $wpide_acf_col ) {\n    $columns[ 'acf_' . $wpide_acf_col['field'] ] = 'acf_' . $wpide_acf_col['field'];\n\n    return $columns;\n} );\n\n// Sort with a LEFT JOIN so posts without a value stay in the list (at the end).\nadd_filter( 'posts_clauses', function ( $clauses, $query ) use ( $wpide_acf_col ) {\n    global $wpdb;\n\n    if ( ! is_admin() || ! $query->is_main_query() || 'acf_' . $wpide_acf_col['field'] !== $query->get( 'orderby' ) ) {\n        return $clauses;\n    }\n\n    $order = 'DESC' === strtoupper( (string) $query->get( 'order' ) ) ? 'DESC' : 'ASC';\n    $value = 'meta_value_num' === $wpide_acf_col['orderby'] ? 'CAST(wpide_acf_sort.meta_value AS DECIMAL(20,4))' : 'wpide_acf_sort.meta_value';\n\n    $clauses['join']   .= $wpdb->prepare( \" LEFT JOIN {$wpdb->postmeta} AS wpide_acf_sort ON ( wpide_acf_sort.post_id = {$wpdb->posts}.ID AND wpide_acf_sort.meta_key = %s )\", $wpide_acf_col['field'] );\n    $clauses['orderby'] = \"( wpide_acf_sort.meta_value IS NULL OR wpide_acf_sort.meta_value = '' ) ASC, {$value} {$order}\";\n\n    return $clauses;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the post type, field name and column label. Use 'meta_value_num' for numbers and Ymd dates, 'meta_value' for text. Posts without a value are listed last."
        },
        {
            "id": "acf-image-admin-column",
            "version": 1,
            "title": "Thumbnail Column From an ACF Image Field",
            "description": "Shows a small preview of an ACF image field in a post type's admin list, handy for logos, headshots or product photos that are not the featured image.",
            "category": "post-types",
            "tags": [
                "acf",
                "admin",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type and image field name.\n$wpide_acf_img_type  = 'post';\n$wpide_acf_img_field = 'logo';\n\nadd_filter( \"manage_{$wpide_acf_img_type}_posts_columns\", function ( $columns ) {\n    return array_slice( $columns, 0, 1, true ) + array( 'acf_thumb' => '<span class=\"screen-reader-text\">Image</span>' ) + array_slice( $columns, 1, null, true );\n} );\n\nadd_action( \"manage_{$wpide_acf_img_type}_posts_custom_column\", function ( $column, $post_id ) use ( $wpide_acf_img_field ) {\n    if ( 'acf_thumb' !== $column || ! function_exists( 'get_field' ) ) {\n        return;\n    }\n\n    $value = get_field( $wpide_acf_img_field, $post_id, false );\n    $id    = is_array( $value ) ? (int) ( $value['ID'] ?? 0 ) : absint( $value );\n\n    echo $id ? wp_get_attachment_image( $id, array( 48, 48 ), false, array( 'style' => 'width:48px;height:48px;object-fit:cover;border-radius:4px' ) ) : '';\n}, 10, 2 );\n\nadd_action( 'admin_head', function () use ( $wpide_acf_img_type ) {\n    $screen = get_current_screen();\n\n    if ( $screen && 'edit-' . $wpide_acf_img_type === $screen->id ) {\n        echo '<style>.column-acf_thumb{width:56px}</style>';\n    }\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Set the post type and the image field name."
        },
        {
            "id": "acf-admin-filter-select",
            "version": 1,
            "title": "Filter an Admin List by an ACF Select Field",
            "description": "Adds a dropdown above a post type's admin list to show only posts with a chosen value of an ACF select, radio or button group field.",
            "category": "post-types",
            "tags": [
                "acf",
                "admin",
                "filters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type and choice field name.\n$wpide_acf_filter_type  = 'post';\n$wpide_acf_filter_field = 'status_label';\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_acf_filter_type, $wpide_acf_filter_field ) {\n    if ( $wpide_acf_filter_type !== $post_type || ! function_exists( 'acf_get_field' ) ) {\n        return;\n    }\n\n    $field = acf_get_field( $wpide_acf_filter_field );\n\n    if ( ! $field || empty( $field['choices'] ) ) {\n        return;\n    }\n\n    $current = isset( $_GET['acf_filter'] ) ? sanitize_text_field( wp_unslash( $_GET['acf_filter'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<label class=\"screen-reader-text\" for=\"acf-filter\">' . esc_html( $field['label'] ) . '</label><select name=\"acf_filter\" id=\"acf-filter\"><option value=\"\">All ' . esc_html( strtolower( $field['label'] ) ) . '</option>';\n\n    foreach ( $field['choices'] as $value => $label ) {\n        printf( '<option value=\"%s\"%s>%s</option>', esc_attr( $value ), selected( $current, (string) $value, false ), esc_html( $label ) );\n    }\n\n    echo '</select>';\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_acf_filter_type, $wpide_acf_filter_field ) {\n    if ( ! is_admin() || ! $query->is_main_query() || $wpide_acf_filter_type !== $query->get( 'post_type' ) || empty( $_GET['acf_filter'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array(\n        'key'   => $wpide_acf_filter_field,\n        'value' => sanitize_text_field( wp_unslash( $_GET['acf_filter'] ) ), // phpcs:ignore WordPress.Security.NonceVerification\n    );\n\n    $query->set( 'meta_query', $meta_query );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Set the post type and field name. The choices come from the field's own settings."
        },
        {
            "id": "acf-rest-field-allowlist",
            "version": 1,
            "title": "Expose Chosen ACF Fields in the REST API",
            "description": "Adds an \"acf_fields\" property with the ACF fields you list to the REST API responses of chosen post types, for headless front ends and apps.",
            "category": "post-types",
            "tags": [
                "acf",
                "rest-api",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types and the field names to expose.\n$wpide_acf_rest_types  = array( 'post', 'page' );\n$wpide_acf_rest_fields = array( 'subtitle', 'price' );\n\nadd_action( 'rest_api_init', function () use ( $wpide_acf_rest_types, $wpide_acf_rest_fields ) {\n    register_rest_field( $wpide_acf_rest_types, 'acf_fields', array(\n        'get_callback' => function ( $object ) use ( $wpide_acf_rest_fields ) {\n            $post_id = isset( $object['id'] ) ? (int) $object['id'] : 0;\n            $values  = array();\n\n            if ( ! $post_id || ! function_exists( 'get_field' ) || ( ! is_post_publicly_viewable( $post_id ) && ! current_user_can( 'read_post', $post_id ) ) ) {\n                return $values;\n            }\n\n            foreach ( $wpide_acf_rest_fields as $name ) {\n                $values[ $name ] = get_field( $name, $post_id );\n            }\n\n            return $values;\n        },\n        'schema'       => array(\n            'description' => 'Selected ACF field values.',\n            'type'        => 'object',\n            'context'     => array( 'view', 'edit' ),\n            'readonly'    => true,\n        ),\n    ) );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Only the listed fields are shared, and only for posts the visitor may read. ACF's own \"Show in REST API\" option exposes whole field groups instead."
        },
        {
            "id": "acf-rest-options-endpoint",
            "version": 1,
            "title": "Read-Only REST Endpoint for ACF Options",
            "description": "Publishes selected fields from your ACF options pages at /wp-json/wpide/v1/site-options, so a headless front end or app can load site-wide settings.",
            "category": "post-types",
            "tags": [
                "acf",
                "rest-api",
                "options-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Options fields that may be shared publicly.\n$wpide_acf_public_options = array( 'phone', 'email', 'address', 'social_links' );\n\nadd_action( 'rest_api_init', function () use ( $wpide_acf_public_options ) {\n    register_rest_route( 'wpide/v1', '/site-options', array(\n        'methods'             => 'GET',\n        'permission_callback' => '__return_true',\n        'callback'            => function () use ( $wpide_acf_public_options ) {\n            $data = array();\n\n            foreach ( $wpide_acf_public_options as $name ) {\n                $data[ $name ] = function_exists( 'get_field' ) ? get_field( $name, 'option' ) : null;\n            }\n\n            $response = rest_ensure_response( $data );\n            $response->header( 'Cache-Control', 'public, max-age=300' );\n\n            return $response;\n        },\n    ) );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Only list fields that are safe to make public. The response is cached by browsers for five minutes."
        },
        {
            "id": "acf-bidirectional-relationship",
            "version": 1,
            "title": "Two-Way ACF Relationship Field",
            "description": "Keeps a relationship field in sync on both sides, so linking post A to post B also links B back to A, and removing the link removes it from both.",
            "category": "post-types",
            "tags": [
                "acf",
                "relationship",
                "sync"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Name of the relationship field used on both sides.\n$wpide_acf_two_way_field = 'related_items';\n\nadd_filter( 'acf/update_value/name=' . $wpide_acf_two_way_field, function ( $value, $post_id, $field ) {\n    static $running = false;\n\n    if ( $running || ! is_numeric( $post_id ) || ! function_exists( 'update_field' ) ) {\n        return $value;\n    }\n\n    $running = true;\n    $post_id = (int) $post_id;\n    $new     = array_filter( array_map( 'intval', (array) $value ) );\n    $old     = array_filter( array_map( 'intval', (array) get_post_meta( $post_id, $field['name'], true ) ) );\n\n    // Add this post to newly linked posts.\n    foreach ( array_diff( $new, $old ) as $target ) {\n        $links = array_filter( array_map( 'intval', (array) get_post_meta( $target, $field['name'], true ) ) );\n\n        if ( ! in_array( $post_id, $links, true ) ) {\n            $links[] = $post_id;\n            update_field( $field['key'], array_values( array_filter( $links ) ), $target );\n        }\n    }\n\n    // Remove this post from posts that were unlinked.\n    foreach ( array_diff( $old, $new ) as $target ) {\n        $links = array_filter( array_map( 'intval', (array) get_post_meta( $target, $field['name'], true ) ) );\n        update_field( $field['key'], array_values( array_diff( $links, array( $post_id ) ) ), $target );\n    }\n\n    $running = false;\n\n    return $value;\n}, 10, 3 );",
            "requires": [
                "acf"
            ],
            "notes": "Use the same relationship field on both post types (for example related_items). Existing links are updated the next time each post is saved."
        },
        {
            "id": "acf-relationship-published-only",
            "version": 1,
            "title": "Only Published Posts in ACF Relationship Fields",
            "description": "Limits the choices in ACF relationship and post object fields to published content, newest first, so editors cannot link to drafts.",
            "category": "post-types",
            "tags": [
                "acf",
                "relationship",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_acf_published_only = function ( $args ) {\n    $args['post_status'] = 'publish';\n    $args['orderby']     = 'date';\n    $args['order']       = 'DESC';\n\n    return $args;\n};\n\nadd_filter( 'acf/fields/relationship/query', $wpide_acf_published_only );\nadd_filter( 'acf/fields/post_object/query', $wpide_acf_published_only );",
            "requires": [
                "acf"
            ],
            "notes": "Applies to every relationship and post object field; add /name=your_field to the hook names to target one."
        },
        {
            "id": "acf-post-object-exclude-current",
            "version": 1,
            "title": "Stop ACF Fields Linking a Post to Itself",
            "description": "Removes the post being edited from the choices of ACF relationship and post object fields.",
            "category": "post-types",
            "tags": [
                "acf",
                "relationship",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_acf_exclude_self = function ( $args, $field, $post_id ) {\n    if ( is_numeric( $post_id ) && $post_id ) {\n        $args['post__not_in'] = array_merge( (array) ( $args['post__not_in'] ?? array() ), array( (int) $post_id ) );\n    }\n\n    return $args;\n};\n\nadd_filter( 'acf/fields/relationship/query', $wpide_acf_exclude_self, 10, 3 );\nadd_filter( 'acf/fields/post_object/query', $wpide_acf_exclude_self, 10, 3 );",
            "requires": [
                "acf"
            ],
            "notes": ""
        },
        {
            "id": "acf-select-choices-from-posts",
            "version": 1,
            "title": "Fill an ACF Select Field With Posts of a Type",
            "description": "Loads the choices of an ACF select field from the titles of a post type, such as locations or team members, and refreshes them when those posts change.",
            "category": "post-types",
            "tags": [
                "acf",
                "select",
                "dynamic"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Select field name and the post type supplying its choices.\n$wpide_acf_choice_field = 'location';\n$wpide_acf_choice_type  = 'page';\n\nadd_filter( 'acf/load_field/name=' . $wpide_acf_choice_field, function ( $field ) use ( $wpide_acf_choice_type ) {\n    $key     = 'wpide_acf_choices_' . $wpide_acf_choice_type;\n    $choices = get_transient( $key );\n\n    if ( false === $choices ) {\n        $choices = array();\n        $ids     = get_posts( array( 'post_type' => $wpide_acf_choice_type, 'post_status' => 'publish', 'posts_per_page' => 200, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n\n        foreach ( $ids as $id ) {\n            $choices[ $id ] = get_the_title( $id );\n        }\n\n        set_transient( $key, $choices, DAY_IN_SECONDS );\n    }\n\n    $field['choices'] = $choices;\n\n    return $field;\n} );\n\nadd_action( \"save_post_{$wpide_acf_choice_type}\", function () use ( $wpide_acf_choice_type ) {\n    delete_transient( 'wpide_acf_choices_' . $wpide_acf_choice_type );\n} );\n\nadd_action( 'deleted_post', function ( $post_id, $post ) use ( $wpide_acf_choice_type ) {\n    if ( $post && $wpide_acf_choice_type === $post->post_type ) {\n        delete_transient( 'wpide_acf_choices_' . $wpide_acf_choice_type );\n    }\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field name and the source post type. The list is cached until a post of that type is saved or deleted."
        },
        {
            "id": "acf-select-choices-from-options",
            "version": 1,
            "title": "Manage ACF Select Choices From an Options Page",
            "description": "Lets editors maintain the choices of an ACF select field in a repeater on an options page instead of editing the field group.",
            "category": "post-types",
            "tags": [
                "acf",
                "select",
                "options-page"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Select field to fill, and the options repeater with value/label rows.\n$wpide_acf_select_field = 'color';\n$wpide_acf_choices_repeater = 'color_choices';\n\nadd_filter( 'acf/load_field/name=' . $wpide_acf_select_field, function ( $field ) use ( $wpide_acf_choices_repeater ) {\n    if ( ! function_exists( 'get_field' ) ) {\n        return $field;\n    }\n\n    $rows = get_field( $wpide_acf_choices_repeater, 'option' );\n\n    if ( is_array( $rows ) && $rows ) {\n        $field['choices'] = array();\n\n        foreach ( $rows as $row ) {\n            if ( ! empty( $row['value'] ) ) {\n                $field['choices'][ sanitize_text_field( $row['value'] ) ] = ! empty( $row['label'] ) ? sanitize_text_field( $row['label'] ) : sanitize_text_field( $row['value'] );\n            }\n        }\n    }\n\n    return $field;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Create an options repeater (default name: color_choices) with \"value\" and \"label\" sub fields. Needs ACF PRO."
        },
        {
            "id": "acf-readonly-field",
            "version": 1,
            "title": "Make an ACF Field Read-Only",
            "description": "Shows an ACF field's value in the editor but stops editors changing it, useful for IDs or values filled by an import or integration.",
            "category": "post-types",
            "tags": [
                "acf",
                "editor",
                "read-only"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Field names to lock.\n$wpide_acf_readonly_fields = array( 'external_id', 'import_source' );\n\nforeach ( $wpide_acf_readonly_fields as $wpide_acf_readonly_name ) {\n    add_filter( 'acf/prepare_field/name=' . $wpide_acf_readonly_name, function ( $field ) {\n        if ( is_array( $field ) ) {\n            $field['readonly'] = true;\n            $field['instructions'] = trim( ( $field['instructions'] ?? '' ) . ' (Read-only)' );\n        }\n\n        return $field;\n    } );\n}",
            "requires": [
                "acf"
            ],
            "notes": "Works for text, number, email and URL fields. Code can still update the value."
        },
        {
            "id": "acf-hide-field-for-roles",
            "version": 1,
            "title": "Hide ACF Fields From Non-Administrators",
            "description": "Hides the ACF fields you choose in the editor for everyone except administrators, such as internal notes or technical settings.",
            "category": "post-types",
            "tags": [
                "acf",
                "roles",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Field names only administrators see.\n$wpide_acf_admin_fields = array( 'internal_notes', 'tracking_code' );\n\nforeach ( $wpide_acf_admin_fields as $wpide_acf_admin_field ) {\n    add_filter( 'acf/prepare_field/name=' . $wpide_acf_admin_field, function ( $field ) {\n        return current_user_can( 'manage_options' ) ? $field : false;\n    } );\n}",
            "requires": [
                "acf"
            ],
            "notes": "Hidden fields keep their saved values. Change manage_options to target another role."
        },
        {
            "id": "acf-posts-by-field-shortcode",
            "version": 1,
            "title": "List Posts by an ACF Field Value",
            "description": "Adds an [acf_posts field=\"featured\" value=\"1\"] shortcode that lists posts whose ACF field matches a value, such as featured items or posts for a region.",
            "category": "post-types",
            "tags": [
                "acf",
                "query",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_posts', function ( $atts ) {\n    $atts = shortcode_atts( array( 'field' => '', 'value' => '', 'post_type' => 'post', 'limit' => 5 ), $atts, 'acf_posts' );\n    $atts['field'] = sanitize_key( $atts['field'] );\n\n    if ( '' === $atts['field'] || ! post_type_exists( $atts['post_type'] ) ) {\n        return '';\n    }\n\n    $cache_key = 'wpide_acf_posts_' . md5( wp_json_encode( $atts ) . get_option( 'wpide_acf_posts_ver_' . $atts['post_type'], '1' ) );\n    $ids       = get_transient( $cache_key );\n\n    if ( false === $ids ) {\n        $ids = get_posts( array(\n            'post_type'      => $atts['post_type'],\n            'posts_per_page' => min( 50, absint( $atts['limit'] ) ),\n            'meta_key'       => $atts['field'],\n            'meta_value'     => sanitize_text_field( $atts['value'] ),\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) );\n        set_transient( $cache_key, $ids, HOUR_IN_SECONDS );\n    }\n\n    if ( ! $ids ) {\n        return '';\n    }\n\n    $html = '<ul class=\"acf-posts\">';\n\n    foreach ( $ids as $id ) {\n        $html .= '<li><a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( get_the_title( $id ) ) . '</a></li>';\n    }\n\n    return $html . '</ul>';\n} );\n\n// Refresh cached lists when content of that type changes.\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && ! wp_is_post_revision( $post ) ) {\n        update_option( 'wpide_acf_posts_ver_' . $post->post_type, (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Add post_type=\"...\" and limit=\"...\" as needed. Results are cached and refresh automatically when content changes."
        },
        {
            "id": "acf-register-fields-in-code",
            "version": 1,
            "title": "Register ACF Fields in Code",
            "description": "A ready-to-edit example that registers an ACF field group (subtitle, summary and call-to-action link) for posts in PHP, so the fields are version-controlled and cannot be changed in the admin.",
            "category": "post-types",
            "tags": [
                "acf",
                "fields",
                "developer"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'acf/init', function () {\n    if ( ! function_exists( 'acf_add_local_field_group' ) ) {\n        return;\n    }\n\n    acf_add_local_field_group( array(\n        'key'      => 'group_wpide_post_extras',\n        'title'    => 'Post Extras',\n        'fields'   => array(\n            array(\n                'key'   => 'field_wpide_subtitle',\n                'label' => 'Subtitle',\n                'name'  => 'subtitle',\n                'type'  => 'text',\n            ),\n            array(\n                'key'       => 'field_wpide_summary',\n                'label'     => 'Summary',\n                'name'      => 'summary',\n                'type'      => 'textarea',\n                'rows'      => 3,\n                'maxlength' => 300,\n            ),\n            array(\n                'key'           => 'field_wpide_cta',\n                'label'         => 'Call to action',\n                'name'          => 'cta',\n                'type'          => 'link',\n                'return_format' => 'array',\n            ),\n        ),\n        'location' => array(\n            array(\n                array( 'param' => 'post_type', 'operator' => '==', 'value' => 'post' ),\n            ),\n        ),\n        'position'      => 'normal',\n        'style'         => 'default',\n        'show_in_rest'  => 1,\n    ) );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Keep every key unique and never change a key after content has been saved. Edit the location rule to target other post types."
        },
        {
            "id": "acf-wysiwyg-simple-toolbar",
            "version": 1,
            "title": "Minimal Toolbar Option for ACF WYSIWYG Fields",
            "description": "Adds a \"Minimal\" toolbar choice to ACF WYSIWYG fields with only bold, italic, link and lists, for short formatted text where full editing is too much.",
            "category": "post-types",
            "tags": [
                "acf",
                "wysiwyg",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'acf/fields/wysiwyg/toolbars', function ( $toolbars ) {\n    $toolbars['Minimal'] = array(\n        1 => array( 'bold', 'italic', 'link', 'unlink', 'bullist', 'numlist', 'undo', 'redo' ),\n    );\n\n    return $toolbars;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Pick \"Minimal\" in the field's Toolbar setting after activating."
        },
        {
            "id": "acf-flexible-layout-titles",
            "version": 1,
            "title": "Descriptive ACF Flexible Content Layout Titles",
            "description": "Shows each flexible content row's heading next to its layout name in the editor, so long pages built from sections are easy to scan.",
            "category": "post-types",
            "tags": [
                "acf",
                "flexible-content",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Flexible content field name and the sub field shown in titles.\n$wpide_acf_flex_field   = 'page_sections';\n$wpide_acf_flex_heading = 'heading';\n\nadd_filter( 'acf/fields/flexible_content/layout_title/name=' . $wpide_acf_flex_field, function ( $title ) use ( $wpide_acf_flex_heading ) {\n    $heading = function_exists( 'get_sub_field' ) ? get_sub_field( $wpide_acf_flex_heading ) : '';\n\n    if ( is_string( $heading ) && '' !== trim( $heading ) ) {\n        $title .= ' <span style=\"font-weight:400;opacity:.7\">— ' . esc_html( wp_trim_words( $heading, 8 ) ) . '</span>';\n    }\n\n    return $title;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Set the flexible content field name and the sub field holding each section's heading. Needs ACF PRO."
        },
        {
            "id": "acf-title-from-fields",
            "version": 1,
            "title": "Build the Post Title From ACF Fields",
            "description": "Sets a post's title and slug from ACF fields when it is saved, for example a team member's first and last name, so editors never type it twice.",
            "category": "post-types",
            "tags": [
                "acf",
                "titles",
                "automation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type and the fields that make up the title.\n$wpide_acf_title_type   = 'team';\n$wpide_acf_title_fields = array( 'first_name', 'last_name' );\n\nadd_action( 'acf/save_post', function ( $post_id ) use ( $wpide_acf_title_type, $wpide_acf_title_fields ) {\n    if ( ! is_numeric( $post_id ) || get_post_type( $post_id ) !== $wpide_acf_title_type || ! function_exists( 'get_field' ) || wp_is_post_revision( $post_id ) ) {\n        return;\n    }\n\n    $parts = array();\n\n    foreach ( $wpide_acf_title_fields as $name ) {\n        $value = get_field( $name, $post_id );\n\n        if ( is_scalar( $value ) && '' !== trim( (string) $value ) ) {\n            $parts[] = trim( (string) $value );\n        }\n    }\n\n    $title = sanitize_text_field( implode( ' ', $parts ) );\n\n    if ( '' === $title || get_the_title( $post_id ) === $title ) {\n        return;\n    }\n\n    wp_update_post( array(\n        'ID'         => (int) $post_id,\n        'post_title' => $title,\n        'post_name'  => wp_unique_post_slug( sanitize_title( $title ), (int) $post_id, get_post_status( $post_id ), $wpide_acf_title_type, 0 ),\n    ) );\n}, 20 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the post type and the fields joined into the title. Runs after ACF saves its values."
        },
        {
            "id": "acf-front-end-form",
            "version": 1,
            "title": "Front-End Submission Form With ACF",
            "description": "Adds an [acf_submit_form] shortcode that lets logged-in users submit a new post (saved as pending review) using your ACF field groups.",
            "category": "post-types",
            "tags": [
                "acf",
                "forms",
                "front-end"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field group keys shown in the form, and the post type created.\n$wpide_acf_form_groups = array( 'group_wpide_post_extras' );\n$wpide_acf_form_type   = 'post';\n\nadd_action( 'template_redirect', function () {\n    // acf_form_head() must run before any output to process submissions.\n    if ( function_exists( 'acf_form_head' ) && is_user_logged_in() && is_singular() && has_shortcode( (string) get_post_field( 'post_content', get_queried_object_id() ), 'acf_submit_form' ) ) {\n        acf_form_head();\n    }\n} );\n\nadd_shortcode( 'acf_submit_form', function () use ( $wpide_acf_form_groups, $wpide_acf_form_type ) {\n    if ( ! function_exists( 'acf_form' ) ) {\n        return '';\n    }\n\n    if ( ! is_user_logged_in() ) {\n        return '<p>Please <a href=\"' . esc_url( wp_login_url( get_permalink() ) ) . '\">log in</a> to submit.</p>';\n    }\n\n    ob_start();\n    acf_form( array(\n        'post_id'         => 'new_post',\n        'new_post'        => array( 'post_type' => $wpide_acf_form_type, 'post_status' => 'pending' ),\n        'post_title'      => true,\n        'field_groups'    => $wpide_acf_form_groups,\n        'submit_value'    => 'Submit for review',\n        'updated_message' => 'Thank you! Your submission is waiting for review.',\n    ) );\n\n    return ob_get_clean();\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field group key(s) and post type. Submissions wait for review; guests see a login link."
        },
        {
            "id": "acf-disable-post-types-ui",
            "version": 1,
            "title": "Turn Off ACF's Post Types and Taxonomies Screens",
            "description": "Hides the post type and taxonomy builder added in ACF 6.1, for sites that register content types in code or with another tool.",
            "category": "post-types",
            "tags": [
                "acf",
                "admin",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'acf/settings/enable_post_types', '__return_false' );",
            "requires": [
                "acf"
            ],
            "notes": "Post types already created there stop being registered, so move them to code first."
        },
        {
            "id": "acf-term-field-shortcode",
            "version": 1,
            "title": "Shortcode for ACF Fields on Categories and Terms",
            "description": "Adds a [term_field name=\"...\"] shortcode that prints an ACF field of the current category, tag or custom term on archive pages, or of a term you specify.",
            "category": "post-types",
            "tags": [
                "acf",
                "taxonomies",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'term_field', function ( $atts ) {\n    $atts = shortcode_atts( array( 'name' => '', 'term' => 0 ), $atts, 'term_field' );\n    $term = $atts['term'] ? get_term( absint( $atts['term'] ) ) : get_queried_object();\n\n    if ( ! $term instanceof WP_Term || '' === $atts['name'] || ! function_exists( 'get_field' ) ) {\n        return '';\n    }\n\n    $value = get_field( sanitize_key( $atts['name'] ), $term );\n\n    if ( is_array( $value ) ) {\n        $value = isset( $value['url'] ) ? $value['url'] : implode( ', ', array_filter( $value, 'is_scalar' ) );\n    }\n\n    return esc_html( (string) $value );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "On singular pages pass term=\"12\" with the term ID."
        },
        {
            "id": "acf-author-field-shortcode",
            "version": 1,
            "title": "Shortcode for ACF Fields on the Post Author",
            "description": "Adds an [author_field name=\"job_title\"] shortcode that shows an ACF user field of the current post's author, for author boxes and bylines.",
            "category": "post-types",
            "tags": [
                "acf",
                "authors",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User fields that may be shown publicly.\n$wpide_acf_public_user_fields = array( 'job_title', 'company', 'website', 'short_bio' );\n\nadd_shortcode( 'author_field', function ( $atts ) use ( $wpide_acf_public_user_fields ) {\n    $atts = shortcode_atts( array( 'name' => '' ), $atts, 'author_field' );\n    $name = sanitize_key( $atts['name'] );\n    $post = get_post();\n\n    if ( ! $post || ! in_array( $name, $wpide_acf_public_user_fields, true ) || ! function_exists( 'get_field' ) ) {\n        return '';\n    }\n\n    $value = get_field( $name, 'user_' . (int) $post->post_author );\n\n    return is_scalar( $value ) ? esc_html( (string) $value ) : '';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Only list fields that are safe to make public."
        },
        {
            "id": "acf-google-map-embed-shortcode",
            "version": 1,
            "title": "Embed a Map From an ACF Google Map Field",
            "description": "Adds an [acf_map name=\"location\"] shortcode that shows a lazy-loaded embedded map and a directions link for the place saved in an ACF Google Map field.",
            "category": "post-types",
            "tags": [
                "acf",
                "google-maps",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_map', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => 'location', 'zoom' => 15, 'height' => 360, 'post_id' => 0 ), $atts, 'acf_map' );\n    $post_id = $atts['post_id'] ? absint( $atts['post_id'] ) : get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) || ! is_post_publicly_viewable( $post_id ) ) {\n        return '';\n    }\n\n    $map = get_field( sanitize_key( $atts['name'] ), $post_id );\n\n    if ( ! is_array( $map ) || ! isset( $map['lat'], $map['lng'] ) ) {\n        return '';\n    }\n\n    $coords = (float) $map['lat'] . ',' . (float) $map['lng'];\n    $label  = ! empty( $map['address'] ) ? $map['address'] : $coords;\n    $src    = add_query_arg( array( 'q' => $coords, 'z' => min( 21, absint( $atts['zoom'] ) ), 'output' => 'embed' ), 'https://maps.google.com/maps' );\n    $dir    = add_query_arg( array( 'api' => 1, 'destination' => $coords ), 'https://www.google.com/maps/dir/' );\n\n    return '<div class=\"acf-map-embed\"><iframe src=\"' . esc_url( $src ) . '\" title=\"' . esc_attr( 'Map: ' . $label ) . '\" loading=\"lazy\" referrerpolicy=\"no-referrer-when-downgrade\" style=\"width:100%;height:' . absint( $atts['height'] ) . 'px;border:0\"></iframe>'\n        . '<p><a href=\"' . esc_url( $dir ) . '\" target=\"_blank\" rel=\"noopener\">Get directions</a> — ' . esc_html( $label ) . '</p></div>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "The embed needs no API key. Change height=\"...\" to resize; the map always fits the screen width."
        },
        {
            "id": "acf-block-bindings-source",
            "version": 1,
            "title": "Use ACF Fields in Core Blocks With Block Bindings",
            "description": "Registers an \"ACF field\" block bindings source, so paragraph, heading, button and image blocks can show ACF values without a custom block (WordPress 6.5+).",
            "category": "post-types",
            "tags": [
                "acf",
                "block-bindings",
                "gutenberg"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/acf', array(\n        'label'              => 'ACF field',\n        'uses_context'       => array( 'postId', 'postType' ),\n        'get_value_callback' => function ( $source_args, $block_instance, $attribute_name ) {\n            $key     = isset( $source_args['key'] ) ? sanitize_key( $source_args['key'] ) : '';\n            $post_id = isset( $block_instance->context['postId'] ) ? (int) $block_instance->context['postId'] : get_the_ID();\n\n            if ( '' === $key || '_' === $key[0] || ! $post_id || ! function_exists( 'get_field' ) || ( ! is_post_publicly_viewable( $post_id ) && ! current_user_can( 'read_post', $post_id ) ) ) {\n                return null;\n            }\n\n            $value = get_field( $key, $post_id );\n\n            if ( is_array( $value ) ) {\n                $value = $value['url'] ?? ( $value['label'] ?? '' );\n            }\n\n            if ( ! is_scalar( $value ) ) {\n                return null;\n            }\n\n            return in_array( $attribute_name, array( 'url', 'href' ), true ) ? esc_url( (string) $value ) : esc_html( (string) $value );\n        },\n    ) );\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Bind in the Code editor, e.g. \"metadata\":{\"bindings\":{\"content\":{\"source\":\"wpide/acf\",\"args\":{\"key\":\"subtitle\"}}}}. Fields starting with an underscore are never exposed."
        },
        {
            "id": "acf-default-from-options",
            "version": 1,
            "title": "Fall Back to an ACF Options Value When a Field Is Empty",
            "description": "Uses a site-wide default from your ACF options page whenever a post's own field is empty, such as a standard call-to-action text or banner.",
            "category": "post-types",
            "tags": [
                "acf",
                "options-page",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post field and the options field with the default.\n$wpide_acf_fallback_field  = 'cta_text';\n$wpide_acf_fallback_option = 'default_cta_text';\n\nadd_filter( 'acf/load_value/name=' . $wpide_acf_fallback_field, function ( $value, $post_id ) use ( $wpide_acf_fallback_option ) {\n    if ( ( null !== $value && '' !== $value && array() !== $value ) || 'options' === $post_id || 'option' === $post_id || is_admin() || ! function_exists( 'get_field' ) ) {\n        return $value;\n    }\n\n    $default = get_field( $wpide_acf_fallback_option, 'option', false );\n\n    return ( null === $default || '' === $default ) ? $value : $default;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the post field and the options field that holds the default."
        },
        {
            "id": "acf-true-false-body-class",
            "version": 1,
            "title": "Body Classes From ACF True/False Fields",
            "description": "Adds a body class such as \"acf-hide-title\" when a true/false field is switched on for the current page, so you can change the layout with CSS alone.",
            "category": "post-types",
            "tags": [
                "acf",
                "body-class",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// True/false field names that become body classes.\n$wpide_acf_class_fields = array( 'hide_title', 'full_width', 'transparent_header' );\n\nadd_filter( 'body_class', function ( $classes ) use ( $wpide_acf_class_fields ) {\n    if ( ! is_singular() || ! function_exists( 'get_field' ) ) {\n        return $classes;\n    }\n\n    foreach ( $wpide_acf_class_fields as $name ) {\n        if ( get_field( $name, get_queried_object_id() ) ) {\n            $classes[] = 'acf-' . sanitize_html_class( str_replace( '_', '-', $name ) );\n        }\n    }\n\n    return $classes;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "List your true/false field names. The class is \"acf-\" plus the field name with dashes."
        },
        {
            "id": "acf-color-css-variable",
            "version": 1,
            "title": "Page Accent Color From an ACF Color Picker",
            "description": "Turns an ACF color picker on posts and pages into a --page-accent CSS variable, so each page can have its own highlight color.",
            "category": "post-types",
            "tags": [
                "acf",
                "colors",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Color picker field name.\n$wpide_acf_color_field = 'accent_color';\n\nadd_action( 'wp_head', function () use ( $wpide_acf_color_field ) {\n    if ( ! is_singular() || ! function_exists( 'get_field' ) ) {\n        return;\n    }\n\n    $color = sanitize_hex_color( (string) get_field( $wpide_acf_color_field, get_queried_object_id() ) );\n\n    if ( $color ) {\n        echo '<style id=\"page-accent\">:root{--page-accent:' . esc_html( $color ) . '}</style>' . \"\\n\";\n    }\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Use var(--page-accent) in your CSS, e.g. a { color: var(--page-accent, inherit); }."
        },
        {
            "id": "acf-relationship-result-details",
            "version": 1,
            "title": "Show Post Type and Date in ACF Relationship Results",
            "description": "Adds the post type and publish date after each title in ACF relationship and post object fields, so similar titles are easy to tell apart.",
            "category": "post-types",
            "tags": [
                "acf",
                "relationship",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_acf_result_details = function ( $text, $post ) {\n    if ( ! $post instanceof WP_Post ) {\n        return $text;\n    }\n\n    $type = get_post_type_object( $post->post_type );\n\n    return $text . ' (' . esc_html( ( $type ? $type->labels->singular_name : $post->post_type ) . ', ' . get_the_date( 'M j, Y', $post ) ) . ')';\n};\n\nadd_filter( 'acf/fields/relationship/result', $wpide_acf_result_details, 10, 2 );\nadd_filter( 'acf/fields/post_object/result', $wpide_acf_result_details, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": ""
        },
        {
            "id": "acf-date-default-today",
            "version": 1,
            "title": "Default an ACF Date Field to Today",
            "description": "Pre-fills an empty ACF date picker with today's date on new posts, saving a click for fields like \"published on\" or \"received\".",
            "category": "post-types",
            "tags": [
                "acf",
                "dates",
                "defaults"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Date picker field name.\n$wpide_acf_today_field = 'received_date';\n\nadd_filter( 'acf/load_field/name=' . $wpide_acf_today_field, function ( $field ) {\n    if ( empty( $field['default_value'] ) ) {\n        $field['default_value'] = wp_date( 'Ymd' );\n    }\n\n    return $field;\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Uses the site's timezone. Saved dates are never changed."
        },
        {
            "id": "acf-repeater-faq-schema",
            "version": 1,
            "title": "FAQ Section With Schema From an ACF Repeater",
            "description": "Adds an [acf_faq] shortcode that shows a question-and-answer repeater as accessible collapsible items and outputs matching FAQPage structured data.",
            "category": "post-types",
            "tags": [
                "acf",
                "faq",
                "schema"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'acf_faq', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'name' => 'faqs', 'question' => 'question', 'answer' => 'answer' ), $atts, 'acf_faq' );\n    $post_id = get_the_ID();\n\n    if ( ! $post_id || ! function_exists( 'get_field' ) ) {\n        return '';\n    }\n\n    $rows = get_field( sanitize_key( $atts['name'] ), $post_id );\n\n    if ( ! is_array( $rows ) || ! $rows ) {\n        return '';\n    }\n\n    $html   = '<div class=\"acf-faq\">';\n    $schema = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => array() );\n\n    foreach ( $rows as $row ) {\n        $q = isset( $row[ $atts['question'] ] ) && is_string( $row[ $atts['question'] ] ) ? trim( $row[ $atts['question'] ] ) : '';\n        $a = isset( $row[ $atts['answer'] ] ) && is_string( $row[ $atts['answer'] ] ) ? trim( $row[ $atts['answer'] ] ) : '';\n\n        if ( '' === $q || '' === $a ) {\n            continue;\n        }\n\n        $html .= '<details class=\"acf-faq-item\"><summary>' . esc_html( $q ) . '</summary><div class=\"acf-faq-answer\">' . wp_kses_post( wpautop( $a ) ) . '</div></details>';\n        $schema['mainEntity'][] = array(\n            '@type'          => 'Question',\n            'name'           => wp_strip_all_tags( $q ),\n            'acceptedAnswer' => array( '@type' => 'Answer', 'text' => wp_kses( $a, array( 'a' => array( 'href' => true ), 'strong' => array(), 'em' => array(), 'br' => array(), 'p' => array(), 'ul' => array(), 'ol' => array(), 'li' => array() ) ) ),\n        );\n    }\n\n    if ( ! $schema['mainEntity'] ) {\n        return '';\n    }\n\n    return $html . '</div><script type=\"application/ld+json\">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . '</script>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Create a repeater named faqs with question and answer sub fields (or change the names). Needs ACF PRO."
        },
        {
            "id": "acf-image-as-featured-fallback",
            "version": 1,
            "title": "Use an ACF Image When There Is No Featured Image",
            "description": "Makes WordPress treat an ACF image field as the featured image for posts that have none, so archives, related posts and social previews still get a picture.",
            "category": "post-types",
            "tags": [
                "acf",
                "featured-image",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Image field used as the fallback.\n$wpide_acf_thumb_field = 'hero_image';\n\nadd_filter( 'post_thumbnail_id', function ( $thumbnail_id, $post ) use ( $wpide_acf_thumb_field ) {\n    if ( $thumbnail_id || ! $post instanceof WP_Post ) {\n        return $thumbnail_id;\n    }\n\n    // Read the raw meta (the attachment ID) to avoid loading ACF on every thumbnail call.\n    $id = absint( get_post_meta( $post->ID, $wpide_acf_thumb_field, true ) );\n\n    return ( $id && wp_attachment_is_image( $id ) ) ? $id : $thumbnail_id;\n}, 10, 2 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the image field name. A real featured image always wins."
        },
        {
            "id": "acf-excerpt-from-field",
            "version": 1,
            "title": "Fill the Excerpt From an ACF Summary Field",
            "description": "Copies an ACF summary field into the post excerpt on save when the excerpt is empty, so themes, feeds and search results show your summary.",
            "category": "post-types",
            "tags": [
                "acf",
                "excerpt",
                "automation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Summary field and the post types it applies to.\n$wpide_acf_excerpt_field = 'summary';\n$wpide_acf_excerpt_types = array( 'post' );\n\nadd_action( 'acf/save_post', function ( $post_id ) use ( $wpide_acf_excerpt_field, $wpide_acf_excerpt_types ) {\n    if ( ! is_numeric( $post_id ) || wp_is_post_revision( $post_id ) || ! in_array( get_post_type( $post_id ), $wpide_acf_excerpt_types, true ) || '' !== trim( (string) get_post_field( 'post_excerpt', $post_id ) ) ) {\n        return;\n    }\n\n    $summary = sanitize_textarea_field( (string) get_post_meta( $post_id, $wpide_acf_excerpt_field, true ) );\n\n    if ( '' !== $summary ) {\n        wp_update_post( array( 'ID' => (int) $post_id, 'post_excerpt' => $summary ) );\n    }\n}, 20 );",
            "requires": [
                "acf"
            ],
            "notes": "Set the field name and post types. Excerpts you write by hand are kept."
        },
        {
            "id": "acf-responsive-oembed",
            "version": 1,
            "title": "Responsive Video Embeds From ACF oEmbed Fields",
            "description": "Wraps the output of ACF oEmbed fields in a 16:9 container and adds lazy loading, so embedded videos scale to the screen instead of overflowing on phones.",
            "category": "post-types",
            "tags": [
                "acf",
                "video",
                "responsive"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Aspect ratio of the container.\n$wpide_acf_embed_ratio = '16 / 9';\n\nadd_filter( 'acf/format_value/type=oembed', function ( $value ) use ( $wpide_acf_embed_ratio ) {\n    if ( ! is_string( $value ) || false === strpos( $value, '<iframe' ) ) {\n        return $value;\n    }\n\n    $value = preg_replace( '/<iframe(?![^>]*\\sloading=)/', '<iframe loading=\"lazy\"', $value );\n    $value = preg_replace( '/\\s(width|height)=\"\\d+\"/', '', $value );\n\n    return '<div class=\"acf-embed\" style=\"aspect-ratio:' . esc_attr( $wpide_acf_embed_ratio ) . ';width:100%\">' . str_replace( '<iframe', '<iframe style=\"width:100%;height:100%;border:0\"', $value ) . '</div>';\n} );",
            "requires": [
                "acf"
            ],
            "notes": "Change the aspect ratio for vertical or square videos."
        },
        {
            "id": "acf-shortcodes-in-text-fields",
            "version": 1,
            "title": "Run Shortcodes Inside ACF Text Fields",
            "description": "Lets editors use shortcodes in ACF text and text area fields, which ACF otherwise prints as plain text.",
            "category": "post-types",
            "tags": [
                "acf",
                "shortcode",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_acf_do_shortcodes = function ( $value ) {\n    return ( is_string( $value ) && false !== strpos( $value, '[' ) ) ? do_shortcode( $value ) : $value;\n};\n\nadd_filter( 'acf/format_value/type=text', $wpide_acf_do_shortcodes, 20 );\nadd_filter( 'acf/format_value/type=textarea', $wpide_acf_do_shortcodes, 20 );",
            "requires": [
                "acf"
            ],
            "notes": "WYSIWYG fields already run shortcodes. Only use this if you trust everyone who can edit fields."
        },
        {
            "id": "cpt-testimonials",
            "version": 1,
            "title": "Testimonials Post Type",
            "description": "Registers a \"Testimonials\" post type for customer quotes, kept out of search and without single pages, ready to show with a shortcode or query block.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "testimonials",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'testimonial', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Testimonial',\n    'plural'   => 'Testimonials',\n    'slug'     => 'testimonials',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'              => false,\n        'show_ui'             => true,\n        'show_in_nav_menus'   => false,\n        'exclude_from_search' => true,\n        'publicly_queryable'  => false,\n        'has_archive'         => false,\n        'rewrite'             => false,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-format-quote',\n        'menu_position' => 22,\n        'supports'      => array( 'title', 'editor', 'thumbnail', 'page-attributes' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Testimonials have no public URLs of their own; set publicly_queryable to true if you want single pages."
        },
        {
            "id": "cpt-team-members",
            "version": 1,
            "title": "Team Members Post Type With Departments",
            "description": "Registers a \"Team\" post type for staff profiles with a Department taxonomy, photos, ordering and a /team/ archive.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "team",
                "staff"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'team', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Team Member',\n    'plural'   => 'Team',\n    'slug'     => 'team',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-groups',\n        'menu_position' => 23,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes' ),\n    ) );\n\n    register_taxonomy( 'department', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Departments',\n            'singular_name' => 'Department',\n            'menu_name'     => 'Departments',\n            'all_items'     => 'All Departments',\n            'edit_item'     => 'Edit Department',\n            'view_item'     => 'View Department',\n            'update_item'   => 'Update Department',\n            'add_new_item'  => 'Add New Department',\n            'new_item_name' => 'New Department Name',\n            'search_items'  => 'Search Departments',\n            'not_found'     => 'No departments found.',\n            'parent_item'   => 'Parent Department',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'department', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Drag order is set with the Order field under Page Attributes. Visit Settings > Permalinks once after activating."
        },
        {
            "id": "cpt-faqs",
            "version": 1,
            "title": "FAQ Post Type With Groups",
            "description": "Registers an \"FAQs\" post type (question as the title, answer as the content) with an FAQ Group taxonomy, for help centers and FAQ pages.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "faq",
                "help"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'faq', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'FAQ',\n    'plural'   => 'FAQs',\n    'slug'     => 'faq',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'              => false,\n        'show_ui'             => true,\n        'show_in_nav_menus'   => false,\n        'exclude_from_search' => true,\n        'publicly_queryable'  => false,\n        'has_archive'         => false,\n        'rewrite'             => false,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-editor-help',\n        'menu_position' => 24,\n        'supports'      => array( 'title', 'editor', 'page-attributes' ),\n    ) );\n\n    register_taxonomy( 'faq_group', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'FAQ Groups',\n            'singular_name' => 'FAQ Group',\n            'menu_name'     => 'FAQ Groups',\n            'all_items'     => 'All FAQ Groups',\n            'edit_item'     => 'Edit FAQ Group',\n            'view_item'     => 'View FAQ Group',\n            'update_item'   => 'Update FAQ Group',\n            'add_new_item'  => 'Add New FAQ Group',\n            'new_item_name' => 'New FAQ Group Name',\n            'search_items'  => 'Search FAQ Groups',\n            'not_found'     => 'No faq groups found.',\n            'parent_item'   => 'Parent FAQ Group',\n        ),\n        'hierarchical'      => true,\n        'public'            => false,\n        'show_ui'           => true,\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "FAQs have no single pages; list them with a shortcode or a Query Loop block."
        },
        {
            "id": "cpt-events",
            "version": 1,
            "title": "Events Post Type With Event Categories",
            "description": "Registers an \"Events\" post type with featured images, an /events/ archive and an Event Category taxonomy, as the base for a simple events calendar.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "events",
                "calendar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'event', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Event',\n    'plural'   => 'Events',\n    'slug'     => 'events',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-calendar-alt',\n        'menu_position' => 25,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'event_category', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Event Categories',\n            'singular_name' => 'Event Category',\n            'menu_name'     => 'Event Categories',\n            'all_items'     => 'All Event Categories',\n            'edit_item'     => 'Edit Event Category',\n            'view_item'     => 'View Event Category',\n            'update_item'   => 'Update Event Category',\n            'add_new_item'  => 'Add New Event Category',\n            'new_item_name' => 'New Event Category Name',\n            'search_items'  => 'Search Event Categories',\n            'not_found'     => 'No event categories found.',\n            'parent_item'   => 'Parent Event Category',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'event-category', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Do not use alongside an events plugin that already registers an \"event\" post type. Visit Settings > Permalinks once after activating."
        },
        {
            "id": "cpt-services",
            "version": 1,
            "title": "Services Post Type (Hierarchical)",
            "description": "Registers a page-like \"Services\" post type that supports parent and child services, ideal for agencies and local businesses.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "services",
                "business"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'service', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Service',\n    'plural'   => 'Services',\n    'slug'     => 'services',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'hierarchical'  => true,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-hammer',\n        'menu_position' => 26,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'page-attributes', 'revisions' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Child services get URLs like /services/design/logo-design/."
        },
        {
            "id": "cpt-locations",
            "version": 1,
            "title": "Locations Post Type for Branches and Stores",
            "description": "Registers a \"Locations\" post type for branches, stores or offices, each with its own page under /locations/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "locations",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'location', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Location',\n    'plural'   => 'Locations',\n    'slug'     => 'locations',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-location',\n        'menu_position' => 27,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Add the Location Details meta box snippet for address, phone and opening hours."
        },
        {
            "id": "cpt-jobs",
            "version": 1,
            "title": "Job Listings Post Type With Type and Location",
            "description": "Registers a \"Jobs\" post type for vacancies with Job Type (full-time, part-time) and Job Location taxonomies and a /jobs/ archive.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "jobs",
                "careers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'job', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Job',\n    'plural'   => 'Jobs',\n    'slug'     => 'jobs',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-businessperson',\n        'menu_position' => 28,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'job_type', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Job Types',\n            'singular_name' => 'Job Type',\n            'menu_name'     => 'Job Types',\n            'all_items'     => 'All Job Types',\n            'edit_item'     => 'Edit Job Type',\n            'view_item'     => 'View Job Type',\n            'update_item'   => 'Update Job Type',\n            'add_new_item'  => 'Add New Job Type',\n            'new_item_name' => 'New Job Type Name',\n            'search_items'  => 'Search Job Types',\n            'not_found'     => 'No job types found.',\n            'parent_item'   => 'Parent Job Type',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'job-type', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n\n    register_taxonomy( 'job_location', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Job Locations',\n            'singular_name' => 'Job Location',\n            'menu_name'     => 'Job Locations',\n            'all_items'     => 'All Job Locations',\n            'edit_item'     => 'Edit Job Location',\n            'view_item'     => 'View Job Location',\n            'update_item'   => 'Update Job Location',\n            'add_new_item'  => 'Add New Job Location',\n            'new_item_name' => 'New Job Location Name',\n            'search_items'  => 'Search Job Locations',\n            'not_found'     => 'No job locations found.',\n            'parent_item'   => 'Parent Job Location',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'job-location', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Combine with the Job Details meta box and JobPosting schema snippets."
        },
        {
            "id": "cpt-recipes",
            "version": 1,
            "title": "Recipes Post Type With Cuisine and Course",
            "description": "Registers a \"Recipes\" post type with Cuisine and Course taxonomies, comments and featured images, under /recipes/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "recipes",
                "food"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'recipe', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Recipe',\n    'plural'   => 'Recipes',\n    'slug'     => 'recipes',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-carrot',\n        'menu_position' => 29,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'comments', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'cuisine', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Cuisines',\n            'singular_name' => 'Cuisine',\n            'menu_name'     => 'Cuisines',\n            'all_items'     => 'All Cuisines',\n            'edit_item'     => 'Edit Cuisine',\n            'view_item'     => 'View Cuisine',\n            'update_item'   => 'Update Cuisine',\n            'add_new_item'  => 'Add New Cuisine',\n            'new_item_name' => 'New Cuisine Name',\n            'search_items'  => 'Search Cuisines',\n            'not_found'     => 'No cuisines found.',\n            'parent_item'   => 'Parent Cuisine',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'cuisine', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n\n    register_taxonomy( 'recipe_course', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Courses',\n            'singular_name' => 'Course',\n            'menu_name'     => 'Courses',\n            'all_items'     => 'All Courses',\n            'edit_item'     => 'Edit Course',\n            'view_item'     => 'View Course',\n            'update_item'   => 'Update Course',\n            'add_new_item'  => 'Add New Course',\n            'new_item_name' => 'New Course Name',\n            'search_items'  => 'Search Courses',\n            'not_found'     => 'No courses found.',\n            'parent_item'   => 'Parent Course',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'recipe-course', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Add the Recipe Details meta box and Recipe schema snippets for times, servings and rich results."
        },
        {
            "id": "cpt-books",
            "version": 1,
            "title": "Books Post Type With Genres and Authors",
            "description": "Registers a \"Books\" post type with a Genre category taxonomy and a tag-style Book Author taxonomy, for publishers, libraries and reading lists.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "books",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'book', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Book',\n    'plural'   => 'Books',\n    'slug'     => 'books',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-book-alt',\n        'menu_position' => 30,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'genre', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Genres',\n            'singular_name' => 'Genre',\n            'menu_name'     => 'Genres',\n            'all_items'     => 'All Genres',\n            'edit_item'     => 'Edit Genre',\n            'view_item'     => 'View Genre',\n            'update_item'   => 'Update Genre',\n            'add_new_item'  => 'Add New Genre',\n            'new_item_name' => 'New Genre Name',\n            'search_items'  => 'Search Genres',\n            'not_found'     => 'No genres found.',\n            'parent_item'   => 'Parent Genre',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'genre', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n\n    register_taxonomy( 'book_author', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'                       => 'Book Authors',\n            'singular_name'              => 'Book Author',\n            'menu_name'                  => 'Book Authors',\n            'all_items'                  => 'All Book Authors',\n            'edit_item'                  => 'Edit Book Author',\n            'view_item'                  => 'View Book Author',\n            'update_item'                => 'Update Book Author',\n            'add_new_item'               => 'Add New Book Author',\n            'new_item_name'              => 'New Book Author Name',\n            'search_items'               => 'Search Book Authors',\n            'not_found'                  => 'No book authors found.',\n            'popular_items'              => 'Popular Book Authors',\n            'separate_items_with_commas' => 'Separate book authors with commas',\n            'choose_from_most_used'      => 'Choose from the most used book authors',\n        ),\n        'hierarchical'      => false,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'book-author', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Book authors work like tags, so a book can have several."
        },
        {
            "id": "cpt-case-studies",
            "version": 1,
            "title": "Case Studies Post Type With Industries",
            "description": "Registers a \"Case Studies\" post type with an Industry taxonomy for showcasing client results, with an archive at /case-studies/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "case-studies",
                "marketing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'case_study', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Case Study',\n    'plural'   => 'Case Studies',\n    'slug'     => 'case-studies',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-chart-line',\n        'menu_position' => 31,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'industry', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Industries',\n            'singular_name' => 'Industry',\n            'menu_name'     => 'Industries',\n            'all_items'     => 'All Industries',\n            'edit_item'     => 'Edit Industry',\n            'view_item'     => 'View Industry',\n            'update_item'   => 'Update Industry',\n            'add_new_item'  => 'Add New Industry',\n            'new_item_name' => 'New Industry Name',\n            'search_items'  => 'Search Industries',\n            'not_found'     => 'No industries found.',\n            'parent_item'   => 'Parent Industry',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'industry', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once after activating so the new URLs work."
        },
        {
            "id": "cpt-podcast-episodes",
            "version": 1,
            "title": "Podcast Episodes Post Type With Seasons",
            "description": "Registers an \"Episodes\" post type for a podcast with a Season taxonomy, comments and an archive at /podcast/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "podcast",
                "audio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'episode', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Episode',\n    'plural'   => 'Episodes',\n    'slug'     => 'podcast',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-microphone',\n        'menu_position' => 32,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'comments', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'season', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Seasons',\n            'singular_name' => 'Season',\n            'menu_name'     => 'Seasons',\n            'all_items'     => 'All Seasons',\n            'edit_item'     => 'Edit Season',\n            'view_item'     => 'View Season',\n            'update_item'   => 'Update Season',\n            'add_new_item'  => 'Add New Season',\n            'new_item_name' => 'New Season Name',\n            'search_items'  => 'Search Seasons',\n            'not_found'     => 'No seasons found.',\n            'parent_item'   => 'Parent Season',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'season', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Embed the audio with the Audio block in each episode."
        },
        {
            "id": "cpt-videos",
            "version": 1,
            "title": "Videos Post Type With Video Categories",
            "description": "Registers a \"Videos\" post type with its own categories, for video libraries and tutorials kept separate from blog posts.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "videos",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'video', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Video',\n    'plural'   => 'Videos',\n    'slug'     => 'videos',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-video-alt3',\n        'menu_position' => 33,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'comments' ),\n    ) );\n\n    register_taxonomy( 'video_category', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Video Categories',\n            'singular_name' => 'Video Category',\n            'menu_name'     => 'Video Categories',\n            'all_items'     => 'All Video Categories',\n            'edit_item'     => 'Edit Video Category',\n            'view_item'     => 'View Video Category',\n            'update_item'   => 'Update Video Category',\n            'add_new_item'  => 'Add New Video Category',\n            'new_item_name' => 'New Video Category Name',\n            'search_items'  => 'Search Video Categories',\n            'not_found'     => 'No video categories found.',\n            'parent_item'   => 'Parent Video Category',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'video-category', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once after activating so the new URLs work."
        },
        {
            "id": "cpt-resources",
            "version": 1,
            "title": "Resources Post Type for Downloads and Guides",
            "description": "Registers a \"Resources\" post type with a Resource Type taxonomy (guide, template, checklist) for a downloads or resource library.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "downloads",
                "resources"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'resource', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Resource',\n    'plural'   => 'Resources',\n    'slug'     => 'resources',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-download',\n        'menu_position' => 34,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail' ),\n    ) );\n\n    register_taxonomy( 'resource_type', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Resource Types',\n            'singular_name' => 'Resource Type',\n            'menu_name'     => 'Resource Types',\n            'all_items'     => 'All Resource Types',\n            'edit_item'     => 'Edit Resource Type',\n            'view_item'     => 'View Resource Type',\n            'update_item'   => 'Update Resource Type',\n            'add_new_item'  => 'Add New Resource Type',\n            'new_item_name' => 'New Resource Type Name',\n            'search_items'  => 'Search Resource Types',\n            'not_found'     => 'No resource types found.',\n            'parent_item'   => 'Parent Resource Type',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'resource-type', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once after activating so the new URLs work."
        },
        {
            "id": "cpt-press-releases",
            "version": 1,
            "title": "Press Releases Post Type",
            "description": "Registers a \"Press Releases\" post type with its own /press/ archive and feed, keeping announcements apart from the blog.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "press",
                "news"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'press_release', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Press Release',\n    'plural'   => 'Press Releases',\n    'slug'     => 'press',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-megaphone',\n        'menu_position' => 35,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'author', 'revisions' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once after activating so the new URLs work."
        },
        {
            "id": "cpt-partners",
            "version": 1,
            "title": "Partners and Sponsors Post Type With Levels",
            "description": "Registers a \"Partners\" post type for sponsor and partner logos with a Partner Level taxonomy (gold, silver, bronze); entries have no single pages.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "sponsors",
                "logos"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'partner', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Partner',\n    'plural'   => 'Partners',\n    'slug'     => 'partners',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'              => false,\n        'show_ui'             => true,\n        'show_in_nav_menus'   => false,\n        'exclude_from_search' => true,\n        'publicly_queryable'  => false,\n        'has_archive'         => false,\n        'rewrite'             => false,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-awards',\n        'menu_position' => 36,\n        'supports'      => array( 'title', 'excerpt', 'thumbnail', 'page-attributes' ),\n    ) );\n\n    register_taxonomy( 'partner_level', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Partner Levels',\n            'singular_name' => 'Partner Level',\n            'menu_name'     => 'Partner Levels',\n            'all_items'     => 'All Partner Levels',\n            'edit_item'     => 'Edit Partner Level',\n            'view_item'     => 'View Partner Level',\n            'update_item'   => 'Update Partner Level',\n            'add_new_item'  => 'Add New Partner Level',\n            'new_item_name' => 'New Partner Level Name',\n            'search_items'  => 'Search Partner Levels',\n            'not_found'     => 'No partner levels found.',\n            'parent_item'   => 'Parent Partner Level',\n        ),\n        'hierarchical'      => true,\n        'public'            => false,\n        'show_ui'           => true,\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Use the featured image as the logo and the excerpt for a short blurb."
        },
        {
            "id": "cpt-properties",
            "version": 1,
            "title": "Real Estate Properties Post Type",
            "description": "Registers a \"Properties\" post type with Property Type and Property Status taxonomies (for sale, for rent, sold) for estate agents.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "real-estate",
                "listings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'property', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Property',\n    'plural'   => 'Properties',\n    'slug'     => 'properties',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-admin-home',\n        'menu_position' => 37,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'property_type', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Property Types',\n            'singular_name' => 'Property Type',\n            'menu_name'     => 'Property Types',\n            'all_items'     => 'All Property Types',\n            'edit_item'     => 'Edit Property Type',\n            'view_item'     => 'View Property Type',\n            'update_item'   => 'Update Property Type',\n            'add_new_item'  => 'Add New Property Type',\n            'new_item_name' => 'New Property Type Name',\n            'search_items'  => 'Search Property Types',\n            'not_found'     => 'No property types found.',\n            'parent_item'   => 'Parent Property Type',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'property-type', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n\n    register_taxonomy( 'property_status', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Property Statuses',\n            'singular_name' => 'Property Status',\n            'menu_name'     => 'Property Statuses',\n            'all_items'     => 'All Property Statuses',\n            'edit_item'     => 'Edit Property Status',\n            'view_item'     => 'View Property Status',\n            'update_item'   => 'Update Property Status',\n            'add_new_item'  => 'Add New Property Status',\n            'new_item_name' => 'New Property Status Name',\n            'search_items'  => 'Search Property Statuses',\n            'not_found'     => 'No property statuses found.',\n            'parent_item'   => 'Parent Property Status',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'property-status', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Add the Property Details meta box snippet for price, bedrooms and floor area."
        },
        {
            "id": "cpt-vehicles",
            "version": 1,
            "title": "Vehicle Listings Post Type With Makes",
            "description": "Registers a \"Vehicles\" post type with a Make taxonomy for car dealers and rental fleets, under /vehicles/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "vehicles",
                "listings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'vehicle', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Vehicle',\n    'plural'   => 'Vehicles',\n    'slug'     => 'vehicles',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-car',\n        'menu_position' => 38,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'vehicle_make', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Makes',\n            'singular_name' => 'Make',\n            'menu_name'     => 'Makes',\n            'all_items'     => 'All Makes',\n            'edit_item'     => 'Edit Make',\n            'view_item'     => 'View Make',\n            'update_item'   => 'Update Make',\n            'add_new_item'  => 'Add New Make',\n            'new_item_name' => 'New Make Name',\n            'search_items'  => 'Search Makes',\n            'not_found'     => 'No makes found.',\n            'parent_item'   => 'Parent Make',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'make', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Create models as child terms of each make."
        },
        {
            "id": "cpt-menu-items",
            "version": 1,
            "title": "Restaurant Menu Items Post Type",
            "description": "Registers a \"Menu Items\" post type with Menu Section taxonomy (starters, mains, desserts) for restaurant and cafe menus, without single pages.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "restaurant",
                "menu"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'menu_item', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Menu Item',\n    'plural'   => 'Menu Items',\n    'slug'     => 'menu',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'              => false,\n        'show_ui'             => true,\n        'show_in_nav_menus'   => false,\n        'exclude_from_search' => true,\n        'publicly_queryable'  => false,\n        'has_archive'         => false,\n        'rewrite'             => false,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-food',\n        'menu_position' => 39,\n        'supports'      => array( 'title', 'excerpt', 'thumbnail', 'page-attributes' ),\n    ) );\n\n    register_taxonomy( 'menu_section', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Menu Sections',\n            'singular_name' => 'Menu Section',\n            'menu_name'     => 'Menu Sections',\n            'all_items'     => 'All Menu Sections',\n            'edit_item'     => 'Edit Menu Section',\n            'view_item'     => 'View Menu Section',\n            'update_item'   => 'Update Menu Section',\n            'add_new_item'  => 'Add New Menu Section',\n            'new_item_name' => 'New Menu Section Name',\n            'search_items'  => 'Search Menu Sections',\n            'not_found'     => 'No menu sections found.',\n            'parent_item'   => 'Parent Menu Section',\n        ),\n        'hierarchical'      => true,\n        'public'            => false,\n        'show_ui'           => true,\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Use the excerpt for the dish description and Order for the position within a section."
        },
        {
            "id": "cpt-glossary",
            "version": 1,
            "title": "Glossary Post Type",
            "description": "Registers a \"Glossary\" post type where each term gets its own page under /glossary/, sorted alphabetically.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "glossary",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'glossary_term', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Glossary Term',\n    'plural'   => 'Glossary',\n    'slug'     => 'glossary',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-book',\n        'menu_position' => 40,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'revisions' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Pair it with the A–Z index shortcode snippet."
        },
        {
            "id": "cpt-knowledge-base",
            "version": 1,
            "title": "Knowledge Base Post Type With Topics",
            "description": "Registers a \"Knowledge Base\" post type with a nested Topic taxonomy for help articles and documentation, under /docs/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "documentation",
                "help"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'kb_article', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Article',\n    'plural'   => 'Knowledge Base',\n    'slug'     => 'docs',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-sos',\n        'menu_position' => 41,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'revisions', 'author' ),\n    ) );\n\n    register_taxonomy( 'kb_topic', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Topics',\n            'singular_name' => 'Topic',\n            'menu_name'     => 'Topics',\n            'all_items'     => 'All Topics',\n            'edit_item'     => 'Edit Topic',\n            'view_item'     => 'View Topic',\n            'update_item'   => 'Update Topic',\n            'add_new_item'  => 'Add New Topic',\n            'new_item_name' => 'New Topic Name',\n            'search_items'  => 'Search Topics',\n            'not_found'     => 'No topics found.',\n            'parent_item'   => 'Parent Topic',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'docs-topic', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once after activating so the new URLs work."
        },
        {
            "id": "cpt-clients",
            "version": 1,
            "title": "Clients Post Type for Logo Walls",
            "description": "Registers a private \"Clients\" post type to manage client names, logos and website links for a logo wall, with no public pages.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "clients",
                "logos"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'client', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Client',\n    'plural'   => 'Clients',\n    'slug'     => 'clients',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'              => false,\n        'show_ui'             => true,\n        'show_in_nav_menus'   => false,\n        'exclude_from_search' => true,\n        'publicly_queryable'  => false,\n        'has_archive'         => false,\n        'rewrite'             => false,\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-building',\n        'menu_position' => 42,\n        'supports'      => array( 'title', 'thumbnail', 'page-attributes', 'custom-fields' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Use the featured image for the logo. Show them with a Query Loop block."
        },
        {
            "id": "cpt-product-catalog",
            "version": 1,
            "title": "Product Catalog Post Type Without WooCommerce",
            "description": "Registers a \"Products\" catalog post type with Product Line categories for showcasing products when you do not sell online.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "catalog",
                "products"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'catalog_item', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Product',\n    'plural'   => 'Products',\n    'slug'     => 'products',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-products',\n        'menu_position' => 43,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'thumbnail', 'revisions' ),\n    ) );\n\n    register_taxonomy( 'product_line', $wpide_type['name'], array(\n        'labels'            => array(\n            'name'          => 'Product Lines',\n            'singular_name' => 'Product Line',\n            'menu_name'     => 'Product Lines',\n            'all_items'     => 'All Product Lines',\n            'edit_item'     => 'Edit Product Line',\n            'view_item'     => 'View Product Line',\n            'update_item'   => 'Update Product Line',\n            'add_new_item'  => 'Add New Product Line',\n            'new_item_name' => 'New Product Line Name',\n            'search_items'  => 'Search Product Lines',\n            'not_found'     => 'No product lines found.',\n            'parent_item'   => 'Parent Product Line',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'product-line', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Do not use alongside WooCommerce, which already registers the product post type; the name here is catalog_item."
        },
        {
            "id": "cpt-changelog",
            "version": 1,
            "title": "Changelog Post Type for Release Notes",
            "description": "Registers a \"Releases\" post type for product release notes and changelogs, with an archive and feed at /changelog/.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "changelog",
                "software"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Names used in labels and URLs: edit to suit.\n$wpide_type = array(\n    'name'     => 'release', // Internal name: lowercase, max 20 characters, never change it once you have content.\n    'singular' => 'Release',\n    'plural'   => 'Releases',\n    'slug'     => 'changelog',\n);\n\nadd_action( 'init', function () use ( $wpide_type ) {\n    $s = $wpide_type['singular'];\n    $p = $wpide_type['plural'];\n\n    register_post_type( $wpide_type['name'], array(\n        'labels'        => array(\n            'name'                  => $p,\n            'singular_name'         => $s,\n            'menu_name'             => $p,\n            'add_new_item'          => \"Add New $s\",\n            'edit_item'             => \"Edit $s\",\n            'new_item'              => \"New $s\",\n            'view_item'             => \"View $s\",\n            'view_items'            => \"View $p\",\n            'all_items'             => \"All $p\",\n            'search_items'          => \"Search $p\",\n            'not_found'             => 'Nothing found.',\n            'not_found_in_trash'    => 'Nothing found in Trash.',\n            'archives'              => \"$s Archives\",\n            'attributes'            => \"$s Attributes\",\n            'insert_into_item'      => \"Insert into $s\",\n            'uploaded_to_this_item' => \"Uploaded to this $s\",\n            'item_published'        => \"$s published.\",\n            'item_updated'          => \"$s updated.\",\n        ),\n        'public'        => true,\n        'has_archive'   => $wpide_type['slug'],\n        'rewrite'       => array( 'slug' => $wpide_type['slug'], 'with_front' => false ),\n        'show_in_rest'  => true,\n        'menu_icon'     => 'dashicons-update',\n        'menu_position' => 44,\n        'supports'      => array( 'title', 'editor', 'excerpt', 'revisions' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Use the title for the version number, for example \"Version 2.4\"."
        },
        {
            "id": "tax-media-categories",
            "version": 1,
            "title": "Categories for the Media Library",
            "description": "Adds a \"Media Categories\" taxonomy to attachments, so you can organise uploads and filter the Media Library list view by category.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "media",
                "organisation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy( 'media_category', 'attachment', array(\n        'labels'            => array(\n            'name'          => 'Media Categories',\n            'singular_name' => 'Media Category',\n            'menu_name'     => 'Media Categories',\n            'all_items'     => 'All Media Categories',\n            'edit_item'     => 'Edit Media Category',\n            'view_item'     => 'View Media Category',\n            'update_item'   => 'Update Media Category',\n            'add_new_item'  => 'Add New Media Category',\n            'new_item_name' => 'New Media Category Name',\n            'search_items'  => 'Search Media Categories',\n            'not_found'     => 'No media categories found.',\n            'parent_item'   => 'Parent Media Category',\n        ),\n        'hierarchical'      => true,\n        'public'            => false,\n        'show_ui'           => true,\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n        'update_count_callback' => '_update_generic_term_count',\n    ) );\n} );",
            "requires": [],
            "notes": "Set categories when editing an item in the Media Library list view."
        },
        {
            "id": "tax-shared-topics",
            "version": 1,
            "title": "Topics Taxonomy Shared by Posts and Pages",
            "description": "Registers a \"Topics\" taxonomy used by both posts and pages, so related content of either kind can be grouped and listed together.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "topics",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy( 'content_topic', array( 'post', 'page' ), array(\n        'labels'            => array(\n            'name'          => 'Topics',\n            'singular_name' => 'Topic',\n            'menu_name'     => 'Topics',\n            'all_items'     => 'All Topics',\n            'edit_item'     => 'Edit Topic',\n            'view_item'     => 'View Topic',\n            'update_item'   => 'Update Topic',\n            'add_new_item'  => 'Add New Topic',\n            'new_item_name' => 'New Topic Name',\n            'search_items'  => 'Search Topics',\n            'not_found'     => 'No topics found.',\n            'parent_item'   => 'Parent Topic',\n        ),\n        'hierarchical'      => true,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'topic', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Add your custom post types to the list to group them too."
        },
        {
            "id": "tax-post-series",
            "version": 1,
            "title": "Series Taxonomy for Multi-Part Posts",
            "description": "Registers a tag-style \"Series\" taxonomy for posts, so tutorials or stories split across several posts can be grouped and linked.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "series",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy( 'series', 'post', array(\n        'labels'            => array(\n            'name'                       => 'Series',\n            'singular_name'              => 'Series',\n            'menu_name'                  => 'Series',\n            'all_items'                  => 'All Series',\n            'edit_item'                  => 'Edit Series',\n            'view_item'                  => 'View Series',\n            'update_item'                => 'Update Series',\n            'add_new_item'               => 'Add New Series',\n            'new_item_name'              => 'New Series Name',\n            'search_items'               => 'Search Series',\n            'not_found'                  => 'No series found.',\n            'popular_items'              => 'Popular Series',\n            'separate_items_with_commas' => 'Separate series with commas',\n            'choose_from_most_used'      => 'Choose from the most used series',\n        ),\n        'hierarchical'      => false,\n        'public'            => true,\n        'rewrite'           => array( 'slug' => 'series', 'with_front' => false ),\n        'show_admin_column' => true,\n        'show_in_rest'      => true,\n    ) );\n} );",
            "requires": [],
            "notes": "Each series gets an archive at /series/name/."
        },
        {
            "id": "metabox-event-details",
            "version": 1,
            "title": "Event Details Meta Box",
            "description": "Adds an \"Event details\" box (start, end, venue, address, price, attendance mode and ticket link) to events, saved securely, plus an [event_details] shortcode to display them.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "events",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'event',\n    'title'     => 'Event details',\n    'fields'    => array(\n        'event_start' => array( 'label' => 'Starts', 'type' => 'datetime-local' ),\n        'event_end' => array( 'label' => 'Ends', 'type' => 'datetime-local' ),\n        'event_venue' => array( 'label' => 'Venue name', 'type' => 'text' ),\n        'event_address' => array( 'label' => 'Address', 'type' => 'text' ),\n        'event_price' => array( 'label' => 'Price (0 = free)', 'type' => 'number' ),\n        'event_mode' => array( 'label' => 'Attendance', 'type' => 'select', 'options' => array( 'offline' => 'In person', 'online' => 'Online', 'mixed' => 'In person and online' ) ),\n        'event_tickets' => array( 'label' => 'Tickets URL', 'type' => 'url' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-event-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_event_details', 'wpide_event_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_event_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_event_details_nonce'] ), 'wpide_event_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'event_details', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'event' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get   = function ( $key ) use ( $id ) { return (string) get_post_meta( $id, '_' . $key, true ); };\n    // Stored as local date-time (site timezone).\n    $to_time = function ( $value ) {\n        $date = $value ? date_create_immutable( $value, wp_timezone() ) : false;\n\n        return $date ? $date->getTimestamp() : 0;\n    };\n    $start = $to_time( $get( 'event_start' ) );\n    $rows  = array();\n\n    if ( $start ) {\n        $fmt  = get_option( 'date_format' ) . ' ' . get_option( 'time_format' );\n        $when = wp_date( $fmt, $start );\n        $end  = $to_time( $get( 'event_end' ) );\n\n        if ( $end > $start ) {\n            $when .= ' – ' . wp_date( wp_date( 'Y-m-d', $end ) === wp_date( 'Y-m-d', $start ) ? get_option( 'time_format' ) : $fmt, $end );\n        }\n\n        $rows['When'] = esc_html( $when );\n    }\n\n    if ( $get( 'event_venue' ) || $get( 'event_address' ) ) {\n        $rows['Where'] = esc_html( trim( $get( 'event_venue' ) . ', ' . $get( 'event_address' ), ', ' ) );\n    }\n\n    if ( '' !== $get( 'event_price' ) ) {\n        $rows['Price'] = 0.0 === (float) $get( 'event_price' ) ? 'Free' : esc_html( $get( 'event_price' ) );\n    }\n\n    if ( $get( 'event_tickets' ) ) {\n        $rows['Tickets'] = '<a href=\"' . esc_url( $get( 'event_tickets' ) ) . '\">Get tickets</a>';\n    }\n\n    $html = '';\n\n    foreach ( $rows as $label => $value ) {\n        $html .= '<dt>' . esc_html( $label ) . '</dt><dd>' . $value . '</dd>';\n    }\n\n    return $html ? '<dl class=\"event-details\">' . $html . '</dl>' : '';\n} );",
            "requires": [],
            "notes": "Made for the Events post type snippet; change post_type to use it with another. The Event schema snippet reads these fields."
        },
        {
            "id": "metabox-testimonial-details",
            "version": 1,
            "title": "Testimonial Details Meta Box",
            "description": "Adds fields for the customer name, role, company, website and a 1–5 star rating to testimonials, plus a [testimonial_details] shortcode for the attribution line.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "testimonials",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'testimonial',\n    'title'     => 'Testimonial details',\n    'fields'    => array(\n        'testimonial_name' => array( 'label' => 'Customer name', 'type' => 'text' ),\n        'testimonial_role' => array( 'label' => 'Role', 'type' => 'text' ),\n        'testimonial_company' => array( 'label' => 'Company', 'type' => 'text' ),\n        'testimonial_url' => array( 'label' => 'Website', 'type' => 'url' ),\n        'testimonial_rating' => array( 'label' => 'Rating', 'type' => 'select', 'options' => array( '' => '—', '5' => '★★★★★', '4' => '★★★★', '3' => '★★★', '2' => '★★', '1' => '★' ) ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-testimonial-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_testimonial_details', 'wpide_testimonial_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_testimonial_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_testimonial_details_nonce'] ), 'wpide_testimonial_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'testimonial_details', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'testimonial' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $name    = get_post_meta( $id, '_testimonial_name', true );\n    $role    = trim( get_post_meta( $id, '_testimonial_role', true ) . ', ' . get_post_meta( $id, '_testimonial_company', true ), ', ' );\n    $url     = get_post_meta( $id, '_testimonial_url', true );\n    $rating  = (int) get_post_meta( $id, '_testimonial_rating', true );\n    $html    = $rating ? '<span class=\"testimonial-rating\" role=\"img\" aria-label=\"' . esc_attr( $rating . ' out of 5 stars' ) . '\">' . str_repeat( '★', $rating ) . str_repeat( '☆', 5 - $rating ) . '</span> ' : '';\n    $name    = $name ? $name : get_the_title( $id );\n    $html   .= '<cite>' . ( $url ? '<a href=\"' . esc_url( $url ) . '\" rel=\"nofollow noopener\">' . esc_html( $name ) . '</a>' : esc_html( $name ) ) . '</cite>';\n\n    return '<p class=\"testimonial-attribution\">' . $html . ( $role ? ' <span class=\"testimonial-role\">' . esc_html( $role ) . '</span>' : '' ) . '</p>';\n} );",
            "requires": [],
            "notes": "Made for the Testimonials post type snippet. The quote itself is the post content."
        },
        {
            "id": "metabox-team-details",
            "version": 1,
            "title": "Team Member Details Meta Box",
            "description": "Adds job title, email, phone and LinkedIn fields to team member profiles and a [team_details] shortcode that shows them with clickable links.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "team",
                "staff"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'team',\n    'title'     => 'Contact details',\n    'fields'    => array(\n        'team_role' => array( 'label' => 'Job title', 'type' => 'text' ),\n        'team_email' => array( 'label' => 'Email', 'type' => 'email' ),\n        'team_phone' => array( 'label' => 'Phone', 'type' => 'tel' ),\n        'team_linkedin' => array( 'label' => 'LinkedIn URL', 'type' => 'url' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-team-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_team_details', 'wpide_team_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_team_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_team_details_nonce'] ), 'wpide_team_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'team_details', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'team' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $role     = get_post_meta( $id, '_team_role', true );\n    $email    = get_post_meta( $id, '_team_email', true );\n    $phone    = get_post_meta( $id, '_team_phone', true );\n    $linkedin = get_post_meta( $id, '_team_linkedin', true );\n    $items    = array();\n\n    if ( $email && is_email( $email ) ) {\n        $items[] = '<a href=\"mailto:' . esc_attr( antispambot( $email ) ) . '\">' . esc_html( antispambot( $email ) ) . '</a>';\n    }\n\n    if ( $phone ) {\n        $items[] = '<a href=\"tel:' . esc_attr( preg_replace( '/[^\\d+]/', '', $phone ) ) . '\">' . esc_html( $phone ) . '</a>';\n    }\n\n    if ( $linkedin ) {\n        $items[] = '<a href=\"' . esc_url( $linkedin ) . '\" rel=\"noopener\">LinkedIn</a>';\n    }\n\n    return '<div class=\"team-details\">' . ( $role ? '<p class=\"team-role\">' . esc_html( $role ) . '</p>' : '' ) . ( $items ? '<p class=\"team-contact\">' . implode( ' · ', $items ) . '</p>' : '' ) . '</div>';\n} );",
            "requires": [],
            "notes": "Made for the Team post type snippet. Email addresses are lightly obfuscated with antispambot()."
        },
        {
            "id": "metabox-location-details",
            "version": 1,
            "title": "Location Details Meta Box",
            "description": "Adds address, phone, opening hours and map coordinates to locations and a [location_details] shortcode with a click-to-call link and directions.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "locations",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'location',\n    'title'     => 'Address and hours',\n    'fields'    => array(\n        'location_street' => array( 'label' => 'Street address', 'type' => 'text' ),\n        'location_city' => array( 'label' => 'City', 'type' => 'text' ),\n        'location_region' => array( 'label' => 'State or region', 'type' => 'text' ),\n        'location_postcode' => array( 'label' => 'Postcode', 'type' => 'text' ),\n        'location_country' => array( 'label' => 'Country code (e.g. US)', 'type' => 'text' ),\n        'location_phone' => array( 'label' => 'Phone', 'type' => 'tel' ),\n        'location_hours' => array( 'label' => 'Opening hours (one per line)', 'type' => 'textarea' ),\n        'location_lat' => array( 'label' => 'Latitude', 'type' => 'text' ),\n        'location_lng' => array( 'label' => 'Longitude', 'type' => 'text' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-location-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_location_details', 'wpide_location_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_location_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_location_details_nonce'] ), 'wpide_location_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'location_details', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'location' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get     = function ( $key ) use ( $id ) { return trim( (string) get_post_meta( $id, '_location_' . $key, true ) ); };\n    $address = implode( ', ', array_filter( array( $get( 'street' ), $get( 'city' ), trim( $get( 'region' ) . ' ' . $get( 'postcode' ) ), $get( 'country' ) ) ) );\n    $html    = '<div class=\"location-details\">';\n\n    if ( $address ) {\n        $dest  = ( $get( 'lat' ) && $get( 'lng' ) ) ? $get( 'lat' ) . ',' . $get( 'lng' ) : $address;\n        $html .= '<p class=\"location-address\">' . esc_html( $address ) . '<br><a href=\"' . esc_url( add_query_arg( array( 'api' => 1, 'destination' => $dest ), 'https://www.google.com/maps/dir/' ) ) . '\" target=\"_blank\" rel=\"noopener\">Get directions</a></p>';\n    }\n\n    if ( $get( 'phone' ) ) {\n        $html .= '<p class=\"location-phone\"><a href=\"tel:' . esc_attr( preg_replace( '/[^\\d+]/', '', $get( 'phone' ) ) ) . '\">' . esc_html( $get( 'phone' ) ) . '</a></p>';\n    }\n\n    $hours = array_filter( array_map( 'trim', explode( \"\\n\", $get( 'hours' ) ) ) );\n\n    if ( $hours ) {\n        $html .= '<ul class=\"location-hours\"><li>' . implode( '</li><li>', array_map( 'esc_html', $hours ) ) . '</li></ul>';\n    }\n\n    return $html . '</div>';\n} );",
            "requires": [],
            "notes": "Enter opening hours one per line, e.g. \"Mo-Fr 09:00-17:00\". The LocalBusiness-per-location schema snippet reads these fields."
        },
        {
            "id": "metabox-job-details",
            "version": 1,
            "title": "Job Details Meta Box",
            "description": "Adds employment type, salary range, location, remote option, closing date and apply link to job listings, plus a [job_details] shortcode.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "jobs",
                "careers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'job',\n    'title'     => 'Job details',\n    'fields'    => array(\n        'job_type' => array( 'label' => 'Employment type', 'type' => 'select', 'options' => array( 'FULL_TIME' => 'Full-time', 'PART_TIME' => 'Part-time', 'CONTRACTOR' => 'Contract', 'TEMPORARY' => 'Temporary', 'INTERN' => 'Internship' ) ),\n        'job_salary_min' => array( 'label' => 'Salary from', 'type' => 'number' ),\n        'job_salary_max' => array( 'label' => 'Salary to', 'type' => 'number' ),\n        'job_currency' => array( 'label' => 'Currency (e.g. USD)', 'type' => 'text' ),\n        'job_salary_unit' => array( 'label' => 'Salary per', 'type' => 'select', 'options' => array( 'YEAR' => 'Year', 'MONTH' => 'Month', 'HOUR' => 'Hour' ) ),\n        'job_city' => array( 'label' => 'City', 'type' => 'text' ),\n        'job_country' => array( 'label' => 'Country code', 'type' => 'text' ),\n        'job_remote' => array( 'label' => 'Remote job', 'type' => 'checkbox' ),\n        'job_valid_through' => array( 'label' => 'Applications close', 'type' => 'date' ),\n        'job_apply_url' => array( 'label' => 'Apply URL', 'type' => 'url' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-job-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_job_details', 'wpide_job_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_job_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_job_details_nonce'] ), 'wpide_job_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'job_details', function () use ( $wpide_box ) {\n    $id = get_the_ID();\n\n    if ( ! $id || 'job' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get  = function ( $key ) use ( $id ) { return (string) get_post_meta( $id, '_' . $key, true ); };\n    $rows = array();\n\n    if ( $get( 'job_type' ) ) {\n        $rows['Type'] = $wpide_box['fields']['job_type']['options'][ $get( 'job_type' ) ] ?? $get( 'job_type' );\n    }\n\n    $where = $get( 'job_remote' ) ? 'Remote' : '';\n    $where = trim( $where . ( $get( 'job_city' ) ? ( $where ? ' / ' : '' ) . $get( 'job_city' ) : '' ) );\n\n    if ( $where ) {\n        $rows['Location'] = $where;\n    }\n\n    if ( $get( 'job_salary_min' ) ) {\n        $rows['Salary'] = trim( number_format_i18n( (float) $get( 'job_salary_min' ) ) . ( $get( 'job_salary_max' ) ? ' – ' . number_format_i18n( (float) $get( 'job_salary_max' ) ) : '' ) . ' ' . $get( 'job_currency' ) ) . ' per ' . strtolower( $get( 'job_salary_unit' ) ? $get( 'job_salary_unit' ) : 'year' );\n    }\n\n    if ( $get( 'job_valid_through' ) ) {\n        $rows['Apply by'] = wp_date( get_option( 'date_format' ), strtotime( $get( 'job_valid_through' ) . ' 12:00' ) );\n    }\n\n    $html = '';\n\n    foreach ( $rows as $label => $value ) {\n        $html .= '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';\n    }\n\n    $apply = $get( 'job_apply_url' ) ? '<p><a class=\"wp-block-button__link wp-element-button\" href=\"' . esc_url( $get( 'job_apply_url' ) ) . '\">Apply now</a></p>' : '';\n\n    return ( $html ? '<dl class=\"job-details\">' . $html . '</dl>' : '' ) . $apply;\n} );",
            "requires": [],
            "notes": "Made for the Job Listings post type snippet. The JobPosting schema and auto-expire snippets read these fields."
        },
        {
            "id": "metabox-recipe-details",
            "version": 1,
            "title": "Recipe Details Meta Box",
            "description": "Adds prep and cook times, servings, calories, ingredients and steps to recipes, plus a [recipe_card] shortcode that prints a tidy recipe card.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "recipes",
                "food"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'recipe',\n    'title'     => 'Recipe details',\n    'fields'    => array(\n        'recipe_prep' => array( 'label' => 'Prep time (minutes)', 'type' => 'number' ),\n        'recipe_cook' => array( 'label' => 'Cook time (minutes)', 'type' => 'number' ),\n        'recipe_servings' => array( 'label' => 'Servings', 'type' => 'number' ),\n        'recipe_calories' => array( 'label' => 'Calories per serving', 'type' => 'number' ),\n        'recipe_ingredients' => array( 'label' => 'Ingredients (one per line)', 'type' => 'textarea' ),\n        'recipe_steps' => array( 'label' => 'Steps (one per line)', 'type' => 'textarea' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-recipe-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_recipe_details', 'wpide_recipe_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_recipe_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_recipe_details_nonce'] ), 'wpide_recipe_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'recipe_card', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'recipe' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get   = function ( $key ) use ( $id ) { return (string) get_post_meta( $id, '_recipe_' . $key, true ); };\n    $lines = function ( $text ) { return array_filter( array_map( 'trim', explode( \"\\n\", $text ) ) ); };\n    $facts = array_filter( array(\n        'Prep'     => $get( 'prep' ) ? (int) $get( 'prep' ) . ' min' : '',\n        'Cook'     => $get( 'cook' ) ? (int) $get( 'cook' ) . ' min' : '',\n        'Serves'   => $get( 'servings' ),\n        'Calories' => $get( 'calories' ),\n    ) );\n\n    $html = '<div class=\"recipe-card\">';\n\n    if ( $facts ) {\n        $html .= '<ul class=\"recipe-facts\">';\n        foreach ( $facts as $label => $value ) {\n            $html .= '<li><strong>' . esc_html( $label ) . ':</strong> ' . esc_html( $value ) . '</li>';\n        }\n        $html .= '</ul>';\n    }\n\n    if ( $lines( $get( 'ingredients' ) ) ) {\n        $html .= '<h3>Ingredients</h3><ul class=\"recipe-ingredients\"><li>' . implode( '</li><li>', array_map( 'esc_html', $lines( $get( 'ingredients' ) ) ) ) . '</li></ul>';\n    }\n\n    if ( $lines( $get( 'steps' ) ) ) {\n        $html .= '<h3>Method</h3><ol class=\"recipe-steps\"><li>' . implode( '</li><li>', array_map( 'esc_html', $lines( $get( 'steps' ) ) ) ) . '</li></ol>';\n    }\n\n    return $html . '</div>';\n} );",
            "requires": [],
            "notes": "Enter one ingredient or step per line. The Recipe schema snippet reads these fields."
        },
        {
            "id": "metabox-book-details",
            "version": 1,
            "title": "Book Details Meta Box",
            "description": "Adds author, ISBN, publisher, publication date, page count and a buy link to books, plus a [book_details] shortcode.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "books",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'book',\n    'title'     => 'Book details',\n    'fields'    => array(\n        'book_author_name' => array( 'label' => 'Author', 'type' => 'text' ),\n        'book_isbn' => array( 'label' => 'ISBN', 'type' => 'text' ),\n        'book_publisher' => array( 'label' => 'Publisher', 'type' => 'text' ),\n        'book_published' => array( 'label' => 'Published', 'type' => 'date' ),\n        'book_pages' => array( 'label' => 'Pages', 'type' => 'number' ),\n        'book_buy_url' => array( 'label' => 'Buy link', 'type' => 'url' ),\n    ),\n);\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-book-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_book_details', 'wpide_book_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_book_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_book_details_nonce'] ), 'wpide_book_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'book_details', function () {\n    $id = get_the_ID();\n\n    if ( ! $id || 'book' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get  = function ( $key ) use ( $id ) { return (string) get_post_meta( $id, '_book_' . $key, true ); };\n    $rows = array_filter( array(\n        'Author'    => $get( 'author_name' ),\n        'Publisher' => $get( 'publisher' ),\n        'Published' => $get( 'published' ) ? wp_date( get_option( 'date_format' ), strtotime( $get( 'published' ) . ' 12:00' ) ) : '',\n        'Pages'     => $get( 'pages' ),\n        'ISBN'      => $get( 'isbn' ),\n    ) );\n\n    $html = '';\n\n    foreach ( $rows as $label => $value ) {\n        $html .= '<dt>' . esc_html( $label ) . '</dt><dd>' . esc_html( $value ) . '</dd>';\n    }\n\n    $buy = $get( 'buy_url' ) ? '<p><a class=\"wp-block-button__link wp-element-button\" href=\"' . esc_url( $get( 'buy_url' ) ) . '\" rel=\"sponsored noopener\">Buy the book</a></p>' : '';\n\n    return ( $html ? '<dl class=\"book-details\">' . $html . '</dl>' : '' ) . $buy;\n} );",
            "requires": [],
            "notes": "Made for the Books post type snippet."
        },
        {
            "id": "metabox-property-details",
            "version": 1,
            "title": "Property Details Meta Box",
            "description": "Adds price, bedrooms, bathrooms, floor area and address fields to property listings and a [property_details] shortcode that shows them as a fact list.",
            "category": "post-types",
            "tags": [
                "meta-box",
                "real-estate",
                "listings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, and the fields: meta key (saved with a leading underscore) => label and input type.\n$wpide_box = array(\n    'post_type' => 'property',\n    'title'     => 'Property details',\n    'fields'    => array(\n        'property_price' => array( 'label' => 'Price', 'type' => 'number' ),\n        'property_beds' => array( 'label' => 'Bedrooms', 'type' => 'number' ),\n        'property_baths' => array( 'label' => 'Bathrooms', 'type' => 'number' ),\n        'property_area' => array( 'label' => 'Floor area', 'type' => 'number' ),\n        'property_area_unit' => array( 'label' => 'Area unit', 'type' => 'select', 'options' => array( 'sq ft' => 'sq ft', 'm²' => 'm²' ) ),\n        'property_address' => array( 'label' => 'Address', 'type' => 'text' ),\n    ),\n);\n\n// Currency symbol shown before prices.\n$wpide_currency = '$';\n\nadd_action( 'add_meta_boxes_' . $wpide_box['post_type'], function () use ( $wpide_box ) {\n    add_meta_box( 'wpide-property-details', $wpide_box['title'], function ( $post ) use ( $wpide_box ) {\n        wp_nonce_field( 'wpide_property_details', 'wpide_property_details_nonce' );\n        echo '<div style=\"display:grid;gap:12px;grid-template-columns:repeat(auto-fill,minmax(220px,1fr))\">';\n\n        foreach ( $wpide_box['fields'] as $key => $field ) {\n            $value = get_post_meta( $post->ID, '_' . $key, true );\n            $id    = 'wpide-' . str_replace( '_', '-', $key );\n            echo '<p style=\"margin:0\">';\n\n            if ( 'checkbox' === $field['type'] ) {\n                printf( '<label><input type=\"checkbox\" name=\"%1$s\" value=\"1\"%2$s> %3$s</label>', esc_attr( $key ), checked( $value, '1', false ), esc_html( $field['label'] ) );\n            } else {\n                printf( '<label for=\"%1$s\"><strong>%2$s</strong></label><br>', esc_attr( $id ), esc_html( $field['label'] ) );\n\n                if ( 'textarea' === $field['type'] ) {\n                    printf( '<textarea class=\"widefat\" rows=\"5\" id=\"%1$s\" name=\"%2$s\">%3$s</textarea>', esc_attr( $id ), esc_attr( $key ), esc_textarea( $value ) );\n                } elseif ( 'select' === $field['type'] ) {\n                    printf( '<select class=\"widefat\" id=\"%1$s\" name=\"%2$s\">', esc_attr( $id ), esc_attr( $key ) );\n                    foreach ( $field['options'] as $option => $label ) {\n                        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $option ), selected( (string) $value, (string) $option, false ), esc_html( $label ) );\n                    }\n                    echo '</select>';\n                } else {\n                    printf( '<input class=\"widefat\" type=\"%1$s\" id=\"%2$s\" name=\"%3$s\" value=\"%4$s\"%5$s>', esc_attr( $field['type'] ), esc_attr( $id ), esc_attr( $key ), esc_attr( $value ), 'number' === $field['type'] ? ' step=\"any\" min=\"0\"' : '' );\n                }\n            }\n\n            echo '</p>';\n        }\n\n        echo '</div>';\n    }, $wpide_box['post_type'], 'normal', 'high' );\n} );\n\nadd_action( 'save_post_' . $wpide_box['post_type'], function ( $post_id ) use ( $wpide_box ) {\n    if ( ! isset( $_POST['wpide_property_details_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_property_details_nonce'] ), 'wpide_property_details' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    foreach ( $wpide_box['fields'] as $key => $field ) {\n        $raw = isset( $_POST[ $key ] ) ? wp_unslash( $_POST[ $key ] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- sanitized below per type.\n        $raw = is_string( $raw ) ? $raw : '';\n\n        switch ( $field['type'] ) {\n            case 'url':\n                $value = esc_url_raw( $raw );\n                break;\n            case 'email':\n                $value = sanitize_email( $raw );\n                break;\n            case 'number':\n                $value = is_numeric( $raw ) ? (string) ( 0 + $raw ) : '';\n                break;\n            case 'textarea':\n                $value = sanitize_textarea_field( $raw );\n                break;\n            case 'checkbox':\n                $value = $raw ? '1' : '';\n                break;\n            case 'select':\n                $value = array_key_exists( $raw, $field['options'] ) ? $raw : '';\n                break;\n            case 'date':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            case 'datetime-local':\n                $value = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $raw ) ? $raw : '';\n                break;\n            default:\n                $value = sanitize_text_field( $raw );\n        }\n\n        if ( '' === $value ) {\n            delete_post_meta( $post_id, '_' . $key );\n        } else {\n            update_post_meta( $post_id, '_' . $key, $value );\n        }\n    }\n} );\n\nadd_shortcode( 'property_details', function () use ( $wpide_currency ) {\n    $id = get_the_ID();\n\n    if ( ! $id || 'property' !== get_post_type( $id ) ) {\n        return '';\n    }\n\n    $get  = function ( $key ) use ( $id ) { return (string) get_post_meta( $id, '_property_' . $key, true ); };\n    $rows = array_filter( array(\n        'Price'     => '' !== $get( 'price' ) ? $wpide_currency . number_format_i18n( (float) $get( 'price' ) ) : '',\n        'Bedrooms'  => $get( 'beds' ),\n        'Bathrooms' => $get( 'baths' ),\n        'Area'      => $get( 'area' ) ? number_format_i18n( (float) $get( 'area' ) ) . ' ' . $get( 'area_unit' ) : '',\n        'Address'   => $get( 'address' ),\n    ) );\n\n    $html = '';\n\n    foreach ( $rows as $label => $value ) {\n        $html .= '<li><strong>' . esc_html( $label ) . ':</strong> ' . esc_html( $value ) . '</li>';\n    }\n\n    return $html ? '<ul class=\"property-details\">' . $html . '</ul>' : '';\n} );",
            "requires": [],
            "notes": "Made for the Real Estate Properties post type snippet. Set the currency symbol at the top."
        },
        {
            "id": "testimonials-grid-shortcode",
            "version": 1,
            "title": "Testimonials Grid Shortcode",
            "description": "Adds a [testimonials] shortcode that shows testimonials as a responsive grid of quotes with the customer's name, role and star rating.",
            "category": "post-types",
            "tags": [
                "testimonials",
                "shortcode",
                "grid"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'testimonials', function ( $atts ) {\n    $atts = shortcode_atts( array( 'limit' => 6, 'columns' => 3, 'orderby' => 'menu_order' ), $atts, 'testimonials' );\n\n    if ( ! post_type_exists( 'testimonial' ) ) {\n        return '';\n    }\n\n    $orderby = in_array( $atts['orderby'], array( 'menu_order', 'date', 'rand' ), true ) ? $atts['orderby'] : 'menu_order';\n    $key     = 'wpide_testimonials_' . md5( wp_json_encode( $atts ) . get_option( 'wpide_testimonials_ver', '1' ) );\n    $html    = 'rand' === $orderby ? false : get_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $ids = get_posts( array(\n        'post_type'      => 'testimonial',\n        'posts_per_page' => min( 50, max( 1, absint( $atts['limit'] ) ) ),\n        'orderby'        => 'menu_order' === $orderby ? array( 'menu_order' => 'ASC', 'date' => 'DESC' ) : $orderby,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n    ) );\n\n    if ( ! $ids ) {\n        return '';\n    }\n\n    $min  = (int) floor( 960 / min( 4, max( 1, absint( $atts['columns'] ) ) ) ) - 24;\n    $html = '<div class=\"testimonials-grid\" style=\"display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(min(100%,' . $min . 'px),1fr))\">';\n\n    foreach ( $ids as $id ) {\n        $name   = get_post_meta( $id, '_testimonial_name', true );\n        $name   = $name ? $name : get_the_title( $id );\n        $role   = trim( get_post_meta( $id, '_testimonial_role', true ) . ', ' . get_post_meta( $id, '_testimonial_company', true ), ', ' );\n        $rating = (int) get_post_meta( $id, '_testimonial_rating', true );\n        $stars  = $rating ? '<span class=\"testimonial-rating\" role=\"img\" aria-label=\"' . esc_attr( $rating . ' out of 5 stars' ) . '\">' . str_repeat( '★', $rating ) . str_repeat( '☆', 5 - $rating ) . '</span>' : '';\n\n        $html .= '<figure class=\"testimonial\">' . $stars\n            . '<blockquote>' . wp_kses_post( wpautop( get_post_field( 'post_content', $id ) ) ) . '</blockquote>'\n            . '<figcaption><strong>' . esc_html( $name ) . '</strong>' . ( $role ? '<br><span>' . esc_html( $role ) . '</span>' : '' ) . '</figcaption></figure>';\n    }\n\n    $html .= '</div>';\n\n    if ( 'rand' !== $orderby ) {\n        set_transient( $key, $html, DAY_IN_SECONDS );\n    }\n\n    return $html;\n} );\n\n// Refresh the cached grids whenever a testimonial changes.\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && 'testimonial' === $post->post_type ) {\n        update_option( 'wpide_testimonials_ver', (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with the Testimonials post type and details meta box snippets. Use limit, columns and orderby=\"date|menu_order|rand\"."
        },
        {
            "id": "team-grid-shortcode",
            "version": 1,
            "title": "Team Members Grid Shortcode",
            "description": "Adds a [team] shortcode that shows team members with photo, name and job title in a responsive grid, optionally filtered by department.",
            "category": "post-types",
            "tags": [
                "team",
                "shortcode",
                "grid"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'team', function ( $atts ) {\n    $atts = shortcode_atts( array( 'department' => '', 'columns' => 4, 'limit' => 50 ), $atts, 'team' );\n\n    if ( ! post_type_exists( 'team' ) ) {\n        return '';\n    }\n\n    $key  = 'wpide_team_' . md5( wp_json_encode( $atts ) . get_option( 'wpide_team_ver', '1' ) );\n    $html = get_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $args = array(\n        'post_type'      => 'team',\n        'posts_per_page' => min( 100, absint( $atts['limit'] ) ),\n        'orderby'        => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n    );\n\n    if ( $atts['department'] && taxonomy_exists( 'department' ) ) {\n        $args['tax_query'] = array( array( 'taxonomy' => 'department', 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['department'] ) ) ) );\n    }\n\n    $ids  = get_posts( $args );\n    $min  = (int) floor( 960 / min( 6, max( 1, absint( $atts['columns'] ) ) ) ) - 24;\n    $html = '';\n\n    foreach ( $ids as $id ) {\n        $name  = esc_html( get_the_title( $id ) );\n        $name  = is_post_publicly_viewable( $id ) ? '<a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . $name . '</a>' : $name;\n        $role  = get_post_meta( $id, '_team_role', true );\n        $html .= '<div class=\"team-member\">' . get_the_post_thumbnail( $id, 'medium', array( 'loading' => 'lazy', 'style' => 'width:100%;height:auto;aspect-ratio:1;object-fit:cover' ) )\n            . '<h3 class=\"team-name\" style=\"margin:.5em 0 0\">' . $name . '</h3>' . ( $role ? '<p class=\"team-role\" style=\"margin:0\">' . esc_html( $role ) . '</p>' : '' ) . '</div>';\n    }\n\n    $html = $html ? '<div class=\"team-grid\" style=\"display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(min(100%,' . $min . 'px),1fr))\">' . $html . '</div>' : '';\n    set_transient( $key, $html, DAY_IN_SECONDS );\n\n    return $html;\n} );\n\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && 'team' === $post->post_type ) {\n        update_option( 'wpide_team_ver', (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with the Team post type and Team details snippets. Example: [team department=\"sales\" columns=\"4\"]."
        },
        {
            "id": "faq-list-shortcode",
            "version": 1,
            "title": "FAQ Accordion Shortcode With Schema",
            "description": "Adds a [faq_list] shortcode that shows FAQ posts as accessible collapsible questions and adds FAQPage structured data for them.",
            "category": "post-types",
            "tags": [
                "faq",
                "shortcode",
                "schema"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'faq_list', function ( $atts ) {\n    $atts = shortcode_atts( array( 'group' => '', 'limit' => 50, 'schema' => 'yes' ), $atts, 'faq_list' );\n\n    if ( ! post_type_exists( 'faq' ) ) {\n        return '';\n    }\n\n    $args = array(\n        'post_type'      => 'faq',\n        'posts_per_page' => min( 100, absint( $atts['limit'] ) ),\n        'orderby'        => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n        'no_found_rows'  => true,\n    );\n\n    if ( $atts['group'] && taxonomy_exists( 'faq_group' ) ) {\n        $args['tax_query'] = array( array( 'taxonomy' => 'faq_group', 'field' => 'slug', 'terms' => sanitize_title( $atts['group'] ) ) );\n    }\n\n    $faqs = get_posts( $args );\n\n    if ( ! $faqs ) {\n        return '';\n    }\n\n    $html   = '<div class=\"faq-list\">';\n    $schema = array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => array() );\n\n    foreach ( $faqs as $faq ) {\n        $answer = wp_kses_post( wpautop( do_shortcode( $faq->post_content ) ) );\n        $html  .= '<details class=\"faq-item\"><summary>' . esc_html( get_the_title( $faq ) ) . '</summary><div class=\"faq-answer\">' . $answer . '</div></details>';\n        $schema['mainEntity'][] = array(\n            '@type'          => 'Question',\n            'name'           => wp_strip_all_tags( get_the_title( $faq ) ),\n            'acceptedAnswer' => array( '@type' => 'Answer', 'text' => trim( wp_strip_all_tags( $answer, true ) ) ),\n        );\n    }\n\n    $html .= '</div>';\n\n    if ( 'no' !== $atts['schema'] ) {\n        $html .= '<script type=\"application/ld+json\">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . '</script>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Works with the FAQ post type snippet. Filter by group with group=\"billing\", or turn the schema off with schema=\"no\"."
        },
        {
            "id": "upcoming-events-shortcode",
            "version": 1,
            "title": "Upcoming Events Shortcode for an Events Post Type",
            "description": "Adds an [upcoming_events] shortcode that lists the next events by start date, skipping events that have already begun.",
            "category": "post-types",
            "tags": [
                "events",
                "shortcode",
                "calendar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'upcoming_events', function ( $atts ) {\n    $atts = shortcode_atts( array( 'limit' => 5 ), $atts, 'upcoming_events' );\n\n    if ( ! post_type_exists( 'event' ) ) {\n        return '';\n    }\n\n    $now = wp_date( 'Y-m-d\\TH:i' );\n    $key = 'wpide_upcoming_' . md5( $atts['limit'] . get_option( 'wpide_events_ver', '1' ) . substr( $now, 0, 13 ) ); // Refreshes hourly.\n    $ids = get_transient( $key );\n\n    if ( false === $ids ) {\n        $ids = get_posts( array(\n            'post_type'      => 'event',\n            'posts_per_page' => min( 50, max( 1, absint( $atts['limit'] ) ) ),\n            'meta_key'       => '_event_start',\n            'orderby'        => 'meta_value',\n            'order'          => 'ASC',\n            'meta_query'     => array( array( 'key' => '_event_start', 'value' => $now, 'compare' => '>=' ) ),\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) );\n        set_transient( $key, $ids, HOUR_IN_SECONDS );\n    }\n\n    if ( ! $ids ) {\n        return '<p class=\"upcoming-events-empty\">No upcoming events.</p>';\n    }\n\n    $html = '<ul class=\"upcoming-events\">';\n\n    foreach ( $ids as $id ) {\n        $date  = date_create_immutable( (string) get_post_meta( $id, '_event_start', true ), wp_timezone() );\n        $when  = $date ? '<time datetime=\"' . esc_attr( $date->format( DATE_W3C ) ) . '\">' . esc_html( wp_date( get_option( 'date_format' ), $date->getTimestamp() ) ) . '</time> — ' : '';\n        $html .= '<li>' . $when . '<a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( get_the_title( $id ) ) . '</a></li>';\n    }\n\n    return $html . '</ul>';\n} );\n\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && 'event' === $post->post_type ) {\n        update_option( 'wpide_events_ver', (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Reads the start date saved by the Event Details meta box snippet (_event_start). Use limit=\"10\" to show more."
        },
        {
            "id": "events-admin-date-column",
            "version": 1,
            "title": "Sortable Event Date Column for Events",
            "description": "Adds \"Starts\" and \"Venue\" columns to the Events admin list, sorts the list by start date by default and lets you click to reverse it.",
            "category": "post-types",
            "tags": [
                "events",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_event_posts_columns', function ( $columns ) {\n    $date = $columns['date'] ?? null;\n    unset( $columns['date'] );\n    $columns['event_start'] = 'Starts';\n    $columns['event_venue'] = 'Venue';\n\n    if ( $date ) {\n        $columns['date'] = $date;\n    }\n\n    return $columns;\n} );\n\nadd_action( 'manage_event_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'event_start' === $column ) {\n        $date = date_create_immutable( (string) get_post_meta( $post_id, '_event_start', true ), wp_timezone() );\n        echo $date && get_post_meta( $post_id, '_event_start', true ) ? esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $date->getTimestamp() ) ) : '—';\n    } elseif ( 'event_venue' === $column ) {\n        echo esc_html( get_post_meta( $post_id, '_event_venue', true ) ?: '—' );\n    }\n}, 10, 2 );\n\nadd_filter( 'manage_edit-event_sortable_columns', function ( $columns ) {\n    $columns['event_start'] = 'event_start';\n\n    return $columns;\n} );\n\n// Sort with a LEFT JOIN so events without a date stay in the list.\nadd_filter( 'posts_clauses', function ( $clauses, $query ) {\n    global $wpdb;\n\n    if ( ! is_admin() || ! $query->is_main_query() || 'event' !== $query->get( 'post_type' ) || ! in_array( $query->get( 'orderby' ), array( '', 'event_start' ), true ) ) {\n        return $clauses;\n    }\n\n    $order = 'ASC' === strtoupper( (string) $query->get( 'order' ) ) ? 'ASC' : 'DESC';\n\n    $clauses['join']   .= $wpdb->prepare( \" LEFT JOIN {$wpdb->postmeta} AS wpide_ev ON ( wpide_ev.post_id = {$wpdb->posts}.ID AND wpide_ev.meta_key = %s )\", '_event_start' );\n    $clauses['orderby'] = \"wpide_ev.meta_value {$order}, {$wpdb->posts}.post_date DESC\";\n\n    return $clauses;\n}, 10, 2 );",
            "requires": [],
            "notes": "Reads the fields saved by the Event Details meta box snippet."
        },
        {
            "id": "event-add-to-calendar",
            "version": 1,
            "title": "\"Add to Calendar\" Download for Events",
            "description": "Adds an \"Add to calendar\" link to single events that downloads an .ics file for Apple Calendar, Outlook and Google Calendar.",
            "category": "post-types",
            "tags": [
                "events",
                "calendar",
                "ics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! isset( $_GET['ical'] ) || ! is_singular( 'event' ) ) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only download.\n        return;\n    }\n\n    $post  = get_queried_object();\n    $tz    = wp_timezone();\n    $start = date_create_immutable( (string) get_post_meta( $post->ID, '_event_start', true ), $tz );\n\n    if ( ! $start || ! get_post_meta( $post->ID, '_event_start', true ) ) {\n        return;\n    }\n\n    $end_raw = (string) get_post_meta( $post->ID, '_event_end', true );\n    $end     = $end_raw ? date_create_immutable( $end_raw, $tz ) : false;\n    $end     = ( $end && $end > $start ) ? $end : $start->modify( '+1 hour' );\n    $utc     = new DateTimeZone( 'UTC' );\n    $text    = function ( $value ) {\n        return addcslashes( str_replace( array( \"\\r\\n\", \"\\n\" ), '\\n', wp_strip_all_tags( (string) $value ) ), ',;\\\\' );\n    };\n    $where   = trim( get_post_meta( $post->ID, '_event_venue', true ) . ', ' . get_post_meta( $post->ID, '_event_address', true ), ', ' );\n\n    $lines = array(\n        'BEGIN:VCALENDAR',\n        'VERSION:2.0',\n        'PRODID:-//' . $text( get_bloginfo( 'name' ) ) . '//Events//EN',\n        'CALSCALE:GREGORIAN',\n        'BEGIN:VEVENT',\n        'UID:event-' . $post->ID . '@' . wp_parse_url( home_url(), PHP_URL_HOST ),\n        'DTSTAMP:' . gmdate( 'Ymd\\THis\\Z' ),\n        'DTSTART:' . $start->setTimezone( $utc )->format( 'Ymd\\THis\\Z' ),\n        'DTEND:' . $end->setTimezone( $utc )->format( 'Ymd\\THis\\Z' ),\n        'SUMMARY:' . $text( get_the_title( $post ) ),\n        'URL:' . esc_url_raw( get_permalink( $post ) ),\n        'DESCRIPTION:' . $text( get_the_excerpt( $post ) ),\n        'LOCATION:' . $text( $where ),\n        'END:VEVENT',\n        'END:VCALENDAR',\n    );\n\n    nocache_headers();\n    header( 'Content-Type: text/calendar; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=\"' . sanitize_file_name( $post->post_name ) . '.ics\"' );\n    echo implode( \"\\r\\n\", $lines ) . \"\\r\\n\"; // phpcs:ignore WordPress.Security.EscapeOutput -- calendar format, escaped above.\n    exit;\n} );\n\nadd_filter( 'the_content', function ( $content ) {\n    if ( ! is_singular( 'event' ) || ! in_the_loop() || ! is_main_query() || ! get_post_meta( get_the_ID(), '_event_start', true ) ) {\n        return $content;\n    }\n\n    return $content . '<p class=\"event-add-to-calendar\"><a href=\"' . esc_url( add_query_arg( 'ical', '1', get_permalink() ) ) . '\" rel=\"nofollow\">Add to calendar (.ics)</a></p>';\n} );",
            "requires": [],
            "notes": "Reads the start, end, venue and address saved by the Event Details meta box snippet. Events without an end time last one hour."
        },
        {
            "id": "auto-expire-by-date",
            "version": 1,
            "title": "Automatically Expire Listings After Their Closing Date",
            "description": "Adds an \"Expired\" status and an hourly check that moves job listings (or any post type) out of public view once their closing date has passed.",
            "category": "post-types",
            "tags": [
                "custom-status",
                "cron",
                "jobs"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type and the meta key holding the closing date (Y-m-d or Y-m-d\\TH:i).\n$wpide_expire = array(\n    'post_type' => 'job',\n    'meta_key'  => '_job_valid_through',\n);\n\nadd_action( 'init', function () use ( $wpide_expire ) {\n    register_post_status( 'expired', array(\n        'label'                     => 'Expired',\n        'public'                    => false,\n        'protected'                 => true,\n        'exclude_from_search'       => true,\n        'show_in_admin_all_list'    => true,\n        'show_in_admin_status_list' => true,\n        /* translators: %s: number of items */\n        'label_count'               => _n_noop( 'Expired <span class=\"count\">(%s)</span>', 'Expired <span class=\"count\">(%s)</span>' ),\n    ) );\n\n    if ( ! wp_next_scheduled( 'wpide_expire_listings' ) ) {\n        wp_schedule_event( time() + 300, 'hourly', 'wpide_expire_listings' );\n    }\n} );\n\nadd_action( 'wpide_expire_listings', function () use ( $wpide_expire ) {\n    $ids = get_posts( array(\n        'post_type'      => $wpide_expire['post_type'],\n        'post_status'    => 'publish',\n        'posts_per_page' => 100,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n        'meta_query'     => array(\n            array( 'key' => $wpide_expire['meta_key'], 'value' => '', 'compare' => '!=' ),\n            array( 'key' => $wpide_expire['meta_key'], 'value' => wp_date( 'Y-m-d' ), 'compare' => '<' ),\n        ),\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_update_post( array( 'ID' => $id, 'post_status' => 'expired' ) );\n    }\n} );\n\nadd_filter( 'display_post_states', function ( $states, $post ) {\n    if ( 'expired' === $post->post_status ) {\n        $states['expired'] = 'Expired';\n    }\n\n    return $states;\n}, 10, 2 );",
            "requires": [],
            "notes": "Defaults to jobs and the _job_valid_through date from the Job Details snippet; change the post type and meta key for events, offers or adverts. Expired items stay in the admin under \"Expired\"."
        },
        {
            "id": "featured-items-first",
            "version": 1,
            "title": "Show Featured Items First on Post Type Archives",
            "description": "Adds a \"Featured\" checkbox to chosen post types and lists featured items at the top of their archives, newest first after that.",
            "category": "post-types",
            "tags": [
                "featured",
                "archives",
                "meta-box"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the Featured checkbox and ordering.\n$wpide_featured_types = array( 'event', 'job', 'property' );\n\nadd_action( 'add_meta_boxes', function ( $post_type ) use ( $wpide_featured_types ) {\n    if ( ! in_array( $post_type, $wpide_featured_types, true ) ) {\n        return;\n    }\n\n    add_meta_box( 'wpide-featured', 'Featured', function ( $post ) {\n        wp_nonce_field( 'wpide_featured_save', 'wpide_featured_nonce' );\n        printf( '<label><input type=\"checkbox\" name=\"wpide_featured\" value=\"1\"%s> Show at the top of the archive</label>', checked( get_post_meta( $post->ID, '_wpide_featured', true ), '1', false ) );\n    }, $post_type, 'side', 'high' );\n} );\n\nadd_action( 'save_post', function ( $post_id, $post ) use ( $wpide_featured_types ) {\n    if ( ! in_array( $post->post_type, $wpide_featured_types, true ) || ! isset( $_POST['wpide_featured_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_featured_nonce'] ), 'wpide_featured_save' ) ) {\n        return;\n    }\n\n    if ( wp_is_post_autosave( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    if ( ! empty( $_POST['wpide_featured'] ) ) {\n        update_post_meta( $post_id, '_wpide_featured', '1' );\n    } else {\n        delete_post_meta( $post_id, '_wpide_featured' );\n    }\n}, 10, 2 );\n\nadd_filter( 'posts_clauses', function ( $clauses, $query ) use ( $wpide_featured_types ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( $wpide_featured_types ) ) {\n        return $clauses;\n    }\n\n    // Join the flag once and sort flagged items first, keeping the existing order after that.\n    $clauses['join']   .= $wpdb->prepare( \" LEFT JOIN {$wpdb->postmeta} AS wpide_feat ON ( wpide_feat.post_id = {$wpdb->posts}.ID AND wpide_feat.meta_key = %s )\", '_wpide_featured' );\n    $clauses['orderby'] = \"( wpide_feat.meta_value = '1' ) DESC\" . ( $clauses['orderby'] ? ', ' . $clauses['orderby'] : '' );\n\n    return $clauses;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the post types in $wpide_featured_types. Sticky posts already do this for the blog."
        },
        {
            "id": "post-type-grid-shortcode",
            "version": 1,
            "title": "Post Type Grid Shortcode",
            "description": "Adds a [post_grid type=\"...\"] shortcode that shows any post type as a responsive grid of cards with image, title and excerpt, so a regular page can act as its archive.",
            "category": "post-types",
            "tags": [
                "shortcode",
                "grid",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'post_grid', function ( $atts ) {\n    $atts = shortcode_atts( array( 'type' => 'post', 'limit' => 9, 'columns' => 3, 'taxonomy' => '', 'term' => '', 'orderby' => 'date' ), $atts, 'post_grid' );\n    $type = sanitize_key( $atts['type'] );\n    $obj  = get_post_type_object( $type );\n\n    if ( ! $obj || ! $obj->public ) {\n        return '';\n    }\n\n    $key  = 'wpide_grid_' . md5( wp_json_encode( $atts ) . get_option( 'wpide_grid_ver_' . $type, '1' ) );\n    $html = get_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $orderby = in_array( $atts['orderby'], array( 'date', 'title', 'menu_order' ), true ) ? $atts['orderby'] : 'date';\n    $args    = array(\n        'post_type'           => $type,\n        'posts_per_page'      => min( 48, max( 1, absint( $atts['limit'] ) ) ),\n        'orderby'             => $orderby,\n        'order'               => 'date' === $orderby ? 'DESC' : 'ASC',\n        'ignore_sticky_posts' => true,\n        'no_found_rows'       => true,\n    );\n\n    if ( $atts['taxonomy'] && $atts['term'] && is_object_in_taxonomy( $type, $atts['taxonomy'] ) ) {\n        $args['tax_query'] = array( array( 'taxonomy' => sanitize_key( $atts['taxonomy'] ), 'field' => 'slug', 'terms' => array_map( 'sanitize_title', explode( ',', $atts['term'] ) ) ) );\n    }\n\n    $posts = get_posts( $args );\n    $min   = (int) floor( 1000 / min( 4, max( 1, absint( $atts['columns'] ) ) ) ) - 24;\n    $html  = '';\n\n    foreach ( $posts as $post ) {\n        $html .= '<article class=\"post-grid-card\">'\n            . ( has_post_thumbnail( $post ) ? '<a href=\"' . esc_url( get_permalink( $post ) ) . '\" tabindex=\"-1\" aria-hidden=\"true\">' . get_the_post_thumbnail( $post, 'medium_large', array( 'loading' => 'lazy', 'style' => 'width:100%;height:auto;aspect-ratio:3/2;object-fit:cover' ) ) . '</a>' : '' )\n            . '<h3 class=\"post-grid-title\"><a href=\"' . esc_url( get_permalink( $post ) ) . '\">' . esc_html( get_the_title( $post ) ) . '</a></h3>'\n            . '<p class=\"post-grid-excerpt\">' . esc_html( wp_trim_words( get_the_excerpt( $post ), 24 ) ) . '</p></article>';\n    }\n\n    $html = $html ? '<div class=\"post-grid\" style=\"display:grid;gap:24px;grid-template-columns:repeat(auto-fill,minmax(min(100%,' . $min . 'px),1fr))\">' . $html . '</div>' : '';\n    set_transient( $key, $html, DAY_IN_SECONDS );\n\n    return $html;\n} );\n\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && ! wp_is_post_revision( $post ) ) {\n        update_option( 'wpide_grid_ver_' . $post->post_type, (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Options: type, limit, columns, taxonomy and term (slug), orderby=\"date|title|menu_order\". Results are cached and refresh when that post type changes."
        },
        {
            "id": "related-items-same-term",
            "version": 1,
            "title": "Related Items From the Same Category for Any Post Type",
            "description": "Shows up to three other items that share a category or tag below single custom post type entries, such as related recipes, services or case studies.",
            "category": "post-types",
            "tags": [
                "related",
                "custom-post-types",
                "internal-links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types that get related items, how many, and the heading.\n$wpide_related_types   = array( 'recipe', 'case_study', 'service' );\n$wpide_related_count   = 3;\n$wpide_related_heading = 'You may also like';\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_related_types, $wpide_related_count, $wpide_related_heading ) {\n    if ( ! is_singular( $wpide_related_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $post = get_post();\n    $key  = 'wpide_related_' . $post->ID . '_' . get_option( 'wpide_related_ver_' . $post->post_type, '1' );\n    $ids  = get_transient( $key );\n\n    if ( false === $ids ) {\n        $tax_query = array( 'relation' => 'OR' );\n\n        foreach ( get_object_taxonomies( $post->post_type, 'objects' ) as $taxonomy ) {\n            if ( ! $taxonomy->public ) {\n                continue;\n            }\n\n            $terms = wp_get_object_terms( $post->ID, $taxonomy->name, array( 'fields' => 'ids' ) );\n\n            if ( $terms && ! is_wp_error( $terms ) ) {\n                $tax_query[] = array( 'taxonomy' => $taxonomy->name, 'terms' => $terms );\n            }\n        }\n\n        $ids = count( $tax_query ) > 1 ? get_posts( array(\n            'post_type'      => $post->post_type,\n            'posts_per_page' => $wpide_related_count,\n            'post__not_in'   => array( $post->ID ),\n            'tax_query'      => $tax_query,\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) ) : array();\n\n        set_transient( $key, $ids, DAY_IN_SECONDS );\n    }\n\n    if ( ! $ids ) {\n        return $content;\n    }\n\n    $list = '';\n\n    foreach ( $ids as $id ) {\n        $list .= '<li><a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( get_the_title( $id ) ) . '</a></li>';\n    }\n\n    return $content . '<nav class=\"related-items\" aria-label=\"' . esc_attr( $wpide_related_heading ) . '\"><h2>' . esc_html( $wpide_related_heading ) . '</h2><ul>' . $list . '</ul></nav>';\n} );\n\nadd_action( 'clean_post_cache', function ( $post_id, $post ) use ( $wpide_related_types ) {\n    if ( $post && in_array( $post->post_type, $wpide_related_types, true ) ) {\n        update_option( 'wpide_related_ver_' . $post->post_type, (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Set the post types and heading. Each list is cached for a day and refreshed when content of that type changes."
        },
        {
            "id": "post-count-shortcode",
            "version": 1,
            "title": "Published Item Count Shortcode",
            "description": "Adds a [post_count type=\"job\"] shortcode that prints how many published items a post type has, for lines like \"We have 12 open positions\".",
            "category": "post-types",
            "tags": [
                "shortcode",
                "counts",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'post_count', function ( $atts ) {\n    $atts = shortcode_atts( array( 'type' => 'post' ), $atts, 'post_count' );\n    $type = sanitize_key( $atts['type'] );\n    $obj  = get_post_type_object( $type );\n\n    if ( ! $obj || ! $obj->public ) {\n        return '';\n    }\n\n    $counts = wp_count_posts( $type );\n\n    return esc_html( number_format_i18n( isset( $counts->publish ) ? (int) $counts->publish : 0 ) );\n} );",
            "requires": [],
            "notes": "Uses WordPress's cached counts, so it adds no extra queries on most pages."
        },
        {
            "id": "az-index-shortcode",
            "version": 1,
            "title": "A–Z Index Shortcode for Glossaries and Directories",
            "description": "Adds an [az_index type=\"glossary_term\"] shortcode that lists every item of a post type grouped by first letter, with a jump-to-letter bar.",
            "category": "post-types",
            "tags": [
                "glossary",
                "shortcode",
                "directory"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'az_index', function ( $atts ) {\n    $atts = shortcode_atts( array( 'type' => 'glossary_term' ), $atts, 'az_index' );\n    $type = sanitize_key( $atts['type'] );\n    $obj  = get_post_type_object( $type );\n\n    if ( ! $obj || ! $obj->public ) {\n        return '';\n    }\n\n    $key  = 'wpide_az_' . $type . '_' . get_option( 'wpide_az_ver_' . $type, '1' );\n    $html = get_transient( $key );\n\n    if ( false !== $html ) {\n        return $html;\n    }\n\n    $ids    = get_posts( array( 'post_type' => $type, 'posts_per_page' => 1000, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n    $groups = array();\n\n    foreach ( $ids as $id ) {\n        $title  = get_the_title( $id );\n        $letter = strtoupper( substr( remove_accents( $title ), 0, 1 ) );\n        $letter = preg_match( '/^[A-Z]$/', $letter ) ? $letter : '#';\n        $groups[ $letter ][] = '<li><a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( $title ) . '</a></li>';\n    }\n\n    ksort( $groups );\n    $nav  = '';\n    $body = '';\n\n    foreach ( $groups as $letter => $items ) {\n        $anchor = 'az-' . ( '#' === $letter ? 'other' : strtolower( $letter ) );\n        $nav   .= '<a href=\"#' . esc_attr( $anchor ) . '\" style=\"padding:.25em .5em\">' . esc_html( $letter ) . '</a>';\n        $body  .= '<section id=\"' . esc_attr( $anchor ) . '\"><h2>' . esc_html( $letter ) . '</h2><ul>' . implode( '', $items ) . '</ul></section>';\n    }\n\n    $html = $groups ? '<nav class=\"az-index-nav\" aria-label=\"Jump to letter\" style=\"display:flex;flex-wrap:wrap;gap:.25em\">' . $nav . '</nav><div class=\"az-index\">' . $body . '</div>' : '';\n    set_transient( $key, $html, DAY_IN_SECONDS );\n\n    return $html;\n} );\n\nadd_action( 'clean_post_cache', function ( $post_id, $post ) {\n    if ( $post && ! wp_is_post_revision( $post ) ) {\n        update_option( 'wpide_az_ver_' . $post->post_type, (string) microtime( true ), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with any public post type, up to 1,000 items. The list is cached and refreshed when that post type changes."
        },
        {
            "id": "child-items-shortcode",
            "version": 1,
            "title": "List Child Items of a Hierarchical Post Type",
            "description": "Adds a [child_items] shortcode that lists the children of the current page or hierarchical item, such as sub-services under a service.",
            "category": "post-types",
            "tags": [
                "hierarchy",
                "shortcode",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'child_items', function ( $atts ) {\n    $atts = shortcode_atts( array( 'excerpt' => 'no' ), $atts, 'child_items' );\n    $post = get_post();\n\n    if ( ! $post || ! is_post_type_hierarchical( $post->post_type ) ) {\n        return '';\n    }\n\n    $children = get_posts( array(\n        'post_type'      => $post->post_type,\n        'post_parent'    => $post->ID,\n        'posts_per_page' => 100,\n        'orderby'        => array( 'menu_order' => 'ASC', 'title' => 'ASC' ),\n        'no_found_rows'  => true,\n    ) );\n\n    if ( ! $children ) {\n        return '';\n    }\n\n    $html = '<ul class=\"child-items\">';\n\n    foreach ( $children as $child ) {\n        $html .= '<li><a href=\"' . esc_url( get_permalink( $child ) ) . '\">' . esc_html( get_the_title( $child ) ) . '</a>'\n            . ( 'yes' === $atts['excerpt'] && has_excerpt( $child ) ? '<br><span class=\"child-excerpt\">' . esc_html( get_the_excerpt( $child ) ) . '</span>' : '' ) . '</li>';\n    }\n\n    return $html . '</ul>';\n} );",
            "requires": [],
            "notes": "Ordered by the Order field, then title. Add excerpt=\"yes\" to include short descriptions."
        },
        {
            "id": "taxonomy-admin-column-any",
            "version": 1,
            "title": "Admin Column for a Taxonomy Added by a Plugin or Theme",
            "description": "Shows a column for any taxonomy in its post type's admin list, even when the plugin or theme that registered it left the column out.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "admin",
                "columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomies that should get an admin column.\n$wpide_tax_columns = array( 'portfolio_category', 'product_brand' );\n\nadd_filter( 'register_taxonomy_args', function ( $args, $taxonomy ) use ( $wpide_tax_columns ) {\n    if ( in_array( $taxonomy, $wpide_tax_columns, true ) ) {\n        $args['show_admin_column'] = true;\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "List taxonomy names (shown in the URL when you edit its terms, e.g. taxonomy=portfolio_category)."
        },
        {
            "id": "menu-order-for-post-types",
            "version": 1,
            "title": "Manual Ordering for Custom Post Types",
            "description": "Adds the Order field to chosen post types and sorts them by it on the front end and in the admin, so you can control the order of team members, services or FAQs.",
            "category": "post-types",
            "tags": [
                "ordering",
                "menu-order",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types ordered by their Order field.\n$wpide_ordered_types = array( 'team', 'service', 'faq' );\n\nadd_action( 'init', function () use ( $wpide_ordered_types ) {\n    foreach ( $wpide_ordered_types as $type ) {\n        if ( post_type_exists( $type ) ) {\n            add_post_type_support( $type, 'page-attributes' );\n        }\n    }\n}, 99 );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_ordered_types ) {\n    if ( ! $query->is_main_query() || $query->get( 'orderby' ) ) {\n        return;\n    }\n\n    $type = $query->get( 'post_type' );\n\n    if ( ( is_admin() && is_string( $type ) && in_array( $type, $wpide_ordered_types, true ) ) || ( ! is_admin() && $query->is_post_type_archive( $wpide_ordered_types ) ) ) {\n        $query->set( 'orderby', array( 'menu_order' => 'ASC', 'title' => 'ASC' ) );\n    }\n} );",
            "requires": [],
            "notes": "Set the order under Page Attributes (or \"Order\" in the block editor's sidebar). Items with the same number sort by title."
        },
        {
            "id": "admin-filter-by-meta",
            "version": 1,
            "title": "Filter an Admin List by a Custom Field",
            "description": "Adds a dropdown above a post type's admin list with every value used in a custom field (such as city or brand), so you can show only matching items.",
            "category": "post-types",
            "tags": [
                "admin",
                "filters",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type, meta key and dropdown label.\n$wpide_meta_filter = array(\n    'post_type' => 'post',\n    'meta_key'  => 'location',\n    'label'     => 'All locations',\n);\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_meta_filter ) {\n    global $wpdb;\n\n    if ( $wpide_meta_filter['post_type'] !== $post_type ) {\n        return;\n    }\n\n    $key    = 'wpide_meta_filter_' . md5( $wpide_meta_filter['post_type'] . $wpide_meta_filter['meta_key'] );\n    $values = get_transient( $key );\n\n    if ( false === $values ) {\n        $values = $wpdb->get_col( $wpdb->prepare(\n            \"SELECT DISTINCT pm.meta_value FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id\n             WHERE pm.meta_key = %s AND p.post_type = %s AND pm.meta_value <> '' ORDER BY pm.meta_value LIMIT 200\",\n            $wpide_meta_filter['meta_key'],\n            $wpide_meta_filter['post_type']\n        ) );\n        set_transient( $key, $values, HOUR_IN_SECONDS );\n    }\n\n    if ( ! $values ) {\n        return;\n    }\n\n    $current = isset( $_GET['wpide_meta'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_meta'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification\n\n    echo '<label class=\"screen-reader-text\" for=\"wpide-meta-filter\">' . esc_html( $wpide_meta_filter['label'] ) . '</label><select name=\"wpide_meta\" id=\"wpide-meta-filter\"><option value=\"\">' . esc_html( $wpide_meta_filter['label'] ) . '</option>';\n\n    foreach ( $values as $value ) {\n        printf( '<option value=\"%1$s\"%2$s>%3$s</option>', esc_attr( $value ), selected( $current, $value, false ), esc_html( wp_trim_words( $value, 8 ) ) );\n    }\n\n    echo '</select>';\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_meta_filter ) {\n    if ( ! is_admin() || ! $query->is_main_query() || $wpide_meta_filter['post_type'] !== $query->get( 'post_type' ) || empty( $_GET['wpide_meta'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return;\n    }\n\n    $meta_query   = (array) $query->get( 'meta_query' );\n    $meta_query[] = array( 'key' => $wpide_meta_filter['meta_key'], 'value' => sanitize_text_field( wp_unslash( $_GET['wpide_meta'] ) ) ); // phpcs:ignore WordPress.Security.NonceVerification\n    $query->set( 'meta_query', $meta_query );\n} );\n\nadd_action( 'save_post_' . $wpide_meta_filter['post_type'], function () use ( $wpide_meta_filter ) {\n    delete_transient( 'wpide_meta_filter_' . md5( $wpide_meta_filter['post_type'] . $wpide_meta_filter['meta_key'] ) );\n} );",
            "requires": [],
            "notes": "Set the post type, meta key and label. The list of values is cached for an hour and refreshed when an item is saved."
        },
        {
            "id": "admin-filter-by-author",
            "version": 1,
            "title": "Filter Admin Lists by Author",
            "description": "Adds an author dropdown above the admin lists of posts and chosen post types, so editors can see everything one person wrote.",
            "category": "post-types",
            "tags": [
                "admin",
                "filters",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the author dropdown.\n$wpide_author_filter_types = array( 'post', 'page' );\n\nadd_action( 'restrict_manage_posts', function ( $post_type ) use ( $wpide_author_filter_types ) {\n    if ( ! in_array( $post_type, $wpide_author_filter_types, true ) ) {\n        return;\n    }\n\n    echo '<label class=\"screen-reader-text\" for=\"wpide-author-filter\">Filter by author</label>';\n    wp_dropdown_users( array(\n        'name'            => 'author',\n        'id'              => 'wpide-author-filter',\n        'show_option_all' => 'All authors',\n        'selected'        => isset( $_GET['author'] ) ? absint( $_GET['author'] ) : 0, // phpcs:ignore WordPress.Security.NonceVerification\n        'capability'      => array( 'edit_posts' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Only users who can write posts appear. WordPress handles the filtering itself through the author parameter."
        },
        {
            "id": "enable-rest-for-post-type",
            "version": 1,
            "title": "Enable the Block Editor and REST API for a Plugin's Post Type",
            "description": "Turns on REST API support for post types registered without it, which also switches them to the block editor and makes them available to apps and headless sites.",
            "category": "post-types",
            "tags": [
                "rest-api",
                "gutenberg",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => REST base (the URL part after /wp-json/wp/v2/).\n$wpide_rest_types = array(\n    'portfolio' => 'portfolio',\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_rest_types ) {\n    if ( isset( $wpide_rest_types[ $post_type ] ) ) {\n        $args['show_in_rest'] = true;\n        $args['rest_base']    = $wpide_rest_types[ $post_type ];\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "The post type also needs editor support for the block editor to appear. Test the plugin's own meta boxes, which may not all work in the block editor."
        },
        {
            "id": "change-post-type-slug",
            "version": 1,
            "title": "Change the URL Slug of Any Post Type",
            "description": "Changes the URL base of a post type registered by a theme or plugin, for example from /portfolio/ to /work/, including its archive.",
            "category": "post-types",
            "tags": [
                "permalinks",
                "slugs",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => new slug.\n$wpide_new_slugs = array(\n    'portfolio' => 'work',\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_new_slugs ) {\n    if ( ! isset( $wpide_new_slugs[ $post_type ] ) ) {\n        return $args;\n    }\n\n    $args['rewrite'] = array_merge( is_array( $args['rewrite'] ?? null ) ? $args['rewrite'] : array(), array( 'slug' => $wpide_new_slugs[ $post_type ] ) );\n\n    if ( ! empty( $args['has_archive'] ) ) {\n        $args['has_archive'] = $wpide_new_slugs[ $post_type ];\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once afterwards, and redirect the old URLs if the site is already indexed."
        },
        {
            "id": "change-taxonomy-slug",
            "version": 1,
            "title": "Change the URL Slug of Any Taxonomy",
            "description": "Changes the URL base of a category-style taxonomy registered by a theme or plugin, for example from /portfolio-category/ to /work-type/.",
            "category": "post-types",
            "tags": [
                "permalinks",
                "slugs",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomy => new slug.\n$wpide_new_tax_slugs = array(\n    'portfolio_category' => 'work-type',\n);\n\nadd_filter( 'register_taxonomy_args', function ( $args, $taxonomy ) use ( $wpide_new_tax_slugs ) {\n    if ( isset( $wpide_new_tax_slugs[ $taxonomy ] ) ) {\n        $args['rewrite'] = array_merge( is_array( $args['rewrite'] ?? null ) ? $args['rewrite'] : array(), array( 'slug' => $wpide_new_tax_slugs[ $taxonomy ] ) );\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once afterwards. For the built-in category and tag bases, use Settings > Permalinks instead."
        },
        {
            "id": "term-in-post-type-permalink",
            "version": 1,
            "title": "Put the Category in Custom Post Type URLs",
            "description": "Builds custom post type URLs with a taxonomy term, such as /books/fantasy/the-hobbit/, which reads better and groups content by section.",
            "category": "post-types",
            "tags": [
                "permalinks",
                "taxonomies",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, taxonomy, URL base and the term slug used when an item has none.\n$wpide_term_url = array(\n    'post_type' => 'book',\n    'taxonomy'  => 'genre',\n    'base'      => 'books',\n    'fallback'  => 'general',\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_term_url ) {\n    if ( $wpide_term_url['post_type'] === $post_type ) {\n        $args['rewrite']     = array( 'slug' => $wpide_term_url['base'] . '/%' . $wpide_term_url['taxonomy'] . '%', 'with_front' => false );\n        $args['has_archive'] = $wpide_term_url['base'];\n    }\n\n    return $args;\n}, 10, 2 );\n\nadd_filter( 'post_type_link', function ( $link, $post ) use ( $wpide_term_url ) {\n    $tag = '%' . $wpide_term_url['taxonomy'] . '%';\n\n    if ( $wpide_term_url['post_type'] !== $post->post_type || false === strpos( $link, $tag ) ) {\n        return $link;\n    }\n\n    $terms = get_the_terms( $post, $wpide_term_url['taxonomy'] );\n    $slug  = ( $terms && ! is_wp_error( $terms ) ) ? $terms[0]->slug : $wpide_term_url['fallback'];\n\n    return str_replace( $tag, $slug, $link );\n}, 10, 2 );",
            "requires": [],
            "notes": "Every item needs a term; items without one use $wpide_term_url['fallback'], so create a term with that slug. Visit Settings > Permalinks once after activating."
        },
        {
            "id": "remove-post-type-slug",
            "version": 1,
            "title": "Remove the Post Type Slug From URLs",
            "description": "Gives a custom post type short URLs like example.com/web-design/ instead of example.com/services/web-design/, as pages have.",
            "category": "post-types",
            "tags": [
                "permalinks",
                "slugs",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type and its current slug.\n$wpide_short_urls = array(\n    'post_type' => 'service',\n    'slug'      => 'services',\n);\n\nadd_filter( 'post_type_link', function ( $link, $post ) use ( $wpide_short_urls ) {\n    if ( $wpide_short_urls['post_type'] === $post->post_type && 'publish' === $post->post_status ) {\n        $link = str_replace( '/' . trim( $wpide_short_urls['slug'], '/' ) . '/', '/', $link );\n    }\n\n    return $link;\n}, 10, 2 );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_short_urls ) {\n    if ( is_admin() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    // A plain /name/ request is parsed as a post name: also look for it in the custom post type.\n    $vars = $query->query;\n    unset( $vars['page'] );\n\n    if ( 1 === count( $vars ) && ! empty( $vars['name'] ) ) {\n        $query->set( 'post_type', array( 'post', 'page', $wpide_short_urls['post_type'] ) );\n    }\n} );",
            "requires": [],
            "notes": "Needs the \"Post name\" permalink setting and a non-hierarchical post type. Avoid items with the same slug as a page or post."
        },
        {
            "id": "shared-custom-type-templates",
            "version": 1,
            "title": "One Template for All Custom Post Types",
            "description": "Lets every custom post type use a shared archive-custom and single-custom template before falling back to the general ones, so you design CPT pages once.",
            "category": "post-types",
            "tags": [
                "templates",
                "themes",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'archive_template_hierarchy', function ( $templates ) {\n    if ( ! is_post_type_archive() || is_post_type_archive( 'post' ) ) {\n        return $templates;\n    }\n\n    $pos = array_search( 'archive.php', $templates, true );\n    array_splice( $templates, false === $pos ? count( $templates ) : $pos, 0, array( 'archive-custom.php' ) );\n\n    return $templates;\n} );\n\nadd_filter( 'single_template_hierarchy', function ( $templates ) {\n    $type = get_post_type( get_queried_object_id() );\n\n    if ( ! $type || in_array( $type, array( 'post', 'attachment' ), true ) || get_post_type_object( $type )->_builtin ) {\n        return $templates;\n    }\n\n    $pos = array_search( 'single.php', $templates, true );\n    array_splice( $templates, false === $pos ? count( $templates ) : $pos, 0, array( 'single-custom.php' ) );\n\n    return $templates;\n} );",
            "requires": [],
            "notes": "Create archive-custom.php and single-custom.php in a classic theme, or templates named archive-custom and single-custom in a block theme. Post-type-specific templates still win."
        },
        {
            "id": "page-template-for-post-type",
            "version": 1,
            "title": "Use the Page Template for a Custom Post Type",
            "description": "Displays single entries of chosen post types with your theme's page template instead of the blog post template, so they have no dates, authors or post navigation.",
            "category": "post-types",
            "tags": [
                "templates",
                "themes",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types shown with the page template.\n$wpide_page_like_types = array( 'service', 'location' );\n\nadd_filter( 'single_template_hierarchy', function ( $templates ) use ( $wpide_page_like_types ) {\n    if ( ! is_singular( $wpide_page_like_types ) ) {\n        return $templates;\n    }\n\n    $pos = array_search( 'single.php', $templates, true );\n    array_splice( $templates, false === $pos ? count( $templates ) : $pos, 0, array( 'page.php' ) );\n\n    return $templates;\n} );",
            "requires": [],
            "notes": "Works with classic (page.php) and block themes (the \"Pages\" template). A single-{post_type} template still takes priority."
        },
        {
            "id": "hide-post-type-front-end",
            "version": 1,
            "title": "Make a Plugin's Post Type Admin-Only",
            "description": "Removes the public pages, archive and search presence of a post type registered by a plugin or theme while keeping it editable in the admin.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "visibility",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types to hide from visitors.\n$wpide_private_types = array( 'portfolio' );\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_private_types ) {\n    if ( in_array( $post_type, $wpide_private_types, true ) ) {\n        $args['public']              = false;\n        $args['publicly_queryable']  = false;\n        $args['exclude_from_search'] = true;\n        $args['show_in_nav_menus']   = false;\n        $args['has_archive']         = false;\n        $args['rewrite']             = false;\n        $args['show_ui']             = true;\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Use for internal records that should never be public, then visit Settings > Permalinks once."
        },
        {
            "id": "add-post-type-supports",
            "version": 1,
            "title": "Add Excerpts, Revisions and More to Any Post Type",
            "description": "Switches on editor features such as excerpts, revisions, page attributes or custom fields for post types that were registered without them.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "editor",
                "supports"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => features to add.\n$wpide_add_supports = array(\n    'page'      => array( 'excerpt' ),\n    'portfolio' => array( 'revisions', 'excerpt', 'page-attributes' ),\n);\n\nadd_action( 'init', function () use ( $wpide_add_supports ) {\n    foreach ( $wpide_add_supports as $type => $features ) {\n        if ( post_type_exists( $type ) ) {\n            add_post_type_support( $type, $features );\n        }\n    }\n}, 99 );",
            "requires": [],
            "notes": "Feature names: title, editor, author, thumbnail, excerpt, comments, revisions, page-attributes, custom-fields."
        },
        {
            "id": "categories-tags-for-pages",
            "version": 1,
            "title": "Categories and Tags for Pages",
            "description": "Lets you assign categories and tags to pages, and includes those pages on the matching category and tag archives.",
            "category": "post-types",
            "tags": [
                "pages",
                "categories",
                "tags"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_taxonomy_for_object_type( 'category', 'page' );\n    register_taxonomy_for_object_type( 'post_tag', 'page' );\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! ( $query->is_category() || $query->is_tag() ) || $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $query->set( 'post_type', array( 'post', 'page' ) );\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "default-term-for-taxonomy",
            "version": 1,
            "title": "Default Term for a Custom Taxonomy",
            "description": "Gives a custom taxonomy a default term, like \"Uncategorized\" for categories, so items saved without a term still get one.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "defaults",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomy => default term name and slug.\n$wpide_default_terms = array(\n    'genre' => array( 'name' => 'General', 'slug' => 'general' ),\n);\n\nadd_filter( 'register_taxonomy_args', function ( $args, $taxonomy ) use ( $wpide_default_terms ) {\n    if ( isset( $wpide_default_terms[ $taxonomy ] ) && empty( $args['default_term'] ) ) {\n        $args['default_term'] = $wpide_default_terms[ $taxonomy ];\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "WordPress creates the term on the next page load if it does not exist. Works for taxonomies registered on init by any plugin or theme."
        },
        {
            "id": "term-color-field",
            "version": 1,
            "title": "Color Field for Categories and Terms",
            "description": "Adds a color picker to categories (or any taxonomy) with a swatch column in the term list, and a helper to use each term's color in your theme.",
            "category": "post-types",
            "tags": [
                "taxonomies",
                "term-meta",
                "colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomies that get the color field.\n$wpide_color_taxonomies = array( 'category' );\n\nif ( ! function_exists( 'wpide_term_color' ) ) {\n    function wpide_term_color( $term_id ) {\n        return (string) sanitize_hex_color( (string) get_term_meta( (int) $term_id, '_wpide_color', true ) );\n    }\n}\n\nforeach ( $wpide_color_taxonomies as $wpide_color_tax ) {\n    add_action( $wpide_color_tax . '_add_form_fields', function () {\n        wp_nonce_field( 'wpide_term_color', 'wpide_term_color_nonce' );\n        echo '<div class=\"form-field\"><label for=\"wpide-term-color\">Color</label><input type=\"color\" id=\"wpide-term-color\" name=\"wpide_term_color\" value=\"#3366cc\"></div>';\n    } );\n\n    add_action( $wpide_color_tax . '_edit_form_fields', function ( $term ) {\n        wp_nonce_field( 'wpide_term_color', 'wpide_term_color_nonce' );\n        $color = wpide_term_color( $term->term_id );\n        printf( '<tr class=\"form-field\"><th scope=\"row\"><label for=\"wpide-term-color\">Color</label></th><td><input type=\"color\" id=\"wpide-term-color\" name=\"wpide_term_color\" value=\"%s\"></td></tr>', esc_attr( $color ? $color : '#3366cc' ) );\n    } );\n\n    $wpide_save_color = function ( $term_id ) {\n        if ( ! isset( $_POST['wpide_term_color_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_term_color_nonce'] ), 'wpide_term_color' ) || ! current_user_can( 'edit_term', $term_id ) ) {\n            return;\n        }\n\n        $color = isset( $_POST['wpide_term_color'] ) ? sanitize_hex_color( wp_unslash( $_POST['wpide_term_color'] ) ) : '';\n\n        if ( $color ) {\n            update_term_meta( $term_id, '_wpide_color', $color );\n        } else {\n            delete_term_meta( $term_id, '_wpide_color' );\n        }\n    };\n\n    add_action( 'created_' . $wpide_color_tax, $wpide_save_color );\n    add_action( 'edited_' . $wpide_color_tax, $wpide_save_color );\n\n    add_filter( 'manage_edit-' . $wpide_color_tax . '_columns', function ( $columns ) {\n        return array_slice( $columns, 0, 2, true ) + array( 'wpide_color' => 'Color' ) + array_slice( $columns, 2, null, true );\n    } );\n\n    add_filter( 'manage_' . $wpide_color_tax . '_custom_column', function ( $output, $column, $term_id ) {\n        if ( 'wpide_color' === $column && wpide_term_color( $term_id ) ) {\n            $output = '<span style=\"display:inline-block;width:20px;height:20px;border-radius:50%;background:' . esc_attr( wpide_term_color( $term_id ) ) . '\" title=\"' . esc_attr( wpide_term_color( $term_id ) ) . '\"></span>';\n        }\n\n        return $output;\n    }, 10, 3 );\n}\n\nadd_action( 'wp_head', function () use ( $wpide_color_taxonomies ) {\n    if ( ( is_category() || is_tag() || is_tax() ) && in_array( get_queried_object()->taxonomy ?? '', $wpide_color_taxonomies, true ) && wpide_term_color( get_queried_object_id() ) ) {\n        echo '<style>:root{--term-color:' . esc_html( wpide_term_color( get_queried_object_id() ) ) . '}</style>' . \"\\n\";\n    }\n} );\n\nadd_filter( 'body_class', function ( $classes ) use ( $wpide_color_taxonomies ) {\n    if ( ( is_category() || is_tag() || is_tax() ) && in_array( get_queried_object()->taxonomy ?? '', $wpide_color_taxonomies, true ) && wpide_term_color( get_queried_object_id() ) ) {\n        $classes[] = 'term-color-set';\n    }\n\n    return $classes;\n} );",
            "requires": [],
            "notes": "Use wpide_term_color( $term_id ) in templates, or the body class \"term-color-set\" with the --term-color variable on term archives."
        },
        {
            "id": "members-only-post-type",
            "version": 1,
            "title": "Members-Only Post Type",
            "description": "Sends logged-out visitors to the login page when they open entries or the archive of a chosen post type, and hides those entries from them in the REST API.",
            "category": "post-types",
            "tags": [
                "members",
                "custom-post-types",
                "access"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types only logged-in users may see.\n$wpide_members_types = array( 'resource' );\n\nadd_action( 'template_redirect', function () use ( $wpide_members_types ) {\n    if ( is_user_logged_in() || ! ( is_singular( $wpide_members_types ) || is_post_type_archive( $wpide_members_types ) ) ) {\n        return;\n    }\n\n    $requested = home_url( add_query_arg( array() ) );\n    wp_safe_redirect( wp_login_url( $requested ) );\n    exit;\n} );\n\nforeach ( $wpide_members_types as $wpide_members_type ) {\n    add_filter( \"rest_{$wpide_members_type}_query\", function ( $args ) {\n        if ( ! is_user_logged_in() ) {\n            $args['post__in'] = array( 0 );\n        }\n\n        return $args;\n    } );\n}\n\nadd_filter( 'rest_request_before_callbacks', function ( $response, $handler, $request ) use ( $wpide_members_types ) {\n    if ( is_user_logged_in() || is_wp_error( $response ) ) {\n        return $response;\n    }\n\n    foreach ( $wpide_members_types as $type ) {\n        $object = get_post_type_object( $type );\n\n        if ( $object && $object->show_in_rest && 0 === strpos( $request->get_route(), '/' . ( $object->rest_namespace ?: 'wp/v2' ) . '/' . ( $object->rest_base ?: $type ) . '/' ) ) {\n            return new WP_Error( 'rest_forbidden', 'Please log in to view this content.', array( 'status' => 401 ) );\n        }\n    }\n\n    return $response;\n}, 10, 3 );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_members_types ) {\n    // Keep these entries out of search results and feeds for guests.\n    if ( is_admin() || is_user_logged_in() || ! ( $query->is_search() || $query->is_feed() ) || ! $query->is_main_query() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( $types && 'any' !== $types ) {\n        $query->set( 'post_type', array_values( array_diff( (array) $types, $wpide_members_types ) ) );\n    } else {\n        $query->set( 'post_type', array_values( array_diff( get_post_types( array( 'exclude_from_search' => false ) ), $wpide_members_types ) ) );\n    }\n} );",
            "requires": [],
            "notes": "After logging in, visitors return to the page they wanted. Combine with noindex if the content was public before."
        },
        {
            "id": "role-access-one-post-type",
            "version": 1,
            "title": "Give a Role Full Control of One Post Type Only",
            "description": "Gives a custom post type its own permissions and lets a chosen role (for example Author) manage it fully, including others' items, without extra rights elsewhere.",
            "category": "post-types",
            "tags": [
                "roles",
                "capabilities",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type, its capability names, and the roles that manage it.\n$wpide_cpt_caps = array(\n    'post_type' => 'event',\n    'singular'  => 'event',\n    'plural'    => 'events',\n    'roles'     => array( 'administrator', 'editor', 'author' ),\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_cpt_caps ) {\n    if ( $wpide_cpt_caps['post_type'] === $post_type ) {\n        $args['capability_type'] = array( $wpide_cpt_caps['singular'], $wpide_cpt_caps['plural'] );\n        $args['map_meta_cap']    = true;\n    }\n\n    return $args;\n}, 10, 2 );\n\nadd_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) use ( $wpide_cpt_caps ) {\n    if ( ! $user instanceof WP_User || ! array_intersect( (array) $user->roles, $wpide_cpt_caps['roles'] ) ) {\n        return $allcaps;\n    }\n\n    $object = get_post_type_object( $wpide_cpt_caps['post_type'] );\n\n    if ( ! $object ) {\n        return $allcaps;\n    }\n\n    // Grant every primitive capability of this post type (edit_events, publish_events, ...).\n    foreach ( (array) $object->cap as $meta => $primitive ) {\n        if ( ! in_array( $meta, array( 'edit_post', 'read_post', 'delete_post' ), true ) ) {\n            $allcaps[ $primitive ] = true;\n        }\n    }\n\n    return $allcaps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Administrators keep full access. The permissions are granted while the snippet runs, so nothing is written to the database."
        },
        {
            "id": "post-type-menu-icon-position",
            "version": 1,
            "title": "Change the Menu Icon and Position of Any Post Type",
            "description": "Gives a post type registered by a plugin or theme a different Dashicon and moves it up or down the admin menu.",
            "category": "post-types",
            "tags": [
                "admin-menu",
                "icons",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => icon and menu position.\n$wpide_menu_tweaks = array(\n    'portfolio' => array( 'icon' => 'dashicons-art', 'position' => 6 ),\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_menu_tweaks ) {\n    if ( isset( $wpide_menu_tweaks[ $post_type ] ) ) {\n        $args['menu_icon']     = $wpide_menu_tweaks[ $post_type ]['icon'];\n        $args['menu_position'] = (int) $wpide_menu_tweaks[ $post_type ]['position'];\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Pick icons at developer.wordpress.org/resource/dashicons. Positions: 5 below Posts, 20 below Pages, 60 below Appearance."
        },
        {
            "id": "rename-post-type-labels",
            "version": 1,
            "title": "Rename a Plugin's Post Type",
            "description": "Changes the name a theme or plugin gave its post type everywhere in the admin, for example \"Projects\" to \"Case Studies\", without touching URLs or content.",
            "category": "post-types",
            "tags": [
                "labels",
                "admin",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type and its new names.\n$wpide_relabel = array(\n    'post_type' => 'portfolio',\n    'singular'  => 'Case Study',\n    'plural'    => 'Case Studies',\n);\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_relabel ) {\n    if ( $wpide_relabel['post_type'] !== $post_type ) {\n        return $args;\n    }\n\n    $s = $wpide_relabel['singular'];\n    $p = $wpide_relabel['plural'];\n\n    $args['label']  = $p;\n    $args['labels'] = array_merge( (array) ( $args['labels'] ?? array() ), array(\n        'name'          => $p,\n        'singular_name' => $s,\n        'menu_name'     => $p,\n        'all_items'     => \"All $p\",\n        'add_new_item'  => \"Add New $s\",\n        'edit_item'     => \"Edit $s\",\n        'new_item'      => \"New $s\",\n        'view_item'     => \"View $s\",\n        'view_items'    => \"View $p\",\n        'search_items'  => \"Search $p\",\n        'archives'      => \"$s Archives\",\n    ) );\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only the labels change; use the slug snippet to change URLs as well."
        },
        {
            "id": "disable-post-type-archive",
            "version": 1,
            "title": "Turn Off the Archive Page of a Post Type",
            "description": "Removes the automatic archive page of a post type, for example when you build the listing yourself on a regular page with the same URL.",
            "category": "post-types",
            "tags": [
                "archives",
                "custom-post-types",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types without an archive page.\n$wpide_no_archive_types = array( 'portfolio' );\n\nadd_filter( 'register_post_type_args', function ( $args, $post_type ) use ( $wpide_no_archive_types ) {\n    if ( in_array( $post_type, $wpide_no_archive_types, true ) ) {\n        $args['has_archive'] = false;\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Visit Settings > Permalinks once afterwards so a page with that slug can take over the URL."
        },
        {
            "id": "unregister-post-type-taxonomy",
            "version": 1,
            "title": "Remove a Post Type or Taxonomy Added by a Theme or Plugin",
            "description": "Unregisters post types or taxonomies you never use, such as a theme's \"Portfolio\" or \"Projects\", to tidy the admin menu and URLs.",
            "category": "post-types",
            "tags": [
                "custom-post-types",
                "taxonomies",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types and taxonomies to remove.\n$wpide_remove_types      = array( 'portfolio' );\n$wpide_remove_taxonomies = array( 'portfolio_category' );\n\nadd_action( 'init', function () use ( $wpide_remove_types, $wpide_remove_taxonomies ) {\n    foreach ( $wpide_remove_taxonomies as $taxonomy ) {\n        if ( taxonomy_exists( $taxonomy ) && ! get_taxonomy( $taxonomy )->_builtin ) {\n            unregister_taxonomy( $taxonomy );\n        }\n    }\n\n    foreach ( $wpide_remove_types as $type ) {\n        if ( post_type_exists( $type ) && ! get_post_type_object( $type )->_builtin ) {\n            unregister_post_type( $type );\n        }\n    }\n}, 999 );",
            "requires": [],
            "notes": "Existing items stay in the database and come back if you deactivate this snippet. Built-in types cannot be removed."
        },
        {
            "id": "alphabetical-post-type-archive",
            "version": 1,
            "title": "Sort a Post Type Archive A to Z",
            "description": "Lists the items of chosen post type archives in alphabetical order by title instead of newest first, for directories, glossaries and catalogs.",
            "category": "post-types",
            "tags": [
                "archives",
                "ordering",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type archives sorted by title.\n$wpide_az_types = array( 'glossary_term', 'location', 'partner' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_az_types ) {\n    if ( ! is_admin() && $query->is_main_query() && $query->is_post_type_archive( $wpide_az_types ) && ! $query->get( 'orderby' ) ) {\n        $query->set( 'orderby', 'title' );\n        $query->set( 'order', 'ASC' );\n    }\n} );",
            "requires": [],
            "notes": "Their taxonomy archives keep the default order."
        },
        {
            "id": "term-names-in-rest",
            "version": 1,
            "title": "Category and Term Names in REST API Responses",
            "description": "Adds a \"term_names\" field to REST API responses for posts and custom post types, so apps get category and tag names without extra requests.",
            "category": "post-types",
            "tags": [
                "rest-api",
                "taxonomies",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    $types = get_post_types( array( 'show_in_rest' => true, 'public' => true ) );\n\n    register_rest_field( array_values( $types ), 'term_names', array(\n        'get_callback' => function ( $object ) {\n            $names = array();\n\n            foreach ( get_object_taxonomies( $object['type'] ?? '', 'objects' ) as $taxonomy ) {\n                if ( ! $taxonomy->show_in_rest ) {\n                    continue;\n                }\n\n                $terms = get_the_terms( (int) $object['id'], $taxonomy->name );\n                $names[ $taxonomy->name ] = ( $terms && ! is_wp_error( $terms ) ) ? wp_list_pluck( $terms, 'name' ) : array();\n            }\n\n            return $names;\n        },\n        'schema'       => array( 'description' => 'Term names keyed by taxonomy.', 'type' => 'object', 'context' => array( 'view', 'edit' ), 'readonly' => true ),\n    ) );\n} );",
            "requires": [],
            "notes": "Only taxonomies shown in REST are included."
        },
        {
            "id": "protect-post-type-from-deletion",
            "version": 1,
            "title": "Stop Non-Administrators Deleting Items of a Post Type",
            "description": "Lets editors and authors edit entries of chosen post types but only administrators can trash or delete them, protecting records like locations or products.",
            "category": "post-types",
            "tags": [
                "roles",
                "capabilities",
                "protection"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types protected from deletion.\n$wpide_protected_types = array( 'page', 'location' );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_protected_types ) {\n    if ( 'delete_post' !== $cap || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $post = get_post( $args[0] );\n\n    if ( $post && in_array( $post->post_type, $wpide_protected_types, true ) && ! user_can( $user_id, 'manage_options' ) ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Change manage_options to another capability to allow more users."
        },
        {
            "id": "hide-term-from-post-type-archive",
            "version": 1,
            "title": "Hide Items in a Term From a Post Type Archive",
            "description": "Keeps items filed under certain terms (such as \"Archived\" or \"Internal\") off a post type's main archive, while their own term archive still lists them.",
            "category": "post-types",
            "tags": [
                "archives",
                "taxonomies",
                "filtering"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type archive, taxonomy and term slugs to hide.\n$wpide_hide_terms = array(\n    'post_type' => 'job',\n    'taxonomy'  => 'job_type',\n    'terms'     => array( 'internal' ),\n);\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_hide_terms ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive( $wpide_hide_terms['post_type'] ) || ! taxonomy_exists( $wpide_hide_terms['taxonomy'] ) ) {\n        return;\n    }\n\n    $tax_query   = (array) $query->get( 'tax_query' );\n    $tax_query[] = array(\n        'taxonomy' => $wpide_hide_terms['taxonomy'],\n        'field'    => 'slug',\n        'terms'    => $wpide_hide_terms['terms'],\n        'operator' => 'NOT IN',\n    );\n\n    $query->set( 'tax_query', $tax_query );\n} );",
            "requires": [],
            "notes": "Set the post type, taxonomy and term slugs."
        },
        {
            "id": "seo-product-schema-no-woo",
            "version": 1,
            "title": "Product Structured Data Without WooCommerce",
            "description": "Outputs Product JSON-LD with price, currency, SKU, brand and availability for a catalog post type, so products shown without a shop can still get rich results.",
            "category": "seo",
            "tags": [
                "schema",
                "products",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type, currency and the custom fields holding product details.\n$wpide_product_schema = array(\n    'post_type'    => 'catalog_item',\n    'currency'     => 'USD',\n    'price'        => 'price',\n    'sku'          => 'sku',\n    'brand'        => 'brand',\n    'availability' => 'availability', // in_stock, out_of_stock or preorder.\n);\n\nadd_action( 'wp_head', function () use ( $wpide_product_schema ) {\n    if ( ! is_singular( $wpide_product_schema['post_type'] ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post ) {\n        return;\n    }\n\n    $meta = function ( $field ) use ( $post, $wpide_product_schema ) {\n        return trim( (string) get_post_meta( $post->ID, $wpide_product_schema[ $field ], true ) );\n    };\n\n    $data = array(\n        '@context'    => 'https://schema.org',\n        '@type'       => 'Product',\n        'name'        => wp_strip_all_tags( get_the_title( $post ) ),\n        'description' => wp_strip_all_tags( get_the_excerpt( $post ) ),\n        'url'         => get_permalink( $post ),\n        'image'       => get_the_post_thumbnail_url( $post, 'full' ),\n        'sku'         => $meta( 'sku' ),\n    );\n\n    if ( $meta( 'brand' ) ) {\n        $data['brand'] = array( '@type' => 'Brand', 'name' => $meta( 'brand' ) );\n    }\n\n    if ( is_numeric( $meta( 'price' ) ) ) {\n        $map = array( 'out_of_stock' => 'OutOfStock', 'preorder' => 'PreOrder' );\n\n        $data['offers'] = array(\n            '@type'         => 'Offer',\n            'price'         => (string) ( 0 + $meta( 'price' ) ),\n            'priceCurrency' => $wpide_product_schema['currency'],\n            'availability'  => 'https://schema.org/' . ( $map[ $meta( 'availability' ) ] ?? 'InStock' ),\n            'url'           => get_permalink( $post ),\n        );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Set the post type and the custom field names that hold each value. Skip it on WooCommerce products, which have their own schema."
        },
        {
            "id": "seo-event-schema",
            "version": 1,
            "title": "Event Structured Data for an Events Post Type",
            "description": "Adds Event JSON-LD (dates, venue or online location, price and organizer) to single events, so they can appear in Google's event listings.",
            "category": "seo",
            "tags": [
                "schema",
                "events",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Currency for ticket prices.\n$wpide_event_currency = 'USD';\n\nadd_action( 'wp_head', function () use ( $wpide_event_currency ) {\n    if ( ! is_singular( 'event' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $get  = function ( $key ) use ( $post ) {\n        return trim( (string) get_post_meta( $post->ID, '_event_' . $key, true ) );\n    };\n\n    $start = $get( 'start' ) ? date_create_immutable( $get( 'start' ), wp_timezone() ) : false;\n\n    if ( ! $start ) {\n        return;\n    }\n\n    $url   = get_permalink( $post );\n    $place = array(\n        '@type'   => 'Place',\n        'name'    => $get( 'venue' ) ? $get( 'venue' ) : $get( 'address' ),\n        'address' => $get( 'address' ),\n    );\n    $online = array( '@type' => 'VirtualLocation', 'url' => $get( 'tickets' ) ? $get( 'tickets' ) : $url );\n    $mode   = $get( 'mode' ) ? $get( 'mode' ) : 'offline';\n    $modes  = array( 'offline' => 'OfflineEventAttendanceMode', 'online' => 'OnlineEventAttendanceMode', 'mixed' => 'MixedEventAttendanceMode' );\n\n    $data = array(\n        '@context'            => 'https://schema.org',\n        '@type'               => 'Event',\n        'name'                => wp_strip_all_tags( get_the_title( $post ) ),\n        'description'         => wp_strip_all_tags( get_the_excerpt( $post ) ),\n        'url'                 => $url,\n        'startDate'           => $start->format( DATE_W3C ),\n        'eventStatus'         => 'https://schema.org/EventScheduled',\n        'eventAttendanceMode' => 'https://schema.org/' . ( $modes[ $mode ] ?? $modes['offline'] ),\n        'location'            => 'online' === $mode ? $online : ( 'mixed' === $mode ? array( $place, $online ) : $place ),\n        'image'               => get_the_post_thumbnail_url( $post, 'full' ),\n        'organizer'           => array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ) ),\n    );\n\n    $end = $get( 'end' ) ? date_create_immutable( $get( 'end' ), wp_timezone() ) : false;\n\n    if ( $end && $end > $start ) {\n        $data['endDate'] = $end->format( DATE_W3C );\n    }\n\n    if ( is_numeric( $get( 'price' ) ) ) {\n        $data['offers'] = array(\n            '@type'         => 'Offer',\n            'price'         => (string) ( 0 + $get( 'price' ) ),\n            'priceCurrency' => $wpide_event_currency,\n            'url'           => $get( 'tickets' ) ? $get( 'tickets' ) : $url,\n            'availability'  => 'https://schema.org/InStock',\n        );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the fields saved by the Event Details meta box snippet. Set your currency at the top."
        },
        {
            "id": "seo-recipe-schema",
            "version": 1,
            "title": "Recipe Structured Data for a Recipes Post Type",
            "description": "Adds Recipe JSON-LD with times, servings, calories, ingredients, steps, cuisine and course to single recipes, for recipe rich results.",
            "category": "seo",
            "tags": [
                "schema",
                "recipes",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'recipe' ) ) {\n        return;\n    }\n\n    $post  = get_queried_object();\n    $get   = function ( $key ) use ( $post ) {\n        return trim( (string) get_post_meta( $post->ID, '_recipe_' . $key, true ) );\n    };\n    $lines = function ( $text ) {\n        return array_values( array_filter( array_map( 'trim', explode( \"\\n\", $text ) ) ) );\n    };\n    $terms = function ( $taxonomy ) use ( $post ) {\n        $list = taxonomy_exists( $taxonomy ) ? get_the_terms( $post, $taxonomy ) : false;\n\n        return ( $list && ! is_wp_error( $list ) ) ? implode( ', ', wp_list_pluck( $list, 'name' ) ) : '';\n    };\n\n    $prep = absint( $get( 'prep' ) );\n    $cook = absint( $get( 'cook' ) );\n\n    $data = array(\n        '@context'           => 'https://schema.org',\n        '@type'              => 'Recipe',\n        'name'               => wp_strip_all_tags( get_the_title( $post ) ),\n        'description'        => wp_strip_all_tags( get_the_excerpt( $post ) ),\n        'image'              => get_the_post_thumbnail_url( $post, 'full' ),\n        'author'             => array( '@type' => 'Person', 'name' => get_the_author_meta( 'display_name', $post->post_author ) ),\n        'datePublished'      => get_the_date( DATE_W3C, $post ),\n        'prepTime'           => $prep ? 'PT' . $prep . 'M' : '',\n        'cookTime'           => $cook ? 'PT' . $cook . 'M' : '',\n        'totalTime'          => ( $prep + $cook ) ? 'PT' . ( $prep + $cook ) . 'M' : '',\n        'recipeYield'        => $get( 'servings' ) ? $get( 'servings' ) . ' servings' : '',\n        'recipeCuisine'      => $terms( 'cuisine' ),\n        'recipeCategory'     => $terms( 'recipe_course' ),\n        'recipeIngredient'   => $lines( $get( 'ingredients' ) ),\n        'recipeInstructions' => array_map( function ( $step ) {\n            return array( '@type' => 'HowToStep', 'text' => $step );\n        }, $lines( $get( 'steps' ) ) ),\n    );\n\n    if ( $get( 'calories' ) ) {\n        $data['nutrition'] = array( '@type' => 'NutritionInformation', 'calories' => absint( $get( 'calories' ) ) . ' calories' );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the Recipe Details meta box fields and the Cuisine and Course taxonomies. Google requires a featured image."
        },
        {
            "id": "seo-job-posting-schema",
            "version": 1,
            "title": "JobPosting Structured Data for Job Listings",
            "description": "Adds JobPosting JSON-LD (salary, employment type, location or remote, closing date and employer) to single job listings, so they can appear in Google for Jobs.",
            "category": "seo",
            "tags": [
                "schema",
                "jobs",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'job' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $get  = function ( $key ) use ( $post ) {\n        return trim( (string) get_post_meta( $post->ID, '_job_' . $key, true ) );\n    };\n\n    $data = array(\n        '@context'           => 'https://schema.org',\n        '@type'              => 'JobPosting',\n        'title'              => wp_strip_all_tags( get_the_title( $post ) ),\n        'description'        => wp_kses_post( wpautop( $post->post_content ) ),\n        'datePosted'         => get_the_date( DATE_W3C, $post ),\n        'employmentType'     => $get( 'type' ),\n        'directApply'        => false,\n        'hiringOrganization' => array(\n            '@type'  => 'Organization',\n            'name'   => get_bloginfo( 'name' ),\n            'sameAs' => home_url( '/' ),\n            'logo'   => has_custom_logo() ? wp_get_attachment_image_url( (int) get_theme_mod( 'custom_logo' ), 'full' ) : '',\n        ),\n    );\n\n    if ( $get( 'valid_through' ) ) {\n        $data['validThrough'] = $get( 'valid_through' ) . 'T23:59:59' . wp_date( 'P' );\n    }\n\n    if ( $get( 'city' ) || $get( 'country' ) ) {\n        $data['jobLocation'] = array(\n            '@type'   => 'Place',\n            'address' => array_filter( array( '@type' => 'PostalAddress', 'addressLocality' => $get( 'city' ), 'addressCountry' => $get( 'country' ) ) ),\n        );\n    }\n\n    if ( $get( 'remote' ) ) {\n        $data['jobLocationType'] = 'TELECOMMUTE';\n\n        if ( $get( 'country' ) ) {\n            $data['applicantLocationRequirements'] = array( '@type' => 'Country', 'name' => $get( 'country' ) );\n        }\n    }\n\n    if ( is_numeric( $get( 'salary_min' ) ) && $get( 'currency' ) ) {\n        $value = array( '@type' => 'QuantitativeValue', 'unitText' => $get( 'salary_unit' ) ? $get( 'salary_unit' ) : 'YEAR' );\n\n        if ( is_numeric( $get( 'salary_max' ) ) && (float) $get( 'salary_max' ) > (float) $get( 'salary_min' ) ) {\n            $value['minValue'] = (float) $get( 'salary_min' );\n            $value['maxValue'] = (float) $get( 'salary_max' );\n        } else {\n            $value['value'] = (float) $get( 'salary_min' );\n        }\n\n        $data['baseSalary'] = array( '@type' => 'MonetaryAmount', 'currency' => strtoupper( $get( 'currency' ) ), 'value' => $value );\n    }\n\n    $data['hiringOrganization'] = array_filter( $data['hiringOrganization'] );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data, function ( $v ) { return '' !== $v && null !== $v; } ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the Job Details meta box fields. Remove or expire listings once filled; Google penalises stale job markup."
        },
        {
            "id": "seo-howto-shortcode",
            "version": 1,
            "title": "How-To Steps Shortcode With HowTo Schema",
            "description": "Adds a [howto] shortcode that wraps a numbered list of steps, keeps it on the page as normal content and outputs matching HowTo structured data.",
            "category": "seo",
            "tags": [
                "schema",
                "howto",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'howto', function ( $atts, $content = '' ) {\n    $atts    = shortcode_atts( array( 'name' => '', 'time' => '' ), $atts, 'howto' );\n    $content = do_shortcode( (string) $content );\n\n    if ( ! preg_match_all( '#<li[^>]*>(.*?)</li>#is', $content, $matches ) ) {\n        return $content;\n    }\n\n    $steps = array();\n\n    foreach ( $matches[1] as $i => $step ) {\n        $text = trim( wp_strip_all_tags( $step ) );\n\n        if ( '' !== $text ) {\n            $steps[] = array( '@type' => 'HowToStep', 'position' => $i + 1, 'text' => $text );\n        }\n    }\n\n    $schema = array(\n        '@context' => 'https://schema.org',\n        '@type'    => 'HowTo',\n        'name'     => $atts['name'] ? wp_strip_all_tags( $atts['name'] ) : wp_strip_all_tags( get_the_title() ),\n        'step'     => $steps,\n    );\n\n    if ( absint( $atts['time'] ) ) {\n        $schema['totalTime'] = 'PT' . absint( $atts['time'] ) . 'M';\n    }\n\n    return '<div class=\"howto\">' . $content . '</div><script type=\"application/ld+json\">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . '</script>';\n} );",
            "requires": [],
            "notes": "Wrap a numbered list in [howto name=\"How to repot a plant\" time=\"20\"] ... [/howto]; Google no longer shows HowTo rich results, but other search and AI tools read the markup."
        },
        {
            "id": "seo-person-schema-about",
            "version": 1,
            "title": "Person Structured Data for a Personal Brand",
            "description": "Adds Person JSON-LD describing you (name, job title, photo, employer and social profiles) to your About page and homepage, for personal sites and portfolios.",
            "category": "seo",
            "tags": [
                "schema",
                "person",
                "personal-brand"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your details.\n$wpide_person = array(\n    'name'      => 'Jane Doe',\n    'job_title' => 'Freelance Web Designer',\n    'image'     => home_url( '/wp-content/uploads/jane-doe.jpg' ),\n    'works_for' => 'Doe Studio',\n    'same_as'   => array( 'https://www.linkedin.com/in/janedoe', 'https://github.com/janedoe' ),\n    'about'     => 'about', // Slug of your About page.\n);\n\nadd_action( 'wp_head', function () use ( $wpide_person ) {\n    if ( ! is_front_page() && ! is_page( $wpide_person['about'] ) ) {\n        return;\n    }\n\n    $data = array(\n        '@context' => 'https://schema.org',\n        '@type'    => 'Person',\n        '@id'      => home_url( '/#person' ),\n        'name'     => $wpide_person['name'],\n        'url'      => home_url( '/' ),\n        'jobTitle' => $wpide_person['job_title'],\n        'image'    => $wpide_person['image'],\n        'sameAs'   => array_values( array_filter( $wpide_person['same_as'] ) ),\n    );\n\n    if ( $wpide_person['works_for'] ) {\n        $data['worksFor'] = array( '@type' => 'Organization', 'name' => $wpide_person['works_for'] );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Fill in your details and the slug of your About page. Use Organization schema instead if the site represents a company."
        },
        {
            "id": "seo-video-schema-youtube",
            "version": 1,
            "title": "VideoObject Structured Data for YouTube Embeds",
            "description": "Finds YouTube videos embedded in a post and adds VideoObject JSON-LD for each, with thumbnail and embed URL, so the page can show video results in search.",
            "category": "seo",
            "tags": [
                "schema",
                "video",
                "youtube"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post || false === stripos( $post->post_content, 'youtu' ) ) {\n        return;\n    }\n\n    preg_match_all( '#(?:youtube(?:-nocookie)?\\.com/(?:watch\\?(?:.*&)?v=|embed/|shorts/)|youtu\\.be/)([A-Za-z0-9_-]{11})#', $post->post_content, $matches );\n    $ids = array_slice( array_unique( $matches[1] ), 0, 3 );\n\n    if ( ! $ids ) {\n        return;\n    }\n\n    $graph = array();\n\n    foreach ( $ids as $i => $id ) {\n        $graph[] = array(\n            '@type'        => 'VideoObject',\n            'name'         => wp_strip_all_tags( get_the_title( $post ) ) . ( $i ? ' (' . ( $i + 1 ) . ')' : '' ),\n            'description'  => wp_strip_all_tags( get_the_excerpt( $post ) ) ?: wp_strip_all_tags( get_the_title( $post ) ),\n            'thumbnailUrl' => 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg',\n            'uploadDate'   => get_the_date( DATE_W3C, $post ),\n            'embedUrl'     => 'https://www.youtube.com/embed/' . $id,\n        );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array( '@context' => 'https://schema.org', '@graph' => $graph ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Uses the post title, excerpt and publish date for every video (up to three per page); no requests are sent to YouTube."
        },
        {
            "id": "seo-software-app-schema",
            "version": 1,
            "title": "SoftwareApplication Structured Data for an App Page",
            "description": "Adds SoftwareApplication JSON-LD (operating system, category, price and optional rating) to the page that presents your app, plugin or SaaS product.",
            "category": "seo",
            "tags": [
                "schema",
                "software",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// App details and the ID of the page that presents it.\n$wpide_app = array(\n    'page_id'      => 42,\n    'name'         => 'Acme Planner',\n    'os'           => 'Web, iOS, Android',\n    'category'     => 'BusinessApplication',\n    'price'        => '0',\n    'currency'     => 'USD',\n    'rating_value' => '',\n    'rating_count' => '',\n);\n\nadd_action( 'wp_head', function () use ( $wpide_app ) {\n    if ( ! is_page( (int) $wpide_app['page_id'] ) && ! is_single( (int) $wpide_app['page_id'] ) ) {\n        return;\n    }\n\n    $data = array(\n        '@context'            => 'https://schema.org',\n        '@type'               => 'SoftwareApplication',\n        'name'                => $wpide_app['name'],\n        'operatingSystem'     => $wpide_app['os'],\n        'applicationCategory' => $wpide_app['category'],\n        'url'                 => get_permalink( (int) $wpide_app['page_id'] ),\n        'offers'              => array( '@type' => 'Offer', 'price' => $wpide_app['price'], 'priceCurrency' => $wpide_app['currency'] ),\n    );\n\n    if ( is_numeric( $wpide_app['rating_value'] ) && absint( $wpide_app['rating_count'] ) ) {\n        $data['aggregateRating'] = array( '@type' => 'AggregateRating', 'ratingValue' => $wpide_app['rating_value'], 'ratingCount' => absint( $wpide_app['rating_count'] ) );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Only add a rating when it comes from real, visible reviews; leave rating_value empty otherwise."
        },
        {
            "id": "seo-service-schema",
            "version": 1,
            "title": "Service Structured Data for a Services Post Type",
            "description": "Adds Service JSON-LD (name, description, provider and area served) to single service pages, describing what your business offers.",
            "category": "seo",
            "tags": [
                "schema",
                "services",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post type and the areas you serve.\n$wpide_service_type  = 'service';\n$wpide_service_areas = array( 'Springfield', 'Shelbyville' );\n\nadd_action( 'wp_head', function () use ( $wpide_service_type, $wpide_service_areas ) {\n    if ( ! is_singular( $wpide_service_type ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $data = array(\n        '@context'    => 'https://schema.org',\n        '@type'       => 'Service',\n        'name'        => wp_strip_all_tags( get_the_title( $post ) ),\n        'serviceType' => wp_strip_all_tags( get_the_title( $post ) ),\n        'description' => wp_strip_all_tags( get_the_excerpt( $post ) ),\n        'url'         => get_permalink( $post ),\n        'image'       => get_the_post_thumbnail_url( $post, 'full' ),\n        'provider'    => array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ) ),\n        'areaServed'  => array_map( function ( $area ) {\n            return array( '@type' => 'Place', 'name' => $area );\n        }, $wpide_service_areas ),\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Made for the Services post type snippet. Set the areas you serve."
        },
        {
            "id": "seo-itemlist-archive-schema",
            "version": 1,
            "title": "ItemList Structured Data on Archive Pages",
            "description": "Adds ItemList JSON-LD listing the posts shown on category, tag, post type and blog archive pages, using the posts already loaded so it adds no queries.",
            "category": "seo",
            "tags": [
                "schema",
                "archives",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    global $wp_query;\n\n    if ( ! ( is_home() || is_category() || is_tag() || is_tax() || is_post_type_archive() ) || empty( $wp_query->posts ) ) {\n        return;\n    }\n\n    $offset = ( max( 1, (int) get_query_var( 'paged' ) ) - 1 ) * (int) $wp_query->get( 'posts_per_page' );\n    $items  = array();\n\n    foreach ( $wp_query->posts as $i => $post ) {\n        $items[] = array( '@type' => 'ListItem', 'position' => $offset + $i + 1, 'url' => get_permalink( $post ) );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array( '@context' => 'https://schema.org', '@type' => 'ItemList', 'itemListElement' => $items ), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Google uses ItemList for carousels of some content types, such as recipes and courses."
        },
        {
            "id": "seo-location-business-schema",
            "version": 1,
            "title": "LocalBusiness Structured Data for Each Location Page",
            "description": "Adds LocalBusiness JSON-LD (address, phone, opening hours and map coordinates) to every branch or store page of a Locations post type.",
            "category": "seo",
            "tags": [
                "schema",
                "locations",
                "local-seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Schema type for each location.\n$wpide_location_type = 'LocalBusiness';\n\nadd_action( 'wp_head', function () use ( $wpide_location_type ) {\n    if ( ! is_singular( 'location' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $get  = function ( $key ) use ( $post ) {\n        return trim( (string) get_post_meta( $post->ID, '_location_' . $key, true ) );\n    };\n\n    $data = array(\n        '@context'     => 'https://schema.org',\n        '@type'        => $wpide_location_type,\n        '@id'          => get_permalink( $post ) . '#business',\n        'name'         => get_bloginfo( 'name' ) . ' – ' . wp_strip_all_tags( get_the_title( $post ) ),\n        'url'          => get_permalink( $post ),\n        'image'        => get_the_post_thumbnail_url( $post, 'full' ),\n        'telephone'    => $get( 'phone' ),\n        'openingHours' => array_values( array_filter( array_map( 'trim', explode( \"\\n\", $get( 'hours' ) ) ) ) ),\n        'address'      => array_filter( array(\n            '@type'           => 'PostalAddress',\n            'streetAddress'   => $get( 'street' ),\n            'addressLocality' => $get( 'city' ),\n            'addressRegion'   => $get( 'region' ),\n            'postalCode'      => $get( 'postcode' ),\n            'addressCountry'  => $get( 'country' ),\n        ) ),\n        'parentOrganization' => array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ) ),\n    );\n\n    if ( is_numeric( $get( 'lat' ) ) && is_numeric( $get( 'lng' ) ) ) {\n        $data['geo'] = array( '@type' => 'GeoCoordinates', 'latitude' => (float) $get( 'lat' ), 'longitude' => (float) $get( 'lng' ) );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the Location Details meta box fields. Change the type to Restaurant, Store or another LocalBusiness subtype."
        },
        {
            "id": "seo-book-schema",
            "version": 1,
            "title": "Book Structured Data for a Books Post Type",
            "description": "Adds Book JSON-LD with author, ISBN, publisher, page count and publication date to single book pages.",
            "category": "seo",
            "tags": [
                "schema",
                "books",
                "json-ld"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'book' ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $get  = function ( $key ) use ( $post ) {\n        return trim( (string) get_post_meta( $post->ID, '_book_' . $key, true ) );\n    };\n\n    $data = array(\n        '@context'      => 'https://schema.org',\n        '@type'         => 'Book',\n        'name'          => wp_strip_all_tags( get_the_title( $post ) ),\n        'description'   => wp_strip_all_tags( get_the_excerpt( $post ) ),\n        'url'           => get_permalink( $post ),\n        'image'         => get_the_post_thumbnail_url( $post, 'full' ),\n        'isbn'          => $get( 'isbn' ),\n        'numberOfPages' => absint( $get( 'pages' ) ),\n        'datePublished' => $get( 'published' ),\n    );\n\n    if ( $get( 'author_name' ) ) {\n        $data['author'] = array( '@type' => 'Person', 'name' => $get( 'author_name' ) );\n    }\n\n    if ( $get( 'publisher' ) ) {\n        $data['publisher'] = array( '@type' => 'Organization', 'name' => $get( 'publisher' ) );\n    }\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( array_filter( $data ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the Book Details meta box fields."
        },
        {
            "id": "seo-faq-schema-details-blocks",
            "version": 1,
            "title": "FAQ Structured Data From Details Blocks",
            "description": "Turns every Details block (summary and hidden text) in a post or page into a question and answer in FAQPage JSON-LD, with nothing extra to fill in.",
            "category": "seo",
            "tags": [
                "schema",
                "faq",
                "gutenberg"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular() ) {\n        return;\n    }\n\n    $post = get_queried_object();\n\n    if ( ! $post instanceof WP_Post || ! has_block( 'core/details', $post ) ) {\n        return;\n    }\n\n    $questions = array();\n    $html_of   = function ( $blocks ) use ( &$html_of ) {\n        $html = '';\n\n        foreach ( $blocks as $block ) {\n            $html .= $block['innerHTML'] . ' ' . $html_of( $block['innerBlocks'] );\n        }\n\n        return $html;\n    };\n    $walk      = function ( $blocks ) use ( &$walk, &$questions, $html_of ) {\n        foreach ( $blocks as $block ) {\n            if ( 'core/details' !== $block['blockName'] ) {\n                if ( ! empty( $block['innerBlocks'] ) ) {\n                    $walk( $block['innerBlocks'] );\n                }\n                continue;\n            }\n\n            if ( ! preg_match( '#<summary[^>]*>(.*?)</summary>(.*)#is', (string) $block['innerHTML'], $m ) ) {\n                continue;\n            }\n\n            $question = trim( wp_strip_all_tags( $m[1] ) );\n            $answer   = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( $m[2] . ' ' . $html_of( $block['innerBlocks'] ) ) ) );\n\n            if ( '' !== $question && '' !== $answer ) {\n                $questions[] = array( '@type' => 'Question', 'name' => $question, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $answer ) );\n            }\n        }\n    };\n\n    $walk( parse_blocks( $post->post_content ) );\n\n    if ( $questions ) {\n        echo '<script type=\"application/ld+json\">' . wp_json_encode( array( '@context' => 'https://schema.org', '@type' => 'FAQPage', 'mainEntity' => $questions ), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n    }\n} );",
            "requires": [],
            "notes": "Google now shows FAQ rich results mainly for authoritative health and government sites, but other search and AI tools still read the markup."
        },
        {
            "id": "seo-paywalled-content-schema",
            "version": 1,
            "title": "Paywalled Content Structured Data",
            "description": "Marks members-only posts as not free with isAccessibleForFree and the CSS class of the gated section, so Google does not treat the hidden part as cloaking.",
            "category": "seo",
            "tags": [
                "schema",
                "paywall",
                "memberships"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Categories whose posts are paywalled, and the class of the gated section.\n$wpide_paywall_categories = array( 'premium' );\n$wpide_paywall_class      = 'paywall';\n\nadd_action( 'wp_head', function () use ( $wpide_paywall_categories, $wpide_paywall_class ) {\n    if ( ! is_singular( 'post' ) || ! has_category( $wpide_paywall_categories, get_queried_object_id() ) ) {\n        return;\n    }\n\n    $post = get_queried_object();\n    $data = array(\n        '@context'            => 'https://schema.org',\n        '@type'               => 'NewsArticle',\n        'headline'            => wp_strip_all_tags( get_the_title( $post ) ),\n        'datePublished'       => get_the_date( DATE_W3C, $post ),\n        'dateModified'        => get_the_modified_date( DATE_W3C, $post ),\n        'mainEntityOfPage'    => get_permalink( $post ),\n        'author'              => array( '@type' => 'Person', 'name' => get_the_author_meta( 'display_name', $post->post_author ) ),\n        'isAccessibleForFree' => false,\n        'hasPart'             => array(\n            '@type'               => 'WebPageElement',\n            'isAccessibleForFree' => false,\n            'cssSelector'         => '.' . sanitize_html_class( $wpide_paywall_class ),\n        ),\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Wrap the members-only part of the content in an element with the paywall class. If your SEO plugin already outputs Article schema, this adds a second, compatible Article node."
        },
        {
            "id": "seo-team-member-profile-schema",
            "version": 1,
            "title": "ProfilePage Structured Data for Team Members",
            "description": "Adds ProfilePage JSON-LD with a Person (name, job title, photo and LinkedIn) to each team member page, linking staff to your organization.",
            "category": "seo",
            "tags": [
                "schema",
                "team",
                "person"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    if ( ! is_singular( 'team' ) ) {\n        return;\n    }\n\n    $post   = get_queried_object();\n    $person = array(\n        '@type'    => 'Person',\n        'name'     => wp_strip_all_tags( get_the_title( $post ) ),\n        'jobTitle' => (string) get_post_meta( $post->ID, '_team_role', true ),\n        'image'    => get_the_post_thumbnail_url( $post, 'large' ),\n        'url'      => get_permalink( $post ),\n        'sameAs'   => array_values( array_filter( array( (string) get_post_meta( $post->ID, '_team_linkedin', true ) ) ) ),\n        'worksFor' => array( '@type' => 'Organization', 'name' => get_bloginfo( 'name' ), 'url' => home_url( '/' ) ),\n    );\n\n    $data = array(\n        '@context'     => 'https://schema.org',\n        '@type'        => 'ProfilePage',\n        'dateModified' => get_the_modified_date( DATE_W3C, $post ),\n        'mainEntity'   => array_filter( $person ),\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Reads the Team Member Details meta box fields. Email and phone are left out on purpose."
        },
        {
            "id": "seo-image-license-schema",
            "version": 1,
            "title": "Image License Structured Data for Featured Images",
            "description": "Adds ImageObject JSON-LD with license, credit and copyright details for each post's featured image, which can earn the \"Licensable\" badge in Google Images.",
            "category": "seo",
            "tags": [
                "schema",
                "images",
                "copyright"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your image licensing details.\n$wpide_image_license = array(\n    'license'   => home_url( '/image-license/' ),\n    'acquire'   => home_url( '/contact/' ),\n    'credit'    => get_bloginfo( 'name' ),\n    'copyright' => '© ' . gmdate( 'Y' ) . ' ' . get_bloginfo( 'name' ),\n);\n\nadd_action( 'wp_head', function () use ( $wpide_image_license ) {\n    if ( ! is_singular() || ! has_post_thumbnail( get_queried_object_id() ) ) {\n        return;\n    }\n\n    $data = array(\n        '@context'           => 'https://schema.org',\n        '@type'              => 'ImageObject',\n        'contentUrl'         => get_the_post_thumbnail_url( get_queried_object_id(), 'full' ),\n        'license'            => $wpide_image_license['license'],\n        'acquireLicensePage' => $wpide_image_license['acquire'],\n        'creditText'         => $wpide_image_license['credit'],\n        'copyrightNotice'    => $wpide_image_license['copyright'],\n        'creator'            => array( '@type' => 'Organization', 'name' => $wpide_image_license['credit'] ),\n    );\n\n    echo '<script type=\"application/ld+json\">' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ) . \"</script>\\n\";\n} );",
            "requires": [],
            "notes": "Point the license and acquire-license URLs at real pages on your site that explain your image terms."
        },
        {
            "id": "seo-hreflang-regional-domains",
            "version": 1,
            "title": "hreflang Tags for Regional Versions of Your Site",
            "description": "Links matching pages across your country sites (such as example.com, example.co.uk and example.com.au) with hreflang tags, assuming each page uses the same path on every domain.",
            "category": "seo",
            "tags": [
                "hreflang",
                "international",
                "multisite"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Language-region code => site address (no trailing slash). x-default is used for everyone else.\n$wpide_regional_sites = array(\n    'en-US'     => 'https://example.com',\n    'en-GB'     => 'https://example.co.uk',\n    'en-AU'     => 'https://example.com.au',\n    'x-default' => 'https://example.com',\n);\n\nadd_action( 'wp_head', function () use ( $wpide_regional_sites ) {\n    if ( is_404() || is_search() || is_preview() || ( is_singular() && post_password_required() ) ) {\n        return;\n    }\n\n    $path = (string) wp_parse_url( home_url( add_query_arg( array() ) ), PHP_URL_PATH );\n    $base = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );\n    $path = '/' . ltrim( substr( $path, strlen( rtrim( $base, '/' ) ) ), '/' );\n\n    foreach ( $wpide_regional_sites as $lang => $site ) {\n        printf( '<link rel=\"alternate\" hreflang=\"%1$s\" href=\"%2$s\">' . \"\\n\", esc_attr( $lang ), esc_url( untrailingslashit( $site ) . $path ) );\n    }\n} );",
            "requires": [],
            "notes": "Add this to every regional site with the same map. Pages whose path differs between sites need a multilingual plugin instead."
        },
        {
            "id": "seo-noindex-author-archives",
            "version": 1,
            "title": "Noindex Author Archives",
            "description": "Keeps author archive pages out of search results while their links are still followed, which suits single-author blogs where they duplicate the main blog.",
            "category": "seo",
            "tags": [
                "noindex",
                "authors",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( is_author() ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "To remove author pages entirely, use a redirect instead."
        },
        {
            "id": "seo-noindex-password-protected",
            "version": 1,
            "title": "Noindex Password-Protected Posts and Pages",
            "description": "Adds noindex to posts and pages that need a password, so search engines do not list pages whose content they cannot see.",
            "category": "seo",
            "tags": [
                "noindex",
                "passwords",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_robots', function ( $robots ) {\n    if ( is_singular() && '' !== (string) get_post_field( 'post_password', get_queried_object_id() ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": ""
        },
        {
            "id": "seo-noindex-post-type",
            "version": 1,
            "title": "Noindex an Entire Post Type",
            "description": "Keeps every entry and the archive of chosen post types out of search results and removes them from the built-in XML sitemap, while visitors can still open them.",
            "category": "seo",
            "tags": [
                "noindex",
                "custom-post-types",
                "sitemap"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types to keep out of search engines.\n$wpide_noindex_types = array( 'testimonial', 'partner' );\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_noindex_types ) {\n    if ( is_singular( $wpide_noindex_types ) || is_post_type_archive( $wpide_noindex_types ) ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );\n\nadd_filter( 'wp_sitemaps_post_types', function ( $post_types ) use ( $wpide_noindex_types ) {\n    return array_diff_key( $post_types, array_flip( $wpide_noindex_types ) );\n} );",
            "requires": [],
            "notes": "Useful for thank-you pages, internal resources or thin listings. SEO plugins have their own per-type setting."
        },
        {
            "id": "seo-noindex-short-posts",
            "version": 1,
            "title": "Noindex Posts Below a Word Count",
            "description": "Adds noindex to blog posts with fewer words than a minimum, such as quick updates or link posts, so thin pages do not drag down how search engines see your site.",
            "category": "seo",
            "tags": [
                "noindex",
                "thin-content",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Post types checked and the minimum number of words.\n$wpide_thin_types     = array( 'post' );\n$wpide_thin_min_words = 250;\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_thin_types, $wpide_thin_min_words ) {\n    if ( ! is_singular( $wpide_thin_types ) ) {\n        return $robots;\n    }\n\n    $content = (string) get_post_field( 'post_content', get_queried_object_id() );\n    $words   = str_word_count( wp_strip_all_tags( strip_shortcodes( $content ) ) );\n\n    if ( $words < $wpide_thin_min_words ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Change the post types and minimum. The count ignores HTML and shortcodes."
        },
        {
            "id": "seo-noindex-filter-params",
            "version": 1,
            "title": "Noindex URLs With Sorting and Filter Parameters",
            "description": "Adds noindex to pages opened with sorting or filter parameters (such as ?orderby= or ?filter_color=), so crawlers do not index endless variations of the same list.",
            "category": "seo",
            "tags": [
                "noindex",
                "parameters",
                "crawl-budget"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Query parameters that make a page a duplicate (a trailing _ matches any name starting with it).\n$wpide_noindex_params = array( 'orderby', 'order', 'sort', 'view', 'per_page', 'min_price', 'max_price', 'rating_filter', 'filter_', 'query_type_' );\n\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_noindex_params ) {\n    if ( empty( $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification\n        return $robots;\n    }\n\n    foreach ( array_keys( $_GET ) as $param ) { // phpcs:ignore WordPress.Security.NonceVerification\n        foreach ( $wpide_noindex_params as $blocked ) {\n            if ( $param === $blocked || ( '_' === substr( $blocked, -1 ) && 0 === strpos( (string) $param, $blocked ) ) ) {\n                $robots['noindex'] = true;\n                $robots['follow']  = true;\n\n                return $robots;\n            }\n        }\n    }\n\n    return $robots;\n} );",
            "requires": [],
            "notes": "Names ending in an underscore match as prefixes. Tracking parameters like utm_source are left alone, since the canonical tag handles them."
        },
        {
            "id": "seo-rel-prev-next",
            "version": 1,
            "title": "rel=\"prev\" and rel=\"next\" Links on Paginated Archives",
            "description": "Adds rel prev and next link tags to paginated blog, category and archive pages, which Bing and other crawlers use to understand page series.",
            "category": "seo",
            "tags": [
                "pagination",
                "archives",
                "crawling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    global $wp_query;\n\n    if ( is_singular() || is_404() || ! ( is_home() || is_archive() || is_search() ) ) {\n        return;\n    }\n\n    $paged = max( 1, (int) get_query_var( 'paged' ) );\n    $max   = (int) $wp_query->max_num_pages;\n\n    if ( $paged > 1 ) {\n        echo '<link rel=\"prev\" href=\"' . esc_url( get_pagenum_link( $paged - 1 ) ) . '\">' . \"\\n\";\n    }\n\n    if ( $paged < $max ) {\n        echo '<link rel=\"next\" href=\"' . esc_url( get_pagenum_link( $paged + 1 ) ) . '\">' . \"\\n\";\n    }\n} );",
            "requires": [],
            "notes": "Skip this if your SEO plugin already prints these links."
        },
        {
            "id": "seo-remove-replytocom",
            "version": 1,
            "title": "Remove ?replytocom From Comment Reply Links",
            "description": "Stops comment reply links from creating ?replytocom= URLs that crawlers treat as duplicate pages, and redirects any such URL to the clean post address.",
            "category": "seo",
            "tags": [
                "comments",
                "duplicate-content",
                "crawling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'comment_reply_link', function ( $link ) {\n    return preg_replace( '/href=([\\'\"])[^\\'\"]*replytocom=\\d+[^\\'\"]*\\1/', 'href=\"#respond\"', $link );\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( isset( $_GET['replytocom'] ) && is_singular() ) { // phpcs:ignore WordPress.Security.NonceVerification\n        wp_safe_redirect( get_permalink( get_queried_object_id() ) . '#respond', 301 );\n        exit;\n    }\n} );",
            "requires": [],
            "notes": "Replying still works through WordPress's comment-reply script, which every modern theme loads."
        },
        {
            "id": "seo-canonical-original-source",
            "version": 1,
            "title": "Canonical Link to the Original Source for Republished Posts",
            "description": "Points the canonical tag of a post to the URL in its \"original_url\" custom field, for articles first published elsewhere, so the original keeps the search credit.",
            "category": "seo",
            "tags": [
                "canonical",
                "syndication",
                "custom-fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Custom field holding the original URL.\n$wpide_original_field = 'original_url';\n\nadd_filter( 'get_canonical_url', function ( $url, $post ) use ( $wpide_original_field ) {\n    $source = esc_url_raw( (string) get_post_meta( $post->ID, $wpide_original_field, true ) );\n\n    return wp_http_validate_url( $source ) ? $source : $url;\n}, 10, 2 );",
            "requires": [],
            "notes": "Works with WordPress's own canonical tag. SEO plugins replace it, so use their canonical setting instead when one is active."
        },
        {
            "id": "seo-remove-category-base",
            "version": 1,
            "title": "Remove /category/ From Category URLs",
            "description": "Shortens category links from example.com/category/news/ to example.com/news/ and redirects the old addresses there permanently.",
            "category": "seo",
            "tags": [
                "permalinks",
                "categories",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'category_link', function ( $link ) {\n    $base = trim( get_option( 'category_base' ) ? get_option( 'category_base' ) : 'category', '/' );\n\n    return str_replace( home_url( '/' . $base . '/' ), home_url( '/' ), $link );\n} );\n\nadd_filter( 'category_rewrite_rules', function () {\n    $rules = array();\n\n    foreach ( get_categories( array( 'hide_empty' => false ) ) as $category ) {\n        $path = $category->parent ? get_category_parents( $category->term_id, false, '/', true ) : $category->slug . '/';\n\n        if ( is_wp_error( $path ) ) {\n            continue;\n        }\n\n        $path = preg_quote( trim( $path, '/' ), '#' );\n        $rules[ \"($path)/(?:feed/)?(feed|rdf|rss|rss2|atom)/?$\" ] = 'index.php?category_name=$matches[1]&feed=$matches[2]';\n        $rules[ \"($path)/page/?([0-9]{1,})/?$\" ]                  = 'index.php?category_name=$matches[1]&paged=$matches[2]';\n        $rules[ \"($path)/?$\" ]                                    = 'index.php?category_name=$matches[1]';\n    }\n\n    // Old /category/... addresses are caught and redirected below.\n    $base = trim( get_option( 'category_base' ) ? get_option( 'category_base' ) : 'category', '/' );\n    $rules[ preg_quote( $base, '#' ) . '/(.+?)/?$' ] = 'index.php?wpide_old_category=$matches[1]';\n\n    return $rules;\n} );\n\nadd_filter( 'query_vars', function ( $vars ) {\n    $vars[] = 'wpide_old_category';\n\n    return $vars;\n} );\n\nadd_action( 'template_redirect', function () {\n    $old = get_query_var( 'wpide_old_category' );\n\n    if ( $old ) {\n        wp_safe_redirect( home_url( '/' . trim( sanitize_text_field( $old ), '/' ) . '/' ), 301 );\n        exit;\n    }\n} );\n\n// Rebuild the rules when categories change.\nforeach ( array( 'created_category', 'edited_category', 'delete_category' ) as $wpide_cat_hook ) {\n    add_action( $wpide_cat_hook, function () {\n        delete_option( 'rewrite_rules' );\n    } );\n}",
            "requires": [],
            "notes": "Needs pretty permalinks; visit Settings > Permalinks once after activating and avoid categories with the same slug as a page, since the category wins."
        },
        {
            "id": "seo-auto-internal-links",
            "version": 1,
            "title": "Automatic Internal Links for Key Phrases",
            "description": "Links the first mention of phrases you choose to the matching pages in your posts, building internal links without editing old content.",
            "category": "seo",
            "tags": [
                "internal-links",
                "content",
                "keywords"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Phrase => URL, and the maximum number of automatic links per post.\n$wpide_auto_links = array(\n    'technical SEO'   => home_url( '/services/technical-seo/' ),\n    'content audit'   => home_url( '/guides/content-audit/' ),\n    'local business'  => home_url( '/local-seo/' ),\n);\n$wpide_auto_links_max = 5;\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_auto_links, $wpide_auto_links_max ) {\n    if ( ! is_singular( 'post' ) || ! in_the_loop() || ! is_main_query() || ! $wpide_auto_links ) {\n        return $content;\n    }\n\n    $current = untrailingslashit( get_permalink() );\n    $parts   = preg_split( '/(<[^>]+>)/', $content, -1, PREG_SPLIT_DELIM_CAPTURE );\n    $skip    = 0;\n    $used    = array();\n    $count   = 0;\n\n    foreach ( $parts as $i => $part ) {\n        if ( '' === $part ) {\n            continue;\n        }\n\n        if ( '<' === $part[0] ) {\n            // Track whether we are inside an element whose text must not be linked.\n            if ( preg_match( '#^<(a|h[1-6]|code|pre|button|script|style|textarea|figcaption)\\b#i', $part ) ) {\n                $skip++;\n            } elseif ( preg_match( '#^</(a|h[1-6]|code|pre|button|script|style|textarea|figcaption)>#i', $part ) ) {\n                $skip = max( 0, $skip - 1 );\n            }\n            continue;\n        }\n\n        if ( $skip || $count >= $wpide_auto_links_max ) {\n            continue;\n        }\n\n        foreach ( $wpide_auto_links as $phrase => $url ) {\n            if ( isset( $used[ $phrase ] ) || $count >= $wpide_auto_links_max || untrailingslashit( $url ) === $current ) {\n                continue;\n            }\n\n            $pattern = '/(?<![\\p{L}\\p{N}])(' . preg_quote( $phrase, '/' ) . ')(?![\\p{L}\\p{N}])/iu';\n\n            if ( preg_match( $pattern, $part ) ) {\n                $part = preg_replace( $pattern, '<a href=\"' . esc_url( $url ) . '\" class=\"auto-link\">$1</a>', $part, 1 );\n                $used[ $phrase ] = true;\n                $count++;\n            }\n        }\n\n        $parts[ $i ] = $part;\n    }\n\n    return implode( '', $parts );\n}, 20 );",
            "requires": [],
            "notes": "Matching ignores case and never links inside headings, existing links, code or buttons. Each phrase is linked once per post, and a page never links to itself."
        },
        {
            "id": "seo-orphan-content-report",
            "version": 1,
            "title": "Orphaned Content Report",
            "description": "Adds Tools > Orphaned Content, listing published posts and pages that no other post or page links to, so you can add internal links where they are missing.",
            "category": "seo",
            "tags": [
                "internal-links",
                "report",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Orphaned Content', 'Orphaned Content', 'edit_others_posts', 'wpide-orphans', function () {\n        global $wpdb;\n\n        if ( isset( $_POST['wpide_orphans_refresh'] ) && check_admin_referer( 'wpide_orphans_refresh' ) ) {\n            delete_transient( 'wpide_orphans' );\n        }\n\n        $orphans = get_transient( 'wpide_orphans' );\n\n        if ( false === $orphans ) {\n            $rows   = $wpdb->get_results( \"SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ('post','page') ORDER BY post_date DESC LIMIT 3000\" );\n            $ids    = wp_list_pluck( $rows, 'ID' );\n            $by_url = array();\n            _prime_post_caches( $ids, false, false );\n\n            foreach ( $ids as $id ) {\n                $by_url[ untrailingslashit( (string) wp_parse_url( get_permalink( $id ), PHP_URL_PATH ) ) ] = (int) $id;\n            }\n\n            $linked = array();\n            $front  = (int) get_option( 'page_on_front' );\n            $host   = wp_parse_url( home_url(), PHP_URL_HOST );\n\n            foreach ( array_chunk( $ids, 200 ) as $chunk ) {\n                $contents = $wpdb->get_results( \"SELECT ID, post_content FROM {$wpdb->posts} WHERE ID IN (\" . implode( ',', array_map( 'intval', $chunk ) ) . ')' );\n\n                foreach ( $contents as $row ) {\n                    if ( ! preg_match_all( '#href=[\"\\']([^\"\\'\\#]+)#i', $row->post_content, $m ) ) {\n                        continue;\n                    }\n\n                    foreach ( $m[1] as $href ) {\n                        $parts = wp_parse_url( $href );\n\n                        if ( ! empty( $parts['host'] ) && $parts['host'] !== $host ) {\n                            continue;\n                        }\n\n                        parse_str( $parts['query'] ?? '', $query );\n                        $target = isset( $query['p'] ) ? (int) $query['p'] : ( isset( $query['page_id'] ) ? (int) $query['page_id'] : ( $by_url[ untrailingslashit( $parts['path'] ?? '' ) ] ?? 0 ) );\n\n                        if ( $target && $target !== (int) $row->ID ) {\n                            $linked[ $target ] = true;\n                        }\n                    }\n                }\n            }\n\n            $orphans = array_values( array_diff( array_map( 'intval', $ids ), array_keys( $linked ), array( $front ) ) );\n            set_transient( 'wpide_orphans', $orphans, 12 * HOUR_IN_SECONDS );\n        }\n\n        echo '<div class=\"wrap\"><h1>Orphaned Content</h1><p>' . esc_html( sprintf( '%d published posts and pages have no internal links pointing to them.', count( $orphans ) ) ) . '</p>';\n        echo '<form method=\"post\">';\n        wp_nonce_field( 'wpide_orphans_refresh' );\n        echo '<p><button class=\"button\" name=\"wpide_orphans_refresh\" value=\"1\">Refresh</button></p></form>';\n\n        if ( $orphans ) {\n            echo '<table class=\"widefat striped\"><thead><tr><th>Title</th><th>Type</th><th>Published</th></tr></thead><tbody>';\n\n            foreach ( array_slice( $orphans, 0, 500 ) as $id ) {\n                printf( '<tr><td><a href=\"%1$s\">%2$s</a></td><td>%3$s</td><td>%4$s</td></tr>', esc_url( get_edit_post_link( $id ) ), esc_html( get_the_title( $id ) ), esc_html( get_post_type( $id ) ), esc_html( get_the_date( '', $id ) ) );\n            }\n\n            echo '</tbody></table>';\n        }\n\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Scans up to 3,000 published items and caches the result for 12 hours; use the Refresh button after adding links. Menus and widgets are not counted as links."
        },
        {
            "id": "seo-h1-warning-block-editor",
            "version": 1,
            "title": "Warn About H1 Headings in the Block Editor",
            "description": "Shows a warning in the block editor when a post contains an H1 heading block, since the post title is already the page's H1 and a second one muddles the outline.",
            "category": "seo",
            "tags": [
                "headings",
                "gutenberg",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-h1-warning', false, array( 'wp-data', 'wp-notices', 'wp-block-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-h1-warning' );\n    wp_add_inline_script( 'wpide-h1-warning', <<<'JS'\n( function ( wp ) {\n    var id = 'wpide-h1-warning', last = null, timer = null;\n    var hasH1 = function ( blocks ) {\n        return blocks.some( function ( block ) {\n            return ( block.name === 'core/heading' && block.attributes.level === 1 ) || ( block.innerBlocks && hasH1( block.innerBlocks ) );\n        } );\n    };\n    wp.data.subscribe( function () {\n        clearTimeout( timer );\n        timer = setTimeout( function () {\n            var editor = wp.data.select( 'core/block-editor' );\n            var found = editor ? hasH1( editor.getBlocks() ) : false;\n            if ( found === last ) {\n                return;\n            }\n            last = found;\n            if ( found ) {\n                wp.data.dispatch( 'core/notices' ).createWarningNotice( 'This post contains an H1 heading. The title is already the H1, so use H2 for main sections.', { id: id, isDismissible: true } );\n            } else {\n                wp.data.dispatch( 'core/notices' ).removeNotice( id );\n            }\n        }, 400 );\n    } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "The warning disappears as soon as the heading is changed to H2 or removed."
        },
        {
            "id": "seo-geo-meta-tags",
            "version": 1,
            "title": "Geo Meta Tags for Local Businesses",
            "description": "Adds geo.region, geo.placename, geo.position and ICBM meta tags with your business location to every page, a small extra local signal for some search engines and directories.",
            "category": "seo",
            "tags": [
                "local-seo",
                "meta",
                "geo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "// Your location.\n$wpide_geo = array(\n    'region'    => 'US-IL',\n    'placename' => 'Springfield',\n    'latitude'  => '39.7817',\n    'longitude' => '-89.6501',\n);\n\nprintf(\n    '<meta name=\"geo.region\" content=\"%1$s\">' . \"\\n\" . '<meta name=\"geo.placename\" content=\"%2$s\">' . \"\\n\" . '<meta name=\"geo.position\" content=\"%3$s;%4$s\">' . \"\\n\" . '<meta name=\"ICBM\" content=\"%3$s, %4$s\">' . \"\\n\",\n    esc_attr( $wpide_geo['region'] ),\n    esc_attr( $wpide_geo['placename'] ),\n    esc_attr( (float) $wpide_geo['latitude'] ),\n    esc_attr( (float) $wpide_geo['longitude'] )\n);",
            "requires": [],
            "notes": "Region uses the ISO 3166-2 code (country-state), e.g. US-IL or GB-LND. Find coordinates by right-clicking the spot in Google Maps."
        },
        {
            "id": "seo-last-modified-header",
            "version": 1,
            "title": "Last-Modified Header and 304 Responses for Posts",
            "description": "Sends a Last-Modified header with each post's update time and answers repeat visits from crawlers and browsers with \"304 Not Modified\", saving bandwidth and crawl budget.",
            "category": "seo",
            "tags": [
                "http-headers",
                "crawling",
                "caching"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_singular() || is_user_logged_in() || is_preview() || headers_sent() || post_password_required() ) {\n        return;\n    }\n\n    $post     = get_queried_object();\n    $modified = (int) get_post_modified_time( 'U', true, $post );\n\n    // A new approved comment also counts as a change.\n    if ( $post->comment_count > 0 ) {\n        $latest = get_comments( array( 'post_id' => $post->ID, 'status' => 'approve', 'number' => 1, 'orderby' => 'comment_date_gmt', 'order' => 'DESC' ) );\n\n        if ( $latest ) {\n            $modified = max( $modified, (int) strtotime( $latest[0]->comment_date_gmt . ' UTC' ) );\n        }\n    }\n\n    if ( ! $modified ) {\n        return;\n    }\n\n    header( 'Last-Modified: ' . gmdate( 'D, d M Y H:i:s', $modified ) . ' GMT' );\n\n    $since = isset( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ? strtotime( sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_MODIFIED_SINCE'] ) ) ) : false;\n\n    if ( $since && $since >= $modified ) {\n        status_header( 304 );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "Only for logged-out visitors on single posts and pages. Sidebar or menu changes do not update the date, so leave this off if those change often."
        },
        {
            "id": "seo-clean-slugs",
            "version": 1,
            "title": "Shorter Post Slugs Without Stop Words",
            "description": "Removes filler words such as \"the\", \"and\" or \"how\" from automatically created post slugs and caps their length, giving shorter, cleaner URLs.",
            "category": "seo",
            "tags": [
                "slugs",
                "permalinks",
                "urls"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Words removed from new slugs, and the maximum number of words kept.\n$wpide_stop_words = array( 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'how', 'in', 'into', 'is', 'it', 'of', 'on', 'or', 'our', 'the', 'this', 'that', 'to', 'what', 'when', 'why', 'with', 'you', 'your' );\n$wpide_slug_max_words = 6;\n\nadd_filter( 'wp_insert_post_data', function ( $data, $postarr ) use ( $wpide_stop_words, $wpide_slug_max_words ) {\n    if ( ! empty( $postarr['post_name'] ) || empty( $data['post_name'] ) || in_array( $data['post_status'], array( 'draft', 'auto-draft', 'pending', 'inherit', 'trash' ), true ) || 'attachment' === $data['post_type'] ) {\n        return $data;\n    }\n\n    $words = explode( '-', $data['post_name'] );\n    $kept  = array_values( array_diff( $words, $wpide_stop_words ) );\n\n    if ( count( $kept ) < 2 ) {\n        return $data;\n    }\n\n    $slug = implode( '-', array_slice( $kept, 0, $wpide_slug_max_words ) );\n\n    if ( $slug !== $data['post_name'] ) {\n        $data['post_name'] = wp_unique_post_slug( $slug, (int) ( $postarr['ID'] ?? 0 ), $data['post_status'], $data['post_type'], (int) $data['post_parent'] );\n    }\n\n    return $data;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only slugs WordPress creates from the title on first publish are changed; slugs you edit by hand are kept. Edit the word list for other languages."
        },
        {
            "id": "seo-rich-results-admin-bar",
            "version": 1,
            "title": "Test Structured Data From the Admin Bar",
            "description": "Adds a toolbar menu for editors on the front end that opens the current page in Google's Rich Results Test or the Schema.org validator.",
            "category": "seo",
            "tags": [
                "schema",
                "admin-bar",
                "testing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $url = home_url( add_query_arg( array() ) );\n\n    $bar->add_node( array( 'id' => 'wpide-schema-test', 'title' => 'Test schema', 'href' => false ) );\n    $bar->add_node( array( 'id' => 'wpide-rich-results', 'parent' => 'wpide-schema-test', 'title' => 'Google Rich Results Test', 'href' => 'https://search.google.com/test/rich-results?url=' . rawurlencode( $url ), 'meta' => array( 'target' => '_blank', 'rel' => 'noopener' ) ) );\n    $bar->add_node( array( 'id' => 'wpide-schema-validator', 'parent' => 'wpide-schema-test', 'title' => 'Schema.org Validator', 'href' => 'https://validator.schema.org/#url=' . rawurlencode( $url ), 'meta' => array( 'target' => '_blank', 'rel' => 'noopener' ) ) );\n}, 90 );",
            "requires": [],
            "notes": "The page must be publicly reachable for the testing tools to fetch it."
        },
        {
            "id": "seo-mid-article-related-link",
            "version": 1,
            "title": "Related Post Link in the Middle of Articles",
            "description": "Places a \"Related\" link to another post from the same category halfway through each blog post, a well-placed internal link that keeps readers on the site.",
            "category": "seo",
            "tags": [
                "internal-links",
                "related",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "content_half",
            "insert_number": 1,
            "code": "$wpide_mid_post_id = get_the_ID();\n\nif ( ! $wpide_mid_post_id || 'post' !== get_post_type( $wpide_mid_post_id ) ) {\n    return;\n}\n\n$wpide_mid_related = get_transient( 'wpide_mid_related_' . $wpide_mid_post_id );\n\nif ( false === $wpide_mid_related ) {\n    $wpide_mid_ids = get_posts( array(\n        'category__in'        => wp_get_post_categories( $wpide_mid_post_id ),\n        'post__not_in'        => array( $wpide_mid_post_id ),\n        'posts_per_page'      => 1,\n        'ignore_sticky_posts' => true,\n        'fields'              => 'ids',\n        'no_found_rows'       => true,\n    ) );\n    $wpide_mid_related = $wpide_mid_ids ? (int) $wpide_mid_ids[0] : 0;\n    set_transient( 'wpide_mid_related_' . $wpide_mid_post_id, $wpide_mid_related, WEEK_IN_SECONDS );\n}\n\nif ( $wpide_mid_related && 'publish' === get_post_status( $wpide_mid_related ) ) {\n    printf(\n        '<aside class=\"mid-article-related\" style=\"margin:1.5em 0;padding:.75em 1em;border-left:4px solid currentColor\"><strong>Related:</strong> <a href=\"%1$s\">%2$s</a></aside>',\n        esc_url( get_permalink( $wpide_mid_related ) ),\n        esc_html( get_the_title( $wpide_mid_related ) )\n    );\n}",
            "requires": [],
            "notes": "Inserted at the middle of the content by the snippet location. The related post is chosen once per post and cached for a week."
        },
        {
            "id": "dev-rest-filter-by-meta",
            "version": 1,
            "title": "Filter REST API Results by Allowed Custom Fields",
            "description": "Lets REST API requests filter posts by custom fields you allow, e.g. /wp-json/wp/v2/posts?meta_key=location&meta_value=paris, for apps and headless front ends.",
            "category": "developer",
            "tags": [
                "rest-api",
                "custom-fields",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type => meta keys that may be used as REST filters.\n$wpide_rest_meta_filters = array(\n    'post' => array( 'location', 'featured' ),\n);\n\nforeach ( $wpide_rest_meta_filters as $wpide_rest_type => $wpide_rest_keys ) {\n    add_filter( \"rest_{$wpide_rest_type}_query\", function ( $args, $request ) use ( $wpide_rest_keys ) {\n        $key   = $request->get_param( 'meta_key' );\n        $value = $request->get_param( 'meta_value' );\n\n        if ( is_string( $key ) && in_array( $key, $wpide_rest_keys, true ) && is_scalar( $value ) && '' !== (string) $value ) {\n            $args['meta_query']   = isset( $args['meta_query'] ) ? (array) $args['meta_query'] : array();\n            $args['meta_query'][] = array( 'key' => $key, 'value' => sanitize_text_field( (string) $value ) );\n        }\n\n        return $args;\n    }, 10, 2 );\n\n    add_filter( \"rest_{$wpide_rest_type}_collection_params\", function ( $params ) use ( $wpide_rest_keys ) {\n        $params['meta_key']   = array( 'description' => 'Custom field to filter by.', 'type' => 'string', 'enum' => array_values( $wpide_rest_keys ) );\n        $params['meta_value'] = array( 'description' => 'Value the custom field must have.', 'type' => 'string' );\n\n        return $params;\n    } );\n}",
            "requires": [],
            "notes": "Only the listed keys can be used, so private fields stay unqueryable. The parameters also appear in the endpoint's schema."
        },
        {
            "id": "dev-auto-flush-rewrite-rules",
            "version": 1,
            "title": "Refresh Permalinks Automatically When Post Types Change",
            "description": "Rebuilds the rewrite rules once whenever a post type or taxonomy is added, removed or gets a new slug, so new URLs work without visiting Settings > Permalinks.",
            "category": "developer",
            "tags": [
                "rewrite-rules",
                "permalinks",
                "custom-post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_loaded', function () {\n    $fingerprint = array();\n\n    foreach ( get_post_types( array(), 'objects' ) as $type ) {\n        if ( $type->rewrite ) {\n            $fingerprint[] = $type->name . ':' . ( $type->rewrite['slug'] ?? '' ) . ':' . (string) $type->has_archive;\n        }\n    }\n\n    foreach ( get_taxonomies( array(), 'objects' ) as $taxonomy ) {\n        if ( $taxonomy->rewrite ) {\n            $fingerprint[] = $taxonomy->name . ':' . ( $taxonomy->rewrite['slug'] ?? '' );\n        }\n    }\n\n    sort( $fingerprint );\n    $hash = md5( implode( '|', $fingerprint ) );\n\n    if ( get_option( 'wpide_rewrite_fingerprint' ) !== $hash ) {\n        flush_rewrite_rules( false );\n        update_option( 'wpide_rewrite_fingerprint', $hash, true );\n    }\n}, 99 );",
            "requires": [],
            "notes": "Compares a short fingerprint on each request, which costs almost nothing; the actual rebuild only happens after a change."
        },
        {
            "id": "dev-rewrite-rules-viewer",
            "version": 1,
            "title": "Rewrite Rules Viewer and URL Tester",
            "description": "Adds Tools > Rewrite Rules, listing every rewrite rule and showing which rule and query a URL path you enter would match, for debugging 404s and custom permalinks.",
            "category": "developer",
            "tags": [
                "rewrite-rules",
                "debugging",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Rewrite Rules', 'Rewrite Rules', 'manage_options', 'wpide-rewrite-rules', function () {\n        $rules = (array) get_option( 'rewrite_rules' );\n        $test  = isset( $_GET['wpide_path'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_path'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification -- read-only.\n        $path  = trim( (string) wp_parse_url( $test, PHP_URL_PATH ), '/' );\n\n        echo '<div class=\"wrap\"><h1>Rewrite Rules</h1>';\n\n        if ( ! get_option( 'permalink_structure' ) ) {\n            echo '<div class=\"notice notice-warning\"><p>Plain permalinks are active, so WordPress uses no rewrite rules.</p></div>';\n        }\n\n        echo '<form method=\"get\"><input type=\"hidden\" name=\"page\" value=\"wpide-rewrite-rules\"><label for=\"wpide-path\"><strong>Test a path</strong></label> <input type=\"text\" id=\"wpide-path\" name=\"wpide_path\" class=\"regular-text\" value=\"' . esc_attr( $test ) . '\" placeholder=\"/category/news/page/2/\"> <button class=\"button\">Test</button></form>';\n\n        $matched = '';\n\n        if ( '' !== $test ) {\n            foreach ( $rules as $regex => $query ) {\n                if ( preg_match( '#^' . str_replace( '#', '\\#', $regex ) . '#', $path, $m ) || preg_match( '#^' . str_replace( '#', '\\#', $regex ) . '#', urldecode( $path ), $m ) ) {\n                    $matched  = $regex;\n                    $resolved = preg_replace_callback( '/\\$matches\\[(\\d+)\\]/', function ( $x ) use ( $m ) {\n                        return $m[ (int) $x[1] ] ?? '';\n                    }, $query );\n                    echo '<p>Matches <code>' . esc_html( $regex ) . '</code> → <code>' . esc_html( $resolved ) . '</code></p>';\n                    break;\n                }\n            }\n\n            if ( '' === $matched ) {\n                echo '<p>No rule matches this path, so it would return a 404 unless a plugin handles it.</p>';\n            }\n        }\n\n        echo '<p>' . esc_html( count( $rules ) ) . ' rules, in the order WordPress checks them.</p><table class=\"widefat striped\"><thead><tr><th>Rule</th><th>Query</th></tr></thead><tbody>';\n\n        foreach ( $rules as $regex => $query ) {\n            printf( '<tr%1$s><td><code>%2$s</code></td><td><code>%3$s</code></td></tr>', $regex === $matched ? ' style=\"outline:2px solid #2271b1\"' : '', esc_html( $regex ), esc_html( $query ) );\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Read-only: it never changes rules. Enter a path such as /events/summer-fair/."
        },
        {
            "id": "dev-content-types-overview",
            "version": 1,
            "title": "Content Types Overview Screen",
            "description": "Adds Tools > Content Types, a table of every registered post type and taxonomy with its labels, visibility, archive, REST base, URL slug and item count.",
            "category": "developer",
            "tags": [
                "custom-post-types",
                "taxonomies",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Content Types', 'Content Types', 'manage_options', 'wpide-content-types', function () {\n        $yes = function ( $value ) {\n            return $value ? '✓' : '—';\n        };\n\n        echo '<div class=\"wrap\"><h1>Content Types</h1><h2>Post types</h2><table class=\"widefat striped\"><thead><tr><th>Name</th><th>Label</th><th>Public</th><th>Archive</th><th>REST base</th><th>URL slug</th><th>Published</th></tr></thead><tbody>';\n\n        foreach ( get_post_types( array(), 'objects' ) as $type ) {\n            $count = wp_count_posts( $type->name );\n            printf(\n                '<tr><td><code>%1$s</code></td><td>%2$s</td><td>%3$s</td><td>%4$s</td><td>%5$s</td><td>%6$s</td><td>%7$s</td></tr>',\n                esc_html( $type->name ),\n                esc_html( $type->label ),\n                esc_html( $yes( $type->public ) ),\n                esc_html( $type->has_archive ? ( is_string( $type->has_archive ) ? $type->has_archive : '✓' ) : '—' ),\n                esc_html( $type->show_in_rest ? ( $type->rest_base ? $type->rest_base : $type->name ) : '—' ),\n                esc_html( is_array( $type->rewrite ) ? ( $type->rewrite['slug'] ?? '' ) : '—' ),\n                esc_html( number_format_i18n( isset( $count->publish ) ? (int) $count->publish : 0 ) )\n            );\n        }\n\n        echo '</tbody></table><h2>Taxonomies</h2><table class=\"widefat striped\"><thead><tr><th>Name</th><th>Label</th><th>Post types</th><th>Hierarchical</th><th>Public</th><th>REST base</th><th>URL slug</th></tr></thead><tbody>';\n\n        foreach ( get_taxonomies( array(), 'objects' ) as $taxonomy ) {\n            printf(\n                '<tr><td><code>%1$s</code></td><td>%2$s</td><td>%3$s</td><td>%4$s</td><td>%5$s</td><td>%6$s</td><td>%7$s</td></tr>',\n                esc_html( $taxonomy->name ),\n                esc_html( $taxonomy->label ),\n                esc_html( implode( ', ', (array) $taxonomy->object_type ) ),\n                esc_html( $yes( $taxonomy->hierarchical ) ),\n                esc_html( $yes( $taxonomy->public ) ),\n                esc_html( $taxonomy->show_in_rest ? ( $taxonomy->rest_base ? $taxonomy->rest_base : $taxonomy->name ) : '—' ),\n                esc_html( is_array( $taxonomy->rewrite ) ? ( $taxonomy->rewrite['slug'] ?? '' ) : '—' )\n            );\n        }\n\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Handy for finding the internal names other snippets ask for."
        },
        {
            "id": "testimonials-cards-scss",
            "version": 1,
            "title": "Testimonial Card Styles (SCSS)",
            "description": "Styles the [testimonials] grid as soft cards with a large quote mark, gold stars and a clear attribution line that adapt to light and dark themes.",
            "category": "post-types",
            "tags": [
                "scss",
                "testimonials",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #f5a524;\n$radius: 12px;\n\n.testimonials-grid {\n    .testimonial {\n        position: relative;\n        margin: 0;\n        padding: 1.75rem 1.5rem 1.25rem;\n        border-radius: $radius;\n        background: color-mix(in srgb, currentColor 5%, transparent);\n\n        &::before {\n            content: \"\\201C\";\n            position: absolute;\n            top: 0.25rem;\n            right: 1rem;\n            font-size: 4rem;\n            line-height: 1;\n            opacity: 0.15;\n        }\n    }\n\n    blockquote {\n        margin: 0 0 1rem;\n        padding: 0;\n        border: 0;\n        font-style: italic;\n\n        p:last-child {\n            margin-bottom: 0;\n        }\n    }\n\n    .testimonial-rating {\n        display: block;\n        margin-bottom: 0.5rem;\n        color: $accent;\n        letter-spacing: 0.1em;\n    }\n\n    figcaption {\n        font-size: 0.9em;\n\n        span {\n            opacity: 0.75;\n        }\n    }\n}",
            "requires": [],
            "notes": "Change $accent and $radius at the top. Pairs with the Testimonials Grid Shortcode snippet."
        },
        {
            "id": "team-grid-scss",
            "version": 1,
            "title": "Team Grid Card Styles (SCSS)",
            "description": "Gives the [team] grid rounded photos, a gentle hover lift and tidy name and job title typography, with motion turned off for visitors who prefer less.",
            "category": "post-types",
            "tags": [
                "scss",
                "team",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$photo-radius: 12px;\n$lift: -4px;\n\n.team-grid {\n    .team-member {\n        text-align: center;\n        transition: transform 0.2s ease;\n\n        img {\n            border-radius: $photo-radius;\n        }\n\n        &:hover,\n        &:focus-within {\n            transform: translateY($lift);\n        }\n    }\n\n    .team-name {\n        font-size: 1.1em;\n\n        a {\n            color: inherit;\n            text-decoration: none;\n\n            &:hover,\n            &:focus {\n                text-decoration: underline;\n            }\n        }\n    }\n\n    .team-role {\n        font-size: 0.9em;\n        opacity: 0.75;\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .team-grid .team-member {\n        transition: none;\n\n        &:hover,\n        &:focus-within {\n            transform: none;\n        }\n    }\n}",
            "requires": [],
            "notes": "Set $photo-radius to 50% for circular photos."
        },
        {
            "id": "faq-accordion-scss",
            "version": 1,
            "title": "FAQ Accordion Styles (SCSS)",
            "description": "Styles collapsible FAQ items (from the FAQ list and ACF FAQ shortcodes, or any Details block) with dividers, a plus/minus marker and a visible keyboard focus ring.",
            "category": "post-types",
            "tags": [
                "scss",
                "faq",
                "accordion"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$divider: color-mix(in srgb, currentColor 15%, transparent);\n$focus: #2271b1;\n\n.faq-item,\n.acf-faq-item,\n.wp-block-details {\n    border-bottom: 1px solid $divider;\n\n    summary {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n        gap: 1rem;\n        padding: 1rem 0;\n        font-weight: 600;\n        cursor: pointer;\n        list-style: none;\n\n        &::-webkit-details-marker {\n            display: none;\n        }\n\n        &::after {\n            content: \"+\";\n            flex-shrink: 0;\n            font-size: 1.4em;\n            line-height: 1;\n        }\n\n        &:focus-visible {\n            outline: 2px solid $focus;\n            outline-offset: 2px;\n        }\n    }\n\n    &[open] summary::after {\n        content: \"\\2212\";\n    }\n\n    > :not(summary) {\n        padding-bottom: 1rem;\n    }\n}",
            "requires": [],
            "notes": "Targets .faq-item, .acf-faq-item and the Details block."
        },
        {
            "id": "event-details-scss",
            "version": 1,
            "title": "Event Details and Upcoming Events Styles (SCSS)",
            "description": "Lays out the [event_details] facts as a neat two-column list and the [upcoming_events] list with bold dates, collapsing to one column on phones.",
            "category": "post-types",
            "tags": [
                "scss",
                "events",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$label-width: 7rem;\n$divider: color-mix(in srgb, currentColor 12%, transparent);\n\n.event-details {\n    display: grid;\n    grid-template-columns: $label-width 1fr;\n    gap: 0.5rem 1rem;\n    margin: 1.5rem 0;\n    padding: 1rem 1.25rem;\n    border: 1px solid $divider;\n    border-radius: 8px;\n\n    dt {\n        font-weight: 600;\n    }\n\n    dd {\n        margin: 0;\n    }\n\n    @media (max-width: 480px) {\n        grid-template-columns: 1fr;\n\n        dd {\n            margin-bottom: 0.5rem;\n        }\n    }\n}\n\n.upcoming-events {\n    list-style: none;\n    padding: 0;\n\n    li {\n        padding: 0.6rem 0;\n        border-bottom: 1px solid $divider;\n    }\n\n    time {\n        font-weight: 600;\n    }\n}",
            "requires": [],
            "notes": "Pairs with the Event Details meta box and Upcoming Events shortcode snippets."
        },
        {
            "id": "recipe-card-scss",
            "version": 1,
            "title": "Recipe Card Styles (SCSS)",
            "description": "Turns the [recipe_card] output into a printable card with a fact strip for times and servings, a two-column ingredient list on wide screens and numbered steps.",
            "category": "post-types",
            "tags": [
                "scss",
                "recipes",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$card-bg: color-mix(in srgb, currentColor 4%, transparent);\n$accent: #c2410c;\n\n.recipe-card {\n    margin: 2rem 0;\n    padding: 1.5rem;\n    border-radius: 12px;\n    background: $card-bg;\n\n    .recipe-facts {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.5rem 1.5rem;\n        margin: 0 0 1rem;\n        padding: 0;\n        list-style: none;\n    }\n\n    h3 {\n        margin-top: 1.25rem;\n        color: $accent;\n    }\n\n    .recipe-ingredients {\n        columns: 2 16rem;\n        column-gap: 2rem;\n    }\n\n    .recipe-steps li {\n        margin-bottom: 0.75rem;\n    }\n}\n\n@media print {\n    .recipe-card {\n        background: none;\n        padding: 0;\n    }\n}",
            "requires": [],
            "notes": "Pairs with the Recipe Details meta box snippet; when printed, the card drops its background to save ink."
        },
        {
            "id": "az-index-scss",
            "version": 1,
            "title": "A–Z Index Styles (SCSS)",
            "description": "Styles the [az_index] letter bar as a row of buttons that stays visible while scrolling, and lays out each letter's entries in columns.",
            "category": "post-types",
            "tags": [
                "scss",
                "glossary",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$sticky-top: 0;\n$chip-bg: color-mix(in srgb, currentColor 8%, transparent);\n\n.az-index-nav {\n    position: sticky;\n    top: $sticky-top;\n    z-index: 5;\n    padding: 0.5rem 0;\n    background: var(--wp--preset--color--base, Canvas);\n\n    a {\n        min-width: 2rem;\n        border-radius: 4px;\n        background: $chip-bg;\n        text-align: center;\n        text-decoration: none;\n    }\n}\n\n.az-index {\n    section {\n        scroll-margin-top: 4rem;\n    }\n\n    ul {\n        columns: 3 12rem;\n        padding-left: 1.2em;\n    }\n}\n\nhtml {\n    scroll-behavior: smooth;\n}\n\n@media (prefers-reduced-motion: reduce) {\n    html {\n        scroll-behavior: auto;\n    }\n}",
            "requires": [],
            "notes": "Set $sticky-top to the height of a fixed header if your theme has one."
        },
        {
            "id": "post-grid-cards-scss",
            "version": 1,
            "title": "Card Layout for the Post Grid Shortcode (SCSS)",
            "description": "Styles the [post_grid] shortcode output as clean cards with rounded images, a subtle shadow on hover and clamped excerpts, respecting reduced-motion settings.",
            "category": "post-types",
            "tags": [
                "scss",
                "grid",
                "cards"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$radius: 10px;\n$excerpt-lines: 3;\n\n.post-grid {\n    .post-grid-card {\n        overflow: hidden;\n        border-radius: $radius;\n        background: color-mix(in srgb, currentColor 4%, transparent);\n        transition: box-shadow 0.2s ease;\n\n        &:hover,\n        &:focus-within {\n            box-shadow: 0 6px 20px rgb(0 0 0 / 12%);\n        }\n\n        > :not(a) {\n            margin-left: 1rem;\n            margin-right: 1rem;\n        }\n    }\n\n    .post-grid-title {\n        margin-top: 0.75rem;\n        font-size: 1.15em;\n\n        a {\n            color: inherit;\n            text-decoration: none;\n        }\n    }\n\n    .post-grid-excerpt {\n        display: -webkit-box;\n        overflow: hidden;\n        -webkit-box-orient: vertical;\n        -webkit-line-clamp: $excerpt-lines;\n        line-clamp: $excerpt-lines;\n        font-size: 0.95em;\n    }\n}\n\n@media (prefers-reduced-motion: reduce) {\n    .post-grid .post-grid-card {\n        transition: none;\n    }\n}",
            "requires": [],
            "notes": "Change $radius and $excerpt-lines to taste."
        },
        {
            "id": "cf7-honeypot-field",
            "version": 1,
            "title": "Honeypot Trap for Contact Form 7",
            "description": "Adds a hidden field to every Contact Form 7 form that people never see but spam bots fill in. Submissions with the field filled are marked as spam and never emailed.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "honeypot"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_trap_name = 'wpide_company_url'; // Name of the hidden trap field.\n\nadd_filter( 'wpcf7_form_elements', function ( $html ) use ( $wpide_cf7_trap_name ) {\n    $field = sprintf(\n        '<span class=\"wpide-cf7-trap\" aria-hidden=\"true\" style=\"position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden;\"><label>Leave this field empty <input type=\"text\" name=\"%1$s\" value=\"\" tabindex=\"-1\" autocomplete=\"off\"></label></span>',\n        esc_attr( $wpide_cf7_trap_name )\n    );\n\n    return $field . $html;\n} );\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_trap_name ) {\n    if ( $spam || empty( $_POST[ $wpide_cf7_trap_name ] ) ) {\n        return $spam;\n    }\n\n    if ( $submission && method_exists( $submission, 'add_spam_log' ) ) {\n        $submission->add_spam_log( array( 'agent' => 'wpide-honeypot', 'reason' => 'The hidden trap field was filled in.' ) );\n    }\n\n    return true;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Change the field name if a bot learns it; keep it something a bot would want to fill, such as a website or company field."
        },
        {
            "id": "cf7-time-trap",
            "version": 1,
            "title": "Reject Contact Form 7 Submissions Sent Too Fast",
            "description": "Stamps each Contact Form 7 form with a signed time when it is shown and treats submissions sent within a few seconds as spam. People take longer than bots to fill in a form.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "time-trap"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_min_seconds = 4; // Minimum time between showing the form and submitting it.\n\nadd_filter( 'wpcf7_form_hidden_fields', function ( $fields ) {\n    $time = (string) time();\n    $fields['wpide_cf7_ts'] = $time . '.' . wp_hash( 'cf7-ts-' . $time );\n\n    return $fields;\n} );\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_min_seconds ) {\n    if ( $spam ) {\n        return $spam;\n    }\n\n    $stamp = isset( $_POST['wpide_cf7_ts'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_cf7_ts'] ) ) : '';\n    $parts = explode( '.', $stamp, 2 );\n    $valid = 2 === count( $parts ) && ctype_digit( $parts[0] ) && hash_equals( wp_hash( 'cf7-ts-' . $parts[0] ), $parts[1] );\n\n    if ( $valid && time() - (int) $parts[0] >= $wpide_cf7_min_seconds ) {\n        return false;\n    }\n\n    if ( $submission && method_exists( $submission, 'add_spam_log' ) ) {\n        $submission->add_spam_log( array( 'agent' => 'wpide-time-trap', 'reason' => $valid ? 'Submitted too quickly.' : 'Missing or forged time stamp.' ) );\n    }\n\n    return true;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Works with page caching because older stamps always pass; raise the seconds for long forms if you see false positives in the spam log."
        },
        {
            "id": "cf7-blocked-words",
            "version": 1,
            "title": "Block Contact Form 7 Messages Containing Spam Words",
            "description": "Marks Contact Form 7 submissions as spam when any field contains a word or phrase from your block list, such as SEO offers or crypto pitches.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Words and phrases that mark a submission as spam.\n$wpide_cf7_blocked_words = array( 'seo services', 'backlinks', 'crypto', 'casino', 'guest post', 'rank your website', 'viagra' );\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_blocked_words ) {\n    if ( $spam || ! $submission || ! $wpide_cf7_blocked_words ) {\n        return $spam;\n    }\n\n    $text = '';\n    foreach ( (array) $submission->get_posted_data() as $value ) {\n        $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n    }\n\n    foreach ( $wpide_cf7_blocked_words as $word ) {\n        if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $text ) ) {\n            $submission->add_spam_log( array( 'agent' => 'wpide-blocked-words', 'reason' => sprintf( 'Contains \"%s\".', $word ) ) );\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Matching is case-insensitive on whole words; add phrases you keep receiving to the list."
        },
        {
            "id": "cf7-block-email-domains",
            "version": 1,
            "title": "Block Email Domains in Contact Form 7",
            "description": "Rejects Contact Form 7 submissions whose email address belongs to a domain on your block list, with a clear message under the email field.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Domains that may not be used in email fields.\n$wpide_cf7_blocked_domains = array( 'mailinator.com', 'yopmail.com', 'tempmail.com', 'guerrillamail.com' );\n\n$wpide_cf7_check_domain = function ( $result, $tag ) use ( $wpide_cf7_blocked_domains ) {\n    $email  = isset( $_POST[ $tag->name ] ) ? strtolower( trim( sanitize_email( wp_unslash( $_POST[ $tag->name ] ) ) ) ) : '';\n    $domain = substr( strrchr( $email, '@' ), 1 );\n\n    if ( '' === (string) $domain ) {\n        return $result;\n    }\n\n    foreach ( $wpide_cf7_blocked_domains as $blocked ) {\n        if ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) {\n            $result->invalidate( $tag, 'Please use a different email address.' );\n            break;\n        }\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_email', $wpide_cf7_check_domain, 20, 2 );\nadd_filter( 'wpcf7_validate_email*', $wpide_cf7_check_domain, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "List bare domains; subdomains of a listed domain are blocked too."
        },
        {
            "id": "cf7-business-email-only",
            "version": 1,
            "title": "Require a Business Email in Contact Form 7",
            "description": "Asks visitors to use their work email instead of a free webmail address such as Gmail or Yahoo, which helps B2B sites get qualified leads.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_free_providers = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'proton.me', 'protonmail.com', 'mail.com', 'yandex.com' );\n$wpide_cf7_business_fields = array(); // Field names to check, e.g. array( 'work-email' ). Empty = all email fields.\n\n$wpide_cf7_business_check = function ( $result, $tag ) use ( $wpide_cf7_free_providers, $wpide_cf7_business_fields ) {\n    if ( $wpide_cf7_business_fields && ! in_array( $tag->name, $wpide_cf7_business_fields, true ) ) {\n        return $result;\n    }\n\n    $email  = isset( $_POST[ $tag->name ] ) ? strtolower( sanitize_email( wp_unslash( $_POST[ $tag->name ] ) ) ) : '';\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_cf7_free_providers, true ) ) {\n        $result->invalidate( $tag, 'Please use your work email address.' );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_email', $wpide_cf7_business_check, 20, 2 );\nadd_filter( 'wpcf7_validate_email*', $wpide_cf7_business_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Limit it to certain fields by adding their names to the list at the top; leave the list empty to check every email field."
        },
        {
            "id": "cf7-no-links-in-message",
            "version": 1,
            "title": "Block Links in Contact Form 7 Messages",
            "description": "Shows an error when a message field contains more web links than you allow. Most contact form spam exists to place links.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_max_links = 0; // Links allowed in a textarea field.\n\n$wpide_cf7_link_check = function ( $result, $tag ) use ( $wpide_cf7_max_links ) {\n    $text = isset( $_POST[ $tag->name ] ) ? (string) wp_unslash( $_POST[ $tag->name ] ) : '';\n\n    if ( '' === $text ) {\n        return $result;\n    }\n\n    $links = preg_match_all( '#(https?://|www\\.|\\[url|<a\\s)#i', $text );\n\n    if ( $links > $wpide_cf7_max_links ) {\n        $result->invalidate( $tag, 0 === $wpide_cf7_max_links ? 'Please remove the links from your message.' : sprintf( 'Please include no more than %d link(s).', $wpide_cf7_max_links ) );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_textarea', $wpide_cf7_link_check, 20, 2 );\nadd_filter( 'wpcf7_validate_textarea*', $wpide_cf7_link_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Set the allowed number of links to 1 or 2 if customers often send you a link to their site."
        },
        {
            "id": "cf7-non-latin-spam",
            "version": 1,
            "title": "Treat Contact Form 7 Messages in Foreign Scripts as Spam",
            "description": "Marks a Contact Form 7 submission as spam when most of its message is written in Cyrillic, Chinese or another script your business does not use.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_blocked_scripts = array( 'Cyrillic', 'Han', 'Arabic', 'Hangul', 'Thai' ); // Unicode script names.\n$wpide_cf7_script_share    = 0.3; // Share of letters in those scripts that marks a message as spam.\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_blocked_scripts, $wpide_cf7_script_share ) {\n    if ( $spam || ! $submission ) {\n        return $spam;\n    }\n\n    $text = '';\n    foreach ( (array) $submission->get_posted_data() as $value ) {\n        $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n    }\n\n    $letters = preg_match_all( '/\\p{L}/u', $text );\n    if ( $letters < 10 ) {\n        return false;\n    }\n\n    $foreign = 0;\n    foreach ( $wpide_cf7_blocked_scripts as $script ) {\n        $foreign += (int) preg_match_all( '/\\p{' . $script . '}/u', $text );\n    }\n\n    if ( $foreign / $letters >= $wpide_cf7_script_share ) {\n        $submission->add_spam_log( array( 'agent' => 'wpide-script-filter', 'reason' => 'Message is mostly in a blocked script.' ) );\n        return true;\n    }\n\n    return false;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Remove a script from the list if you do get genuine enquiries in that language."
        },
        {
            "id": "cf7-phone-validation",
            "version": 1,
            "title": "Validate Phone Numbers in Contact Form 7",
            "description": "Checks Contact Form 7 phone fields for a realistic number of digits and only the characters phone numbers use, instead of accepting any text.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_phone_min_digits = 7;\n$wpide_cf7_phone_max_digits = 15;\n\n$wpide_cf7_phone_check = function ( $result, $tag ) use ( $wpide_cf7_phone_min_digits, $wpide_cf7_phone_max_digits ) {\n    $phone = isset( $_POST[ $tag->name ] ) ? trim( (string) wp_unslash( $_POST[ $tag->name ] ) ) : '';\n\n    if ( '' === $phone ) {\n        return $result;\n    }\n\n    $digits = strlen( preg_replace( '/\\D/', '', $phone ) );\n\n    if ( ! preg_match( '/^\\+?[0-9\\s().\\-]+$/', $phone ) || $digits < $wpide_cf7_phone_min_digits || $digits > $wpide_cf7_phone_max_digits ) {\n        $result->invalidate( $tag, 'Please enter a valid phone number.' );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_tel', $wpide_cf7_phone_check, 20, 2 );\nadd_filter( 'wpcf7_validate_tel*', $wpide_cf7_phone_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Adjust the minimum and maximum digit counts for your country; spaces, dashes, dots, brackets and a leading + are always allowed."
        },
        {
            "id": "cf7-confirm-email-match",
            "version": 1,
            "title": "\"Confirm Email\" Field for Contact Form 7",
            "description": "Makes visitors type their email twice and shows an error when the two addresses do not match, so you do not lose replies to typos.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_email_field   = 'your-email';\n$wpide_cf7_confirm_field = 'your-email-confirm';\n\n$wpide_cf7_confirm_check = function ( $result, $tag ) use ( $wpide_cf7_email_field, $wpide_cf7_confirm_field ) {\n    if ( $tag->name !== $wpide_cf7_confirm_field ) {\n        return $result;\n    }\n\n    $email   = isset( $_POST[ $wpide_cf7_email_field ] ) ? strtolower( trim( (string) wp_unslash( $_POST[ $wpide_cf7_email_field ] ) ) ) : '';\n    $confirm = isset( $_POST[ $wpide_cf7_confirm_field ] ) ? strtolower( trim( (string) wp_unslash( $_POST[ $wpide_cf7_confirm_field ] ) ) ) : '';\n\n    if ( $email !== $confirm ) {\n        $result->invalidate( $tag, 'The email addresses do not match.' );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_email', $wpide_cf7_confirm_check, 20, 2 );\nadd_filter( 'wpcf7_validate_email*', $wpide_cf7_confirm_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add a second email field named your-email-confirm to the form, for example [email* your-email-confirm], or change the names at the top."
        },
        {
            "id": "cf7-message-min-length",
            "version": 1,
            "title": "Minimum Message Length in Contact Form 7",
            "description": "Requires message fields to contain a few real words, which stops one-word and empty-looking submissions and gives you enough detail to reply.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "textarea"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_min_words = 5; // Minimum number of words in textarea fields.\n\n$wpide_cf7_words_check = function ( $result, $tag ) use ( $wpide_cf7_min_words ) {\n    $text = isset( $_POST[ $tag->name ] ) ? trim( wp_strip_all_tags( (string) wp_unslash( $_POST[ $tag->name ] ) ) ) : '';\n\n    if ( '' === $text && ! $tag->is_required() ) {\n        return $result;\n    }\n\n    $words = preg_match_all( '/[\\p{L}\\p{N}\\']+/u', $text );\n\n    if ( $words < $wpide_cf7_min_words ) {\n        $result->invalidate( $tag, sprintf( 'Please write at least %d words so we can help you.', $wpide_cf7_min_words ) );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_textarea', $wpide_cf7_words_check, 20, 2 );\nadd_filter( 'wpcf7_validate_textarea*', $wpide_cf7_words_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "CF7 also has a minlength: option per tag; this snippet sets a site-wide minimum in words instead of characters."
        },
        {
            "id": "cf7-pattern-validation-rules",
            "version": 1,
            "title": "Custom Pattern Rules for Contact Form 7 Fields",
            "description": "Validates chosen Contact Form 7 fields against your own patterns, such as postcodes, order numbers or membership IDs, each with its own error message.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "validation",
                "regex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field name => array( pattern, error message ).\n$wpide_cf7_patterns = array(\n    'postcode'     => array( '/^[A-Za-z0-9 \\-]{3,10}$/', 'Please enter a valid postcode.' ),\n    'order-number' => array( '/^#?\\d{4,10}$/', 'Order numbers contain 4 to 10 digits.' ),\n);\n\n$wpide_cf7_pattern_check = function ( $result, $tag ) use ( $wpide_cf7_patterns ) {\n    if ( ! isset( $wpide_cf7_patterns[ $tag->name ] ) ) {\n        return $result;\n    }\n\n    $value = isset( $_POST[ $tag->name ] ) ? trim( (string) wp_unslash( $_POST[ $tag->name ] ) ) : '';\n\n    if ( '' !== $value && ! preg_match( $wpide_cf7_patterns[ $tag->name ][0], $value ) ) {\n        $result->invalidate( $tag, $wpide_cf7_patterns[ $tag->name ][1] );\n    }\n\n    return $result;\n};\n\nadd_filter( 'wpcf7_validate_text', $wpide_cf7_pattern_check, 20, 2 );\nadd_filter( 'wpcf7_validate_text*', $wpide_cf7_pattern_check, 20, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Keys are field names from your form; patterns are PHP regular expressions matched against the whole value."
        },
        {
            "id": "cf7-rate-limit-ip",
            "version": 1,
            "title": "Limit Contact Form 7 Submissions per Visitor",
            "description": "Allows only a few Contact Form 7 submissions per IP address each hour and treats the rest as spam, which stops bots and angry repeat senders flooding your inbox.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_max_per_hour = 3; // Submissions allowed per IP address per hour.\n\n$wpide_cf7_rate_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    return 'wpide_cf7_rate_' . md5( wp_salt() . $ip );\n};\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_max_per_hour, $wpide_cf7_rate_key ) {\n    if ( $spam ) {\n        return $spam;\n    }\n\n    if ( (int) get_transient( $wpide_cf7_rate_key() ) >= $wpide_cf7_max_per_hour ) {\n        if ( $submission && method_exists( $submission, 'add_spam_log' ) ) {\n            $submission->add_spam_log( array( 'agent' => 'wpide-rate-limit', 'reason' => 'Too many submissions from this IP address.' ) );\n        }\n        return true;\n    }\n\n    return false;\n}, 20, 2 );\n\nadd_action( 'wpcf7_mail_sent', function () use ( $wpide_cf7_rate_key ) {\n    $key = $wpide_cf7_rate_key();\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "IP addresses are stored only as hashes in short-lived transients; raise the limit for forms used from shared offices."
        },
        {
            "id": "cf7-prefill-logged-in-user",
            "version": 1,
            "title": "Prefill Contact Form 7 With the Logged-In User's Details",
            "description": "Fills the name and email fields of Contact Form 7 forms with the details of the logged-in visitor, so members do not have to type them again.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "prefill",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Form field name => user detail (display_name, user_email, first_name, last_name).\n$wpide_cf7_user_map = array(\n    'your-name'  => 'display_name',\n    'your-email' => 'user_email',\n);\n\nadd_filter( 'wpcf7_form_tag', function ( $tag ) use ( $wpide_cf7_user_map ) {\n    if ( ! is_user_logged_in() || empty( $tag->name ) || ! isset( $wpide_cf7_user_map[ $tag->name ] ) || ! empty( $tag->values ) ) {\n        return $tag;\n    }\n\n    $user  = wp_get_current_user();\n    $value = (string) $user->get( $wpide_cf7_user_map[ $tag->name ] );\n\n    if ( '' !== $value ) {\n        $tag->values     = array( $value );\n        $tag->raw_values = array( $value );\n    }\n\n    return $tag;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Uses the default field names your-name and your-email; change the map at the top for your own field names."
        },
        {
            "id": "cf7-dropdown-from-posts",
            "version": 1,
            "title": "Fill a Contact Form 7 Drop-Down With Your Posts",
            "description": "Fills a Contact Form 7 select field with the titles of published posts, pages or a custom post type, so a services or products list stays in sync with your content.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "dynamic",
                "select"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_select_field = 'menu-service'; // Name of the select tag to fill.\n$wpide_cf7_select_type  = 'page';         // Post type to list.\n\nadd_filter( 'wpcf7_form_tag', function ( $tag ) use ( $wpide_cf7_select_field, $wpide_cf7_select_type ) {\n    if ( empty( $tag->name ) || $tag->name !== $wpide_cf7_select_field || 'select' !== $tag->basetype ) {\n        return $tag;\n    }\n\n    $titles = get_transient( 'wpide_cf7_select_' . $wpide_cf7_select_type );\n\n    if ( false === $titles ) {\n        $ids    = get_posts( array( 'post_type' => $wpide_cf7_select_type, 'post_status' => 'publish', 'numberposts' => 100, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n        $titles = array_values( array_filter( array_map( function ( $id ) {\n            return wp_strip_all_tags( get_the_title( $id ) );\n        }, $ids ) ) );\n        set_transient( 'wpide_cf7_select_' . $wpide_cf7_select_type, $titles, HOUR_IN_SECONDS );\n    }\n\n    if ( $titles ) {\n        $tag->values     = $titles;\n        $tag->raw_values = $titles;\n        $tag->labels     = $titles;\n    }\n\n    return $tag;\n} );\n\nadd_action( 'save_post_' . $wpide_cf7_select_type, function () use ( $wpide_cf7_select_type ) {\n    delete_transient( 'wpide_cf7_select_' . $wpide_cf7_select_type );\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Add an empty select tag such as [select* menu-service] to your form and set the field name and post type at the top; the list is cached for an hour and refreshed when a post of that type is saved."
        },
        {
            "id": "cf7-redirect-after-submit",
            "version": 1,
            "title": "Redirect to a Thank-You Page After Contact Form 7",
            "description": "Sends visitors to a thank-you page after a Contact Form 7 form is sent successfully, with a different page per form if you like. Handy for conversion tracking.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "redirect",
                "thank-you"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Form ID => thank-you URL. \"default\" applies to all other forms.\n$wpide_cf7_redirects = array(\n    'default' => '/thank-you/',\n    // 123 => '/thanks-for-your-quote-request/',\n);\n\nadd_action( 'wp_footer', function () use ( $wpide_cf7_redirects ) {\n    if ( ! wp_script_is( 'contact-form-7', 'enqueued' ) ) {\n        return;\n    }\n\n    $map = array();\n    foreach ( $wpide_cf7_redirects as $id => $url ) {\n        if ( $url ) {\n            $map[ (string) $id ] = esc_url_raw( $url );\n        }\n    }\n    ?>\n<script>\ndocument.addEventListener( 'wpcf7mailsent', function ( event ) {\n    var map = <?php echo wp_json_encode( $map ); ?>;\n    var id = String( event.detail.contactFormId );\n    var url = map[ id ] || map[ 'default' ];\n    if ( url ) { window.location.href = url; }\n}, false );\n</script>\n    <?php\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Map form IDs (from the form shortcode) to page URLs; the \"default\" entry covers every other form, so leave it empty to redirect only the forms you list."
        },
        {
            "id": "cf7-redirect-by-field-value",
            "version": 1,
            "title": "Redirect Contact Form 7 Based on an Answer",
            "description": "Sends visitors to a different page after submitting depending on what they chose in a drop-down or radio field, such as a sales or support thank-you page.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "redirect",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_cf7_choice_field = 'menu-department'; // Name of the select or radio field.\n// Option value => URL.\n$wpide_cf7_choice_urls  = array(\n    'Sales'   => '/thank-you-sales/',\n    'Support' => '/thank-you-support/',\n);\n\nadd_action( 'wp_footer', function () use ( $wpide_cf7_choice_field, $wpide_cf7_choice_urls ) {\n    if ( ! wp_script_is( 'contact-form-7', 'enqueued' ) ) {\n        return;\n    }\n\n    $urls = array_map( 'esc_url_raw', $wpide_cf7_choice_urls );\n    ?>\n<script>\ndocument.addEventListener( 'wpcf7mailsent', function ( event ) {\n    var field = <?php echo wp_json_encode( $wpide_cf7_choice_field ); ?>;\n    var urls = <?php echo wp_json_encode( $urls ); ?>;\n    ( event.detail.inputs || [] ).forEach( function ( input ) {\n        var name = input.name.replace( /\\[\\]$/, '' );\n        if ( name === field && urls[ input.value ] ) { window.location.href = urls[ input.value ]; }\n    } );\n}, false );\n</script>\n    <?php\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Set the field name and map each option value to a URL; options you do not list keep the normal on-page message."
        },
        {
            "id": "cf7-save-submissions",
            "version": 1,
            "title": "Save Contact Form 7 Submissions in the Admin",
            "description": "Keeps a copy of every Contact Form 7 message in a private \"Form Messages\" list in the admin, so nothing is lost when an email goes missing.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "entries",
                "storage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_type( 'wpide_cf7_message', array(\n        'labels'          => array( 'name' => 'Form Messages', 'singular_name' => 'Form Message', 'menu_name' => 'Form Messages' ),\n        'public'          => false,\n        'show_ui'         => true,\n        'show_in_menu'    => true,\n        'menu_icon'       => 'dashicons-email-alt',\n        'supports'        => array( 'title', 'editor' ),\n        'capability_type' => 'post',\n        'capabilities'    => array( 'create_posts' => 'do_not_allow' ),\n        'map_meta_cap'    => true,\n    ) );\n} );\n\nadd_action( 'wpcf7_mail_sent', function ( $contact_form ) {\n    $submission = class_exists( 'WPCF7_Submission' ) ? WPCF7_Submission::get_instance() : null;\n\n    if ( ! $submission ) {\n        return;\n    }\n\n    $lines = array();\n    foreach ( (array) $submission->get_posted_data() as $key => $value ) {\n        if ( 0 === strpos( $key, '_' ) ) {\n            continue;\n        }\n        $lines[] = '<strong>' . esc_html( $key ) . ':</strong> ' . nl2br( esc_html( is_array( $value ) ? implode( ', ', $value ) : (string) $value ) );\n    }\n    $lines[] = '<strong>Page:</strong> ' . esc_url( (string) $submission->get_meta( 'url' ) );\n\n    wp_insert_post( array(\n        'post_type'    => 'wpide_cf7_message',\n        'post_status'  => 'private',\n        'post_title'   => sprintf( '%s – %s', $contact_form->title(), wp_date( 'Y-m-d H:i' ) ),\n        'post_content' => wp_kses_post( implode( \"<br>\\n\", $lines ) ),\n        'meta_input'   => array( '_wpide_cf7_form_id' => (int) $contact_form->id() ),\n    ) );\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Messages are stored as private posts that only administrators can see (file uploads are not kept), so delete old ones from the list from time to time."
        },
        {
            "id": "cf7-webhook",
            "version": 1,
            "title": "Send Contact Form 7 Submissions to a Webhook",
            "description": "Posts each Contact Form 7 submission as JSON to a webhook URL, such as Zapier, Make, n8n or your CRM, so leads flow into your other tools automatically.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "webhook",
                "integration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_webhook_forms = array(); // Form IDs to send; empty = all forms.\n\nadd_action( 'wpcf7_mail_sent', function ( $contact_form ) use ( $wpide_cf7_webhook_forms ) {\n    $url = defined( 'WPIDE_CF7_WEBHOOK_URL' ) ? (string) WPIDE_CF7_WEBHOOK_URL : '';\n\n    if ( ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'https', 'http' ), true ) ) {\n        return;\n    }\n    if ( $wpide_cf7_webhook_forms && ! in_array( (int) $contact_form->id(), array_map( 'intval', $wpide_cf7_webhook_forms ), true ) ) {\n        return;\n    }\n\n    $submission = WPCF7_Submission::get_instance();\n    if ( ! $submission ) {\n        return;\n    }\n\n    $fields = array();\n    foreach ( (array) $submission->get_posted_data() as $key => $value ) {\n        if ( 0 !== strpos( $key, '_' ) ) {\n            $fields[ $key ] = $value;\n        }\n    }\n\n    $body = wp_json_encode( array(\n        'form_id'    => (int) $contact_form->id(),\n        'form_title' => $contact_form->title(),\n        'fields'     => $fields,\n        'page_url'   => (string) $submission->get_meta( 'url' ),\n        'submitted'  => gmdate( 'c' ),\n        'site'       => home_url(),\n    ) );\n\n    $headers = array( 'Content-Type' => 'application/json' );\n    if ( defined( 'WPIDE_CF7_WEBHOOK_SECRET' ) && WPIDE_CF7_WEBHOOK_SECRET ) {\n        $headers['X-Signature'] = hash_hmac( 'sha256', $body, WPIDE_CF7_WEBHOOK_SECRET );\n    }\n\n    wp_remote_post( $url, array( 'body' => $body, 'headers' => $headers, 'timeout' => 5, 'blocking' => false ) );\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Define WPIDE_CF7_WEBHOOK_URL (and optionally WPIDE_CF7_WEBHOOK_SECRET for an X-Signature HMAC header) in wp-config.php; limit it to certain forms with the list at the top."
        },
        {
            "id": "cf7-slack-notification",
            "version": 1,
            "title": "Post Contact Form 7 Messages to Slack",
            "description": "Sends a short summary of every Contact Form 7 submission to a Slack channel through an incoming webhook, so your team sees new leads right away.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "slack",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_slack_fields = array( 'your-name', 'your-email', 'your-subject', 'your-message' ); // Fields to include.\n\nadd_action( 'wpcf7_mail_sent', function ( $contact_form ) use ( $wpide_cf7_slack_fields ) {\n    $url = defined( 'WPIDE_SLACK_WEBHOOK_URL' ) ? (string) WPIDE_SLACK_WEBHOOK_URL : '';\n    $submission = WPCF7_Submission::get_instance();\n\n    if ( 0 !== strpos( $url, 'https://hooks.slack.com/' ) || ! $submission ) {\n        return;\n    }\n\n    $lines = array( sprintf( '*New message from \"%s\"*', $contact_form->title() ) );\n    foreach ( $wpide_cf7_slack_fields as $name ) {\n        $value = $submission->get_posted_data( $name );\n        if ( null !== $value && '' !== $value ) {\n            $value   = is_array( $value ) ? implode( ', ', $value ) : (string) $value;\n            $lines[] = sprintf( '*%s:* %s', $name, wp_html_excerpt( str_replace( array( '<', '>', '&' ), array( '&lt;', '&gt;', '&amp;' ), $value ), 500, '…' ) );\n        }\n    }\n\n    wp_safe_remote_post( $url, array(\n        'body'     => wp_json_encode( array( 'text' => implode( \"\\n\", $lines ) ) ),\n        'headers'  => array( 'Content-Type' => 'application/json' ),\n        'timeout'  => 5,\n        'blocking' => false,\n    ) );\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Create an incoming webhook in Slack and define WPIDE_SLACK_WEBHOOK_URL in wp-config.php; the fields shown are listed at the top."
        },
        {
            "id": "cf7-route-by-department",
            "version": 1,
            "title": "Route Contact Form 7 Emails by Department",
            "description": "Sends each Contact Form 7 message to the right inbox based on the department or topic the visitor picked, instead of one shared address.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "notifications",
                "routing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_route_field = 'menu-department';\n// Option value => recipient address.\n$wpide_cf7_routes = array(\n    // 'Sales'   => 'sales@yourdomain.com',\n    // 'Support' => 'support@yourdomain.com',\n);\n\nadd_filter( 'wpcf7_mail_components', function ( $components, $contact_form = null, $mail = null ) use ( $wpide_cf7_route_field, $wpide_cf7_routes ) {\n    $submission = class_exists( 'WPCF7_Submission' ) ? WPCF7_Submission::get_instance() : null;\n\n    if ( ! $submission || ( $mail && method_exists( $mail, 'name' ) && 'mail' !== $mail->name() ) ) {\n        return $components;\n    }\n\n    $choice = $submission->get_posted_data( $wpide_cf7_route_field );\n    $choice = is_array( $choice ) ? reset( $choice ) : (string) $choice;\n\n    if ( isset( $wpide_cf7_routes[ $choice ] ) && is_email( $wpide_cf7_routes[ $choice ] ) ) {\n        $components['recipient'] = $wpide_cf7_routes[ $choice ];\n    }\n\n    return $components;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add your choices and addresses to the map at the top (unknown choices keep the form's own recipient); CF7 pipes (Label|email) are an alternative that keeps addresses inside the form."
        },
        {
            "id": "cf7-email-post-author",
            "version": 1,
            "title": "Send Contact Form 7 Messages to the Post Author",
            "description": "When a Contact Form 7 form is placed on a post, listing or profile page, the message goes to that post's author instead of the site admin.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "notifications",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_author_post_types = array( 'post' ); // Post types whose author receives the message.\n\nadd_filter( 'wpcf7_mail_components', function ( $components, $contact_form = null, $mail = null ) use ( $wpide_cf7_author_post_types ) {\n    $submission = class_exists( 'WPCF7_Submission' ) ? WPCF7_Submission::get_instance() : null;\n\n    if ( ! $submission || ( $mail && method_exists( $mail, 'name' ) && 'mail' !== $mail->name() ) ) {\n        return $components;\n    }\n\n    $post = get_post( (int) $submission->get_meta( 'container_post_id' ) );\n\n    if ( ! $post || ! in_array( $post->post_type, $wpide_cf7_author_post_types, true ) ) {\n        return $components;\n    }\n\n    $author = get_userdata( (int) $post->post_author );\n\n    if ( $author && is_email( $author->user_email ) ) {\n        $components['recipient'] = $author->user_email;\n    }\n\n    return $components;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "Limit it to certain post types with the list at the top; on other pages the form's own recipient is used."
        },
        {
            "id": "cf7-subject-prefix",
            "version": 1,
            "title": "Prefix Contact Form 7 Email Subjects",
            "description": "Adds the form name and a short reference number to the subject of every Contact Form 7 email, so messages are easy to filter and refer to in your inbox.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "notifications",
                "subject"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_subject_format = '[%1$s #%2$s] '; // Prefix format.\n\nadd_filter( 'wpcf7_mail_components', function ( $components, $contact_form = null ) use ( $wpide_cf7_subject_format ) {\n    if ( ! $contact_form || ! method_exists( $contact_form, 'title' ) ) {\n        return $components;\n    }\n\n    $reference = strtoupper( substr( base_convert( (string) ( time() . wp_rand( 10, 99 ) ), 10, 36 ), -6 ) );\n    $components['subject'] = sprintf( $wpide_cf7_subject_format, $contact_form->title(), $reference ) . $components['subject'];\n\n    return $components;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Change the format string at the top; %1$s is the form title and %2$s the reference."
        },
        {
            "id": "cf7-append-submission-details",
            "version": 1,
            "title": "Add Visitor Details to Contact Form 7 Emails",
            "description": "Appends the page the form was sent from, the date, the visitor's browser and IP address to every Contact Form 7 email, without editing each form's mail template.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "notifications",
                "details"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpcf7_mail_components', function ( $components, $contact_form = null, $mail = null ) {\n    $submission = class_exists( 'WPCF7_Submission' ) ? WPCF7_Submission::get_instance() : null;\n\n    if ( ! $submission || ( $mail && method_exists( $mail, 'name' ) && 'mail' !== $mail->name() ) ) {\n        return $components;\n    }\n\n    $details = array(\n        'Page'    => (string) $submission->get_meta( 'url' ),\n        'Date'    => wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $submission->get_meta( 'timestamp' ) ),\n        'Browser' => (string) $submission->get_meta( 'user_agent' ),\n        'IP'      => (string) $submission->get_meta( 'remote_ip' ),\n    );\n\n    $html = $mail && method_exists( $mail, 'get' ) && $mail->get( 'use_html' );\n    $text = '';\n    foreach ( $details as $label => $value ) {\n        $text .= $html ? '<br>' . esc_html( $label . ': ' . $value ) : \"\\n\" . $label . ': ' . $value;\n    }\n\n    $components['body'] .= ( $html ? '<hr>' : \"\\n\\n--\" ) . $text;\n\n    return $components;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "Applies to the main mail only, not the autoresponder; remove the IP line if your privacy policy does not cover it."
        },
        {
            "id": "cf7-custom-response-messages",
            "version": 1,
            "title": "Friendlier Contact Form 7 Response Messages",
            "description": "Replaces the standard Contact Form 7 success, error and spam messages on every form with your own wording in one place.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "messages",
                "copy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// CF7 message name => your text.\n$wpide_cf7_messages = array(\n    'mail_sent_ok'     => 'Thanks! Your message is on its way and we usually reply within one working day.',\n    'mail_sent_ng'     => 'Sorry, something went wrong sending your message. Please try again or email us directly.',\n    'validation_error' => 'Please check the highlighted fields and try again.',\n    'spam'             => 'Your message could not be sent. If you are a real person, please email us directly.',\n    'invalid_required' => 'This field is required.',\n);\n\nadd_filter( 'wpcf7_display_message', function ( $message, $status = '' ) use ( $wpide_cf7_messages ) {\n    return isset( $wpide_cf7_messages[ $status ] ) ? $wpide_cf7_messages[ $status ] : $message;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Keys are CF7 message names (mail_sent_ok, mail_sent_ng, validation_error, spam, accept_terms, invalid_required); remove a line to keep the form's own text."
        },
        {
            "id": "cf7-disable-auto-paragraphs",
            "version": 1,
            "title": "Stop Contact Form 7 Adding Paragraph Tags",
            "description": "Turns off Contact Form 7's automatic paragraph and line-break tags, so forms built with your own HTML or grid markup render exactly as written.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "markup",
                "html"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpcf7_autop_or_not', '__return_false' );",
            "requires": [
                "cf7"
            ],
            "notes": "After enabling, add your own wrappers (div or p) around fields in the form editor where you want spacing."
        },
        {
            "id": "cf7-default-upload-limits",
            "version": 1,
            "title": "Default Upload Limits for Contact Form 7 File Fields",
            "description": "Gives every Contact Form 7 file field a safe default list of allowed file types and a maximum size, unless the field already sets its own.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "uploads",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_upload_types = 'pdf|doc|docx|jpg|jpeg|png'; // Allowed extensions, separated by |.\n$wpide_cf7_upload_limit = '5mb';                       // Maximum size per file (kb or mb).\n\nadd_filter( 'wpcf7_form_tag', function ( $tag ) use ( $wpide_cf7_upload_types, $wpide_cf7_upload_limit ) {\n    if ( empty( $tag->basetype ) || 'file' !== $tag->basetype ) {\n        return $tag;\n    }\n\n    $options = (array) $tag->options;\n\n    if ( ! $tag->has_option( 'filetypes' ) ) {\n        $options[] = 'filetypes:' . $wpide_cf7_upload_types;\n    }\n    if ( ! $tag->has_option( 'limit' ) ) {\n        $options[] = 'limit:' . $wpide_cf7_upload_limit;\n    }\n\n    $tag->options = $options;\n\n    return $tag;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Fields with their own filetypes: or limit: options keep them; the server's upload_max_filesize must also allow the size."
        },
        {
            "id": "cf7-skip-mail-for-forms",
            "version": 1,
            "title": "Don't Email Submissions of Certain Contact Form 7 Forms",
            "description": "Stops Contact Form 7 sending emails for the forms you list while still showing the success message, useful when submissions are saved or sent to a CRM instead.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "notifications",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_no_mail_forms = array(); // Form IDs that should not send email, e.g. array( 123, 456 ).\n\nadd_filter( 'wpcf7_skip_mail', function ( $skip, $contact_form = null ) use ( $wpide_cf7_no_mail_forms ) {\n    if ( $contact_form && in_array( (int) $contact_form->id(), array_map( 'intval', $wpide_cf7_no_mail_forms ), true ) ) {\n        return true;\n    }\n\n    return $skip;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add the form IDs at the top and pair it with a snippet that saves submissions or sends them to a webhook, otherwise the messages are lost."
        },
        {
            "id": "cf7-limit-total-submissions",
            "version": 1,
            "title": "Close a Contact Form 7 Form After a Number of Entries",
            "description": "Accepts only a set number of successful submissions for a Contact Form 7 form, such as event places or giveaway entries, then shows a \"closed\" message instead of the form.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "limits",
                "events"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Form ID => maximum successful submissions.\n$wpide_cf7_limits = array(); // e.g. array( 123 => 50 ).\n$wpide_cf7_closed_message = 'Sorry, this form is closed because all places have been taken.';\n\nadd_action( 'wpcf7_mail_sent', function ( $contact_form ) use ( $wpide_cf7_limits ) {\n    $id = (int) $contact_form->id();\n    if ( isset( $wpide_cf7_limits[ $id ] ) ) {\n        update_option( 'wpide_cf7_count_' . $id, (int) get_option( 'wpide_cf7_count_' . $id, 0 ) + 1, false );\n    }\n} );\n\nadd_filter( 'wpcf7_validate', function ( $result ) use ( $wpide_cf7_limits, $wpide_cf7_closed_message ) {\n    $form = function_exists( 'wpcf7_get_current_contact_form' ) ? wpcf7_get_current_contact_form() : null;\n    $id   = $form ? (int) $form->id() : 0;\n\n    if ( isset( $wpide_cf7_limits[ $id ] ) && (int) get_option( 'wpide_cf7_count_' . $id, 0 ) >= $wpide_cf7_limits[ $id ] ) {\n        $tags = $form->scan_form_tags();\n        if ( $tags ) {\n            $result->invalidate( $tags[0], $wpide_cf7_closed_message );\n        }\n    }\n\n    return $result;\n} );\n\nadd_filter( 'do_shortcode_tag', function ( $output, $tag, $attr ) use ( $wpide_cf7_limits, $wpide_cf7_closed_message ) {\n    if ( 'contact-form-7' !== $tag || empty( $attr['id'] ) ) {\n        return $output;\n    }\n\n    $form = function_exists( 'wpcf7_contact_form' ) ? wpcf7_contact_form( $attr['id'] ) : null;\n    $id   = $form ? (int) $form->id() : 0;\n\n    if ( isset( $wpide_cf7_limits[ $id ] ) && (int) get_option( 'wpide_cf7_count_' . $id, 0 ) >= $wpide_cf7_limits[ $id ] ) {\n        return '<p class=\"wpide-form-closed\" role=\"status\">' . esc_html( $wpide_cf7_closed_message ) . '</p>';\n    }\n\n    return $output;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add form IDs and limits to the list at the top; the count is stored per form in the option wpide_cf7_count_<ID>, which you can delete to reopen the form."
        },
        {
            "id": "cf7-form-deadline",
            "version": 1,
            "title": "Open a Contact Form 7 Form Only Between Two Dates",
            "description": "Shows a Contact Form 7 form only during the dates you set, for example an application period, and blocks late submissions from pages left open.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "schedule",
                "deadline"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Form ID => array( start, end ).\n$wpide_cf7_windows = array(\n    // 123 => array( '', '2026-12-31 23:59' ),\n);\n$wpide_cf7_window_message = 'This form is not accepting submissions at the moment.';\n\n$wpide_cf7_is_open = function ( $id ) use ( $wpide_cf7_windows ) {\n    if ( ! isset( $wpide_cf7_windows[ $id ] ) ) {\n        return true;\n    }\n\n    $now   = current_time( 'timestamp' );\n    $start = $wpide_cf7_windows[ $id ][0] ? strtotime( $wpide_cf7_windows[ $id ][0] ) : 0;\n    $end   = $wpide_cf7_windows[ $id ][1] ? strtotime( $wpide_cf7_windows[ $id ][1] ) : PHP_INT_MAX;\n\n    return $now >= $start && $now <= $end;\n};\n\nadd_filter( 'do_shortcode_tag', function ( $output, $tag, $attr ) use ( $wpide_cf7_is_open, $wpide_cf7_window_message ) {\n    if ( 'contact-form-7' !== $tag || empty( $attr['id'] ) || ! function_exists( 'wpcf7_contact_form' ) ) {\n        return $output;\n    }\n\n    $form = wpcf7_contact_form( $attr['id'] );\n\n    return $form && ! $wpide_cf7_is_open( (int) $form->id() ) ? '<p class=\"wpide-form-closed\" role=\"status\">' . esc_html( $wpide_cf7_window_message ) . '</p>' : $output;\n}, 10, 3 );\n\nadd_filter( 'wpcf7_spam', function ( $spam ) use ( $wpide_cf7_is_open ) {\n    $form = function_exists( 'wpcf7_get_current_contact_form' ) ? wpcf7_get_current_contact_form() : null;\n\n    return ( $form && ! $wpide_cf7_is_open( (int) $form->id() ) ) ? true : $spam;\n}, 30 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add form IDs with start and end dates (site timezone, Y-m-d H:i) to the list at the top; leave the start empty to open the form straight away."
        },
        {
            "id": "cf7-block-ip-addresses",
            "version": 1,
            "title": "Block IP Addresses From Contact Form 7",
            "description": "Silently rejects Contact Form 7 submissions from IP addresses or ranges you list, such as a persistent spammer, while the rest of the site stays open to them.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "ip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_blocked_ips = array( '203.0.113.7', '198.51.100.' ); // Exact IPs or prefixes.\n\nadd_filter( 'wpcf7_spam', function ( $spam, $submission = null ) use ( $wpide_cf7_blocked_ips ) {\n    if ( $spam || ! $submission ) {\n        return $spam;\n    }\n\n    $ip = (string) $submission->get_meta( 'remote_ip' );\n\n    foreach ( $wpide_cf7_blocked_ips as $blocked ) {\n        $prefix = in_array( substr( $blocked, -1 ), array( '.', ':' ), true );\n        if ( ( $prefix && 0 === strpos( $ip, $blocked ) ) || $ip === $blocked ) {\n            $submission->add_spam_log( array( 'agent' => 'wpide-ip-block', 'reason' => 'Blocked IP address.' ) );\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Supports exact addresses and prefix ranges ending in a dot or colon, such as 203.0.113.; behind a proxy CF7 reads the visitor IP it is given."
        },
        {
            "id": "cf7-normalize-posted-data",
            "version": 1,
            "title": "Tidy Up Contact Form 7 Submissions",
            "description": "Trims stray spaces, lowercases email addresses and collapses repeated blank lines in Contact Form 7 submissions before they are emailed or stored.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "data",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpcf7_posted_data', function ( $data ) {\n    foreach ( (array) $data as $key => $value ) {\n        if ( is_array( $value ) || 0 === strpos( $key, '_' ) ) {\n            continue;\n        }\n\n        $value = trim( preg_replace( \"/(\\r?\\n){3,}/\", \"\\n\\n\", (string) $value ) );\n\n        if ( false !== stripos( $key, 'email' ) ) {\n            $value = strtolower( $value );\n        }\n\n        $data[ $key ] = $value;\n    }\n\n    return $data;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Field names containing \"email\" are lowercased; everything else keeps its case."
        },
        {
            "id": "cf7-recaptcha-only-with-forms",
            "version": 1,
            "title": "Load Contact Form 7 reCAPTCHA Only on Form Pages",
            "description": "Stops the Google reCAPTCHA script that Contact Form 7 adds to every page from loading where no form is shown, which speeds up the rest of the site and hides the badge there.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "recaptcha",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_cf7_recaptcha_pages = array(); // Extra page IDs that show a form outside the content.\n\nadd_action( 'wp_enqueue_scripts', function () use ( $wpide_cf7_recaptcha_pages ) {\n    $post = get_post();\n    $uses = $post && is_singular() && ( has_shortcode( $post->post_content, 'contact-form-7' ) || has_block( 'contact-form-7/contact-form-selector', $post ) );\n\n    if ( $uses || ( $wpide_cf7_recaptcha_pages && is_page( $wpide_cf7_recaptcha_pages ) ) ) {\n        return;\n    }\n\n    wp_dequeue_script( 'wpcf7-recaptcha' );\n    wp_dequeue_script( 'google-recaptcha' );\n}, 50 );",
            "requires": [
                "cf7"
            ],
            "notes": "Add page IDs to the list if a form appears outside the post content, for example in a footer widget."
        },
        {
            "id": "cf7-autocomplete-attributes",
            "version": 1,
            "title": "Autofill Hints for Contact Form 7 Fields",
            "description": "Adds the right autocomplete attributes to Contact Form 7 name, email, phone and company fields, so browsers and password managers can fill them in one tap.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "accessibility",
                "autofill"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field name => autocomplete token.\n$wpide_cf7_autocomplete = array(\n    'your-name'    => 'name',\n    'your-email'   => 'email',\n    'your-phone'   => 'tel',\n    'your-company' => 'organization',\n);\n\nadd_filter( 'wpcf7_form_elements', function ( $html ) use ( $wpide_cf7_autocomplete ) {\n    foreach ( $wpide_cf7_autocomplete as $name => $token ) {\n        $html = preg_replace_callback( '/<input\\b[^>]*\\bname=\"' . preg_quote( $name, '/' ) . '\"[^>]*>/i', function ( $m ) use ( $token ) {\n            return false !== stripos( $m[0], 'autocomplete=' ) ? $m[0] : preg_replace( '/^<input\\b/i', '<input autocomplete=\"' . esc_attr( $token ) . '\"', $m[0] );\n        }, $html );\n    }\n\n    return $html;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Map your own field names to autocomplete tokens at the top; fields that already have an autocomplete attribute are left alone."
        },
        {
            "id": "cf7-scroll-to-response",
            "version": 1,
            "title": "Scroll to the Contact Form 7 Response Message",
            "description": "Scrolls the page to the Contact Form 7 success or error message after submitting, so visitors on long pages and phones see the result.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "ux",
                "scroll"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! wp_script_is( 'contact-form-7', 'enqueued' ) ) {\n        return;\n    }\n    ?>\n<script>\ndocument.addEventListener( 'wpcf7submit', function ( event ) {\n    var output = event.target.querySelector( '.wpcf7-response-output' );\n    if ( ! output ) { return; }\n    var reduce = window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches;\n    setTimeout( function () {\n        output.scrollIntoView( { behavior: reduce ? 'auto' : 'smooth', block: 'center' } );\n        output.setAttribute( 'tabindex', '-1' );\n        output.focus( { preventScroll: true } );\n    }, 50 );\n}, false );\n</script>\n    <?php\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Jumps instantly instead of animating for visitors who prefer reduced motion."
        },
        {
            "id": "cf7-hide-form-after-success",
            "version": 1,
            "title": "Hide the Contact Form 7 Form After It Is Sent",
            "description": "Hides the fields of a Contact Form 7 form once it has been sent successfully and leaves only the thank-you message, so people do not submit twice.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "ux",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpcf7 form.sent > *:not(.wpcf7-response-output) {\n    display: none;\n}\n\n.wpcf7 form.sent .wpcf7-response-output {\n    margin: 0;\n    padding: 1rem 1.25rem;\n    font-size: 1.05em;\n}",
            "requires": [
                "cf7"
            ],
            "notes": "Contact Form 7 adds the \"sent\" class to the form after a successful submission; reloading the page shows the form again."
        },
        {
            "id": "cf7-modern-form-styles",
            "version": 1,
            "title": "Modern Styling for Contact Form 7",
            "description": "Gives Contact Form 7 forms clean full-width fields, clear focus outlines, readable error messages and a solid submit button that work in light and dark themes.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "styling",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpcf7 {\n    --wpide-cf7-accent: #2563eb;\n    --wpide-cf7-border: #cbd5e1;\n    --wpide-cf7-error: #dc2626;\n    --wpide-cf7-success: #16a34a;\n    --wpide-cf7-radius: 8px;\n}\n\n.wpcf7 input[type=\"text\"],\n.wpcf7 input[type=\"email\"],\n.wpcf7 input[type=\"tel\"],\n.wpcf7 input[type=\"url\"],\n.wpcf7 input[type=\"number\"],\n.wpcf7 input[type=\"date\"],\n.wpcf7 select,\n.wpcf7 textarea {\n    width: 100%;\n    max-width: 100%;\n    box-sizing: border-box;\n    padding: 0.7em 0.9em;\n    border: 1px solid var(--wpide-cf7-border);\n    border-radius: var(--wpide-cf7-radius);\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    font-size: max(16px, 1em);\n}\n\n.wpcf7 input:focus-visible,\n.wpcf7 select:focus-visible,\n.wpcf7 textarea:focus-visible {\n    outline: 2px solid var(--wpide-cf7-accent);\n    outline-offset: 1px;\n    border-color: var(--wpide-cf7-accent);\n}\n\n.wpcf7 label {\n    display: block;\n    font-weight: 600;\n}\n\n.wpcf7 input[type=\"submit\"] {\n    padding: 0.75em 1.6em;\n    border: 0;\n    border-radius: var(--wpide-cf7-radius);\n    background: var(--wpide-cf7-accent);\n    color: #fff;\n    font: inherit;\n    font-weight: 600;\n    cursor: pointer;\n}\n\n.wpcf7 input[type=\"submit\"]:disabled {\n    opacity: 0.6;\n    cursor: progress;\n}\n\n.wpcf7 .wpcf7-not-valid {\n    border-color: var(--wpide-cf7-error);\n}\n\n.wpcf7 .wpcf7-not-valid-tip {\n    margin-top: 0.3em;\n    color: var(--wpide-cf7-error);\n    font-size: 0.875em;\n}\n\n.wpcf7 form .wpcf7-response-output {\n    margin: 1em 0 0;\n    padding: 0.8em 1em;\n    border-radius: var(--wpide-cf7-radius);\n}\n\n.wpcf7 form.sent .wpcf7-response-output {\n    border-color: var(--wpide-cf7-success);\n}",
            "requires": [
                "cf7"
            ],
            "notes": "Change the custom properties at the top to match your brand colors."
        },
        {
            "id": "cf7-form-theme-scss",
            "version": 1,
            "title": "Contact Form 7 Theme With SCSS Variables",
            "description": "A complete Contact Form 7 look written in SCSS: set a few variables for colors, radius and spacing and every field, checkbox, message and button follows.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "scss",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #7c3aed;\n$radius: 10px;\n$gap: 1.1rem;\n$border: #d4d4d8;\n$error: #e11d48;\n\n.wpcf7 {\n    form > p,\n    form > div {\n        margin: 0 0 $gap;\n    }\n\n    input:not([type=\"submit\"]):not([type=\"checkbox\"]):not([type=\"radio\"]),\n    select,\n    textarea {\n        width: 100%;\n        box-sizing: border-box;\n        padding: 0.75em 1em;\n        border: 1px solid $border;\n        border-radius: $radius;\n        background: #fff;\n        color: #18181b;\n        font-size: max(16px, 1em);\n        transition: border-color 0.15s, box-shadow 0.15s;\n\n        &:focus {\n            outline: none;\n            border-color: $accent;\n            box-shadow: 0 0 0 3px rgba($accent, 0.25);\n        }\n    }\n\n    .wpcf7-list-item {\n        margin: 0 1em 0.4em 0;\n    }\n\n    input[type=\"checkbox\"],\n    input[type=\"radio\"] {\n        accent-color: $accent;\n        width: 1.1em;\n        height: 1.1em;\n    }\n\n    input[type=\"submit\"] {\n        padding: 0.8em 1.8em;\n        border: 0;\n        border-radius: $radius;\n        background: $accent;\n        color: #fff;\n        font-weight: 600;\n        cursor: pointer;\n\n        &:hover {\n            background: darken($accent, 8%);\n        }\n    }\n\n    .wpcf7-not-valid {\n        border-color: $error !important;\n    }\n\n    .wpcf7-not-valid-tip {\n        color: $error;\n        font-size: 0.85em;\n    }\n\n    @media (prefers-color-scheme: dark) {\n        input:not([type=\"submit\"]):not([type=\"checkbox\"]):not([type=\"radio\"]),\n        select,\n        textarea {\n            background: #18181b;\n            color: #f4f4f5;\n            border-color: #3f3f46;\n        }\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        input,\n        select,\n        textarea {\n            transition: none;\n        }\n    }\n}",
            "requires": [
                "cf7"
            ],
            "notes": "Edit the variables at the top; the dark-mode block follows the visitor's system setting."
        },
        {
            "id": "cf7-ga4-conversion-event",
            "version": 1,
            "title": "Track Contact Form 7 Sends in Google Analytics 4",
            "description": "Sends a generate_lead event to Google Analytics 4 or Google Tag Manager each time a Contact Form 7 form is sent successfully, with the form ID attached.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "analytics",
                "ga4"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_cf7_event_name = 'generate_lead'; // GA4 event name.\n\nadd_action( 'wp_footer', function () use ( $wpide_cf7_event_name ) {\n    if ( ! wp_script_is( 'contact-form-7', 'enqueued' ) ) {\n        return;\n    }\n    ?>\n<script>\ndocument.addEventListener( 'wpcf7mailsent', function ( event ) {\n    var name = <?php echo wp_json_encode( $wpide_cf7_event_name ); ?>;\n    var data = { form_id: String( event.detail.contactFormId ), form_type: 'contact-form-7' };\n    if ( typeof window.gtag === 'function' ) {\n        window.gtag( 'event', name, data );\n    } else {\n        window.dataLayer = window.dataLayer || [];\n        window.dataLayer.push( Object.assign( { event: name }, data ) );\n    }\n}, false );\n</script>\n    <?php\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Works with gtag.js or a GTM dataLayer already on the site; rename the event at the top to match your conversion setup."
        },
        {
            "id": "gf-block-email-domains",
            "version": 1,
            "title": "Block Email Domains in Gravity Forms",
            "description": "Shows an error when someone enters an email address from a domain on your block list, such as throwaway inbox services, in any Gravity Forms email field.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Domains that may not be used in email fields.\n$wpide_gf_blocked_domains = array( 'mailinator.com', 'yopmail.com', 'tempmail.com', 'guerrillamail.com', 'sharklasers.com' );\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_blocked_domains ) {\n    if ( 'email' !== $field->type || ! $result['is_valid'] ) {\n        return $result;\n    }\n\n    $email  = strtolower( trim( is_array( $value ) ? (string) reset( $value ) : (string) $value ) );\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    foreach ( $wpide_gf_blocked_domains as $blocked ) {\n        if ( $domain && ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) ) {\n            $result['is_valid'] = false;\n            $result['message']  = 'Please use a different email address.';\n            break;\n        }\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Subdomains of listed domains are blocked as well; add more domains to the list as spam arrives."
        },
        {
            "id": "gf-business-email-only",
            "version": 1,
            "title": "Require a Work Email in Gravity Forms",
            "description": "Asks for a company email address instead of Gmail, Yahoo or other free webmail in Gravity Forms email fields, for B2B lead and demo request forms.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_free_providers = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'proton.me', 'protonmail.com', 'mail.com', 'yandex.com' );\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_free_providers ) {\n    if ( 'email' !== $field->type || ! $result['is_valid'] ) {\n        return $result;\n    }\n\n    $classed = false;\n    foreach ( $form['fields'] as $f ) {\n        if ( false !== strpos( ' ' . $f->cssClass . ' ', ' work-email ' ) ) {\n            $classed = true;\n            break;\n        }\n    }\n    if ( $classed && false === strpos( ' ' . $field->cssClass . ' ', ' work-email ' ) ) {\n        return $result;\n    }\n\n    $email  = strtolower( trim( is_array( $value ) ? (string) reset( $value ) : (string) $value ) );\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_gf_free_providers, true ) ) {\n        $result['is_valid'] = false;\n        $result['message']  = 'Please use your work email address.';\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Add the CSS class work-email to an email field in the form editor (Appearance tab) to check only that field; without the class every email field is checked."
        },
        {
            "id": "gf-no-links-in-fields",
            "version": 1,
            "title": "Block Links in Gravity Forms Text Fields",
            "description": "Rejects Gravity Forms submissions that put web links in name, text or paragraph fields, which removes most link-spam without a captcha.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_link_field_types = array( 'text', 'textarea', 'name' ); // Field types to check.\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_link_field_types ) {\n    if ( ! $result['is_valid'] || ! in_array( $field->type, $wpide_gf_link_field_types, true ) || false !== strpos( ' ' . $field->cssClass . ' ', ' allow-links ' ) ) {\n        return $result;\n    }\n\n    $text = is_array( $value ) ? implode( ' ', $value ) : (string) $value;\n\n    if ( preg_match( '#(https?://|www\\.|\\[url|<a\\s)#i', $text ) ) {\n        $result['is_valid'] = false;\n        $result['message']  = 'Please remove links from this field.';\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Website fields are never checked; add the CSS class allow-links to a field that should accept links."
        },
        {
            "id": "gf-blocked-words-spam",
            "version": 1,
            "title": "Mark Gravity Forms Entries With Spam Words as Spam",
            "description": "Sends Gravity Forms entries to the Spam list when any field contains a word or phrase from your block list, so they skip notifications and stay out of your entries.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_spam_words = array( 'seo services', 'backlinks', 'crypto', 'casino', 'guest post', 'web design offer', 'loan' );\n\nadd_filter( 'gform_entry_is_spam', function ( $is_spam, $form, $entry ) use ( $wpide_gf_spam_words ) {\n    if ( $is_spam ) {\n        return $is_spam;\n    }\n\n    $text = '';\n    foreach ( $form['fields'] as $field ) {\n        $text .= ' ' . (string) $field->get_value_export( $entry );\n    }\n\n    foreach ( $wpide_gf_spam_words as $word ) {\n        if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $text ) ) {\n            if ( method_exists( 'GFCommon', 'set_spam_filter' ) ) {\n                GFCommon::set_spam_filter( rgar( $form, 'id' ), 'WPIDE blocked words', sprintf( 'Contains \"%s\".', $word ) );\n            }\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Spam entries can still be reviewed and restored from the form's Spam list; matching is case-insensitive on whole words."
        },
        {
            "id": "gf-time-trap",
            "version": 1,
            "title": "Reject Gravity Forms Submitted Too Quickly",
            "description": "Adds a signed time stamp to each Gravity Forms form and marks entries sent within a few seconds of the page loading as spam, which catches bots that fill forms instantly.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "time-trap"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_min_seconds = 4; // Minimum seconds between page load and submit.\n\nadd_filter( 'gform_form_tag', function ( $tag, $form ) {\n    $time = (string) time();\n    $tag .= '<input type=\"hidden\" name=\"wpide_gf_ts\" value=\"' . esc_attr( $time . '.' . wp_hash( 'gf-ts-' . $time ) ) . '\">';\n\n    return $tag;\n}, 10, 2 );\n\nadd_filter( 'gform_entry_is_spam', function ( $is_spam, $form, $entry ) use ( $wpide_gf_min_seconds ) {\n    if ( $is_spam ) {\n        return $is_spam;\n    }\n\n    $stamp = isset( $_POST['wpide_gf_ts'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_gf_ts'] ) ) : '';\n    $parts = explode( '.', $stamp, 2 );\n    $valid = 2 === count( $parts ) && ctype_digit( $parts[0] ) && hash_equals( wp_hash( 'gf-ts-' . $parts[0] ), $parts[1] );\n\n    if ( $valid && time() - (int) $parts[0] >= $wpide_gf_min_seconds ) {\n        return false;\n    }\n\n    if ( method_exists( 'GFCommon', 'set_spam_filter' ) ) {\n        GFCommon::set_spam_filter( rgar( $form, 'id' ), 'WPIDE time trap', $valid ? 'Submitted too quickly.' : 'Missing time stamp.' );\n    }\n\n    return true;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Safe with page caching because older stamps always pass; multi-page forms keep the first stamp."
        },
        {
            "id": "gf-honeypot-all-forms",
            "version": 1,
            "title": "Turn On the Gravity Forms Honeypot for Every Form",
            "description": "Switches on Gravity Forms' built-in anti-spam honeypot for all forms, including ones created later, so nobody has to remember the setting.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "honeypot"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_honeypot_skip = array(); // Form IDs to leave alone.\n\nadd_filter( 'gform_form_post_get_meta', function ( $form ) use ( $wpide_gf_honeypot_skip ) {\n    if ( is_array( $form ) && ! in_array( (int) rgar( $form, 'id' ), array_map( 'intval', $wpide_gf_honeypot_skip ), true ) ) {\n        $form['enableHoneypot'] = true;\n    }\n\n    return $form;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Exclude a form by adding its ID to the list at the top."
        },
        {
            "id": "gf-non-latin-spam",
            "version": 1,
            "title": "Treat Gravity Forms Entries in Foreign Scripts as Spam",
            "description": "Marks a Gravity Forms entry as spam when most of its text is written in Cyrillic, Chinese or another script you never receive genuine enquiries in.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_blocked_scripts = array( 'Cyrillic', 'Han', 'Arabic', 'Hangul', 'Thai' );\n$wpide_gf_script_share    = 0.3; // Share of letters that marks the entry as spam.\n\nadd_filter( 'gform_entry_is_spam', function ( $is_spam, $form, $entry ) use ( $wpide_gf_blocked_scripts, $wpide_gf_script_share ) {\n    if ( $is_spam ) {\n        return $is_spam;\n    }\n\n    $text = '';\n    foreach ( $form['fields'] as $field ) {\n        if ( in_array( $field->type, array( 'text', 'textarea', 'name' ), true ) ) {\n            $text .= ' ' . (string) $field->get_value_export( $entry );\n        }\n    }\n\n    $letters = preg_match_all( '/\\p{L}/u', $text );\n    if ( $letters < 10 ) {\n        return false;\n    }\n\n    $foreign = 0;\n    foreach ( $wpide_gf_blocked_scripts as $script ) {\n        $foreign += (int) preg_match_all( '/\\p{' . $script . '}/u', $text );\n    }\n\n    return $foreign / $letters >= $wpide_gf_script_share;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Remove scripts from the list that your customers actually write in."
        },
        {
            "id": "gf-rate-limit-ip",
            "version": 1,
            "title": "Limit Gravity Forms Entries per IP Address",
            "description": "Stops a visitor from sending more than a few Gravity Forms entries per hour from the same IP address, with a polite message, to block flooding and repeat spam.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_max_entries = 3;       // Entries allowed per IP...\n$wpide_gf_window      = '1 hour'; // ...within this period.\n$wpide_gf_limit_forms = array(); // Form IDs to limit; empty = all.\n\nadd_filter( 'gform_validation', function ( $validation ) use ( $wpide_gf_max_entries, $wpide_gf_window, $wpide_gf_limit_forms ) {\n    $form = $validation['form'];\n\n    if ( ! $validation['is_valid'] || ( $wpide_gf_limit_forms && ! in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_limit_forms ), true ) ) ) {\n        return $validation;\n    }\n\n    $count = GFAPI::count_entries( $wpide_gf_limit_forms ? (int) $form['id'] : 0, array(\n        'status'        => 'active',\n        'start_date'    => wp_date( 'Y-m-d H:i:s', strtotime( '-' . $wpide_gf_window ) ),\n        'field_filters' => array( array( 'key' => 'ip', 'value' => GFFormsModel::get_ip() ) ),\n    ) );\n\n    if ( $count >= $wpide_gf_max_entries ) {\n        $validation['is_valid'] = false;\n        foreach ( $form['fields'] as $field ) {\n            if ( ! in_array( $field->type, array( 'hidden', 'html', 'section', 'page' ), true ) ) {\n                $field->failed_validation  = true;\n                $field->validation_message = 'You have sent several messages recently. Please try again later.';\n                break;\n            }\n        }\n        $validation['form'] = $form;\n    }\n\n    return $validation;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Counts entries already stored for that IP across all forms; set a form ID in the list to limit only specific forms."
        },
        {
            "id": "gf-one-entry-per-email",
            "version": 1,
            "title": "One Gravity Forms Entry per Email Address per Day",
            "description": "Prevents the same email address from submitting a Gravity Forms form more than once in 24 hours, useful for giveaways, sign-ups and quote forms.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "duplicates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_daily_forms = array(); // Form IDs to protect, e.g. array( 1 ).\n$wpide_gf_daily_hours = 24;\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_daily_forms, $wpide_gf_daily_hours ) {\n    if ( 'email' !== $field->type || ! $result['is_valid'] || ! in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_daily_forms ), true ) ) {\n        return $result;\n    }\n\n    $email = strtolower( trim( is_array( $value ) ? (string) reset( $value ) : (string) $value ) );\n    if ( ! is_email( $email ) ) {\n        return $result;\n    }\n\n    $count = GFAPI::count_entries( (int) $form['id'], array(\n        'status'        => 'active',\n        'start_date'    => wp_date( 'Y-m-d H:i:s', time() - $wpide_gf_daily_hours * HOUR_IN_SECONDS ),\n        'field_filters' => array( array( 'key' => (string) $field->id, 'value' => $email ) ),\n    ) );\n\n    if ( $count > 0 ) {\n        $result['is_valid'] = false;\n        $result['message']  = 'This email address has already been used today.';\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Only checks the forms listed at the top, using each form's first email field; Gravity Forms' own \"No Duplicates\" setting blocks repeats forever instead."
        },
        {
            "id": "gf-phone-validation",
            "version": 1,
            "title": "Stricter Phone Validation in Gravity Forms",
            "description": "Checks Gravity Forms phone fields set to the international format for a realistic number of digits and only valid characters, instead of accepting any text.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_phone_min = 7;\n$wpide_gf_phone_max = 15;\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_phone_min, $wpide_gf_phone_max ) {\n    if ( 'phone' !== $field->type || ! $result['is_valid'] || ( ! empty( $field->phoneFormat ) && 'standard' === $field->phoneFormat ) ) {\n        return $result;\n    }\n\n    $phone = trim( (string) $value );\n    if ( '' === $phone ) {\n        return $result;\n    }\n\n    $digits = strlen( preg_replace( '/\\D/', '', $phone ) );\n\n    if ( ! preg_match( '/^\\+?[0-9\\s().\\-]+$/', $phone ) || $digits < $wpide_gf_phone_min || $digits > $wpide_gf_phone_max ) {\n        $result['is_valid'] = false;\n        $result['message']  = 'Please enter a valid phone number, including the country code.';\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Fields using the US (###) ###-#### format keep Gravity Forms' own check; adjust the digit range for your country."
        },
        {
            "id": "gf-minimum-age",
            "version": 1,
            "title": "Minimum Age Check on a Gravity Forms Date of Birth",
            "description": "Rejects a Gravity Forms date of birth that makes the person younger than the age you set, for age-restricted sign-ups and applications.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_min_age = 18;\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_min_age ) {\n    if ( 'date' !== $field->type || ! $result['is_valid'] || false === strpos( ' ' . $field->cssClass . ' ', ' date-of-birth ' ) ) {\n        return $result;\n    }\n\n    if ( is_array( $value ) ) {\n        $value = implode( '/', array_values( $value ) );\n    }\n\n    $format = ! empty( $field->dateFormat ) ? $field->dateFormat : 'mdy';\n    $parts  = preg_split( '/[\\/\\-.]/', (string) $value );\n\n    if ( 3 !== count( $parts ) ) {\n        return $result;\n    }\n\n    $map = array( 'mdy' => array( 2, 0, 1 ), 'dmy' => array( 2, 1, 0 ), 'dmy_dash' => array( 2, 1, 0 ), 'dmy_dot' => array( 2, 1, 0 ), 'ymd_slash' => array( 0, 1, 2 ), 'ymd_dash' => array( 0, 1, 2 ), 'ymd_dot' => array( 0, 1, 2 ) );\n    $idx = isset( $map[ $format ] ) ? $map[ $format ] : $map['mdy'];\n\n    $birth = sprintf( '%04d-%02d-%02d', (int) $parts[ $idx[0] ], (int) $parts[ $idx[1] ], (int) $parts[ $idx[2] ] );\n    $limit = wp_date( 'Y-m-d', strtotime( '-' . $wpide_gf_min_age . ' years' ) );\n\n    if ( $birth > $limit ) {\n        $result['is_valid'] = false;\n        $result['message']  = sprintf( 'You must be at least %d years old.', $wpide_gf_min_age );\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Add the CSS class date-of-birth to the date field; works with every Gravity Forms date format."
        },
        {
            "id": "gf-prefill-user-details",
            "version": 1,
            "title": "Fill Gravity Forms Fields With the Logged-In User's Details",
            "description": "Lets Gravity Forms fields fill themselves with the visitor's name, email, username or role when they are logged in, using simple parameter names.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "prefill",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_user_params = array(\n    'user_email'        => 'user_email',\n    'user_first_name'   => 'first_name',\n    'user_last_name'    => 'last_name',\n    'user_display_name' => 'display_name',\n    'user_login'        => 'user_login',\n);\n\nforeach ( $wpide_gf_user_params as $wpide_param => $wpide_prop ) {\n    add_filter( 'gform_field_value_' . $wpide_param, function ( $value ) use ( $wpide_prop ) {\n        if ( '' !== (string) $value || ! is_user_logged_in() ) {\n            return $value;\n        }\n\n        return (string) wp_get_current_user()->get( $wpide_prop );\n    } );\n}\n\nadd_filter( 'gform_field_value_user_role', function ( $value ) {\n    if ( '' !== (string) $value || ! is_user_logged_in() ) {\n        return $value;\n    }\n\n    $roles = (array) wp_get_current_user()->roles;\n\n    return $roles ? (string) reset( $roles ) : '';\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "In a field's Advanced tab tick \"Allow field to be populated dynamically\" and enter one of the parameter names: user_email, user_first_name, user_last_name, user_display_name, user_login or user_role."
        },
        {
            "id": "gf-prefill-page-details",
            "version": 1,
            "title": "Record the Page and Referrer in Gravity Forms",
            "description": "Fills hidden Gravity Forms fields with the title and address of the page the form is on and the site the visitor came from, so you know the context of each entry.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "prefill",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_field_value_page_title', function ( $value ) {\n    return '' !== (string) $value ? $value : wp_strip_all_tags( (string) get_the_title( get_queried_object_id() ) );\n} );\n\nadd_filter( 'gform_field_value_page_url', function ( $value ) {\n    $id = get_queried_object_id();\n\n    return '' !== (string) $value ? $value : ( $id ? (string) get_permalink( $id ) : home_url( add_query_arg( array() ) ) );\n} );\n\nadd_filter( 'gform_field_value_referrer_url', function ( $value ) {\n    if ( '' !== (string) $value || empty( $_SERVER['HTTP_REFERER'] ) ) {\n        return $value;\n    }\n\n    return esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) );\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Allow dynamic population on hidden fields with the parameter names page_title, page_url or referrer_url."
        },
        {
            "id": "gf-choices-from-posts",
            "version": 1,
            "title": "Fill Gravity Forms Choices From Your Posts",
            "description": "Fills Gravity Forms drop-down, radio and checkbox fields with the titles of your posts, pages or a custom post type, so the choices update when you publish content.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "dynamic",
                "choices"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_populate = function ( $form ) {\n    foreach ( $form['fields'] as $field ) {\n        if ( ! in_array( $field->type, array( 'select', 'radio', 'checkbox', 'multiselect' ), true ) || ! preg_match( '/\\bpopulate-([a-z0-9_-]+)\\b/', (string) $field->cssClass, $m ) ) {\n            continue;\n        }\n\n        $type = 'posts' === $m[1] ? 'post' : $m[1];\n        if ( ! post_type_exists( $type ) ) {\n            continue;\n        }\n\n        $cache   = 'wpide_gf_choices_' . $type;\n        $choices = get_transient( $cache );\n\n        if ( false === $choices ) {\n            $choices = array();\n            $ids     = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'numberposts' => 200, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n            foreach ( $ids as $id ) {\n                $title     = wp_strip_all_tags( get_the_title( $id ) );\n                $choices[] = array( 'text' => $title, 'value' => $title );\n            }\n            set_transient( $cache, $choices, HOUR_IN_SECONDS );\n        }\n\n        $field->choices = $choices;\n\n        if ( 'checkbox' === $field->type ) {\n            $inputs = array();\n            foreach ( $choices as $i => $choice ) {\n                $inputs[] = array( 'id' => $field->id . '.' . ( $i + 1 + (int) floor( $i / 9 ) ), 'label' => $choice['text'] );\n            }\n            $field->inputs = $inputs;\n        }\n    }\n\n    return $form;\n};\n\nadd_filter( 'gform_pre_render', $wpide_gf_populate );\nadd_filter( 'gform_pre_validation', $wpide_gf_populate );\nadd_filter( 'gform_pre_submission_filter', $wpide_gf_populate );\nadd_filter( 'gform_admin_pre_render', $wpide_gf_populate );\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    delete_transient( 'wpide_gf_choices_' . $post->post_type );\n}, 10, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Give the field the CSS class populate-posts (or populate-<post type>, such as populate-product); the list is cached for an hour and refreshed when posts change."
        },
        {
            "id": "gf-choices-from-users",
            "version": 1,
            "title": "Fill Gravity Forms Choices With Team Members",
            "description": "Fills a Gravity Forms drop-down or radio field with the names of users in a role, such as staff or agents, so visitors can choose who they want to contact.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "dynamic",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_users = function ( $form ) {\n    foreach ( $form['fields'] as $field ) {\n        if ( ! in_array( $field->type, array( 'select', 'radio' ), true ) || ! preg_match( '/\\bpopulate-role-([a-z0-9_-]+)\\b/', (string) $field->cssClass, $m ) ) {\n            continue;\n        }\n\n        $users = get_users( array( 'role' => $m[1], 'orderby' => 'display_name', 'fields' => array( 'ID', 'display_name' ), 'number' => 200 ) );\n\n        $field->choices = array_map( function ( $user ) {\n            return array( 'text' => $user->display_name, 'value' => (string) $user->ID );\n        }, $users );\n    }\n\n    return $form;\n};\n\nadd_filter( 'gform_pre_render', $wpide_gf_users );\nadd_filter( 'gform_pre_validation', $wpide_gf_users );\nadd_filter( 'gform_pre_submission_filter', $wpide_gf_users );\nadd_filter( 'gform_admin_pre_render', $wpide_gf_users );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Give the field the CSS class populate-role-<role> (for example populate-role-editor); values are user IDs, so pair it with a routing snippet if you email the chosen person."
        },
        {
            "id": "gf-redirect-by-answer",
            "version": 1,
            "title": "Redirect Gravity Forms Based on an Answer",
            "description": "Sends people to a different page after submitting a Gravity Forms form depending on what they chose in one field, such as a sales or support thank-you page.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "redirect",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_redirect_form  = 0;  // Form ID (0 = off until you set it).\n$wpide_gf_redirect_field = 6;  // Field ID holding the answer.\n// Answer value => URL.\n$wpide_gf_redirect_map = array(\n    'sales'   => '/thank-you-sales/',\n    'support' => '/thank-you-support/',\n);\n\nadd_filter( 'gform_confirmation_' . $wpide_gf_redirect_form, function ( $confirmation, $form, $entry ) use ( $wpide_gf_redirect_field, $wpide_gf_redirect_map ) {\n    $answer = (string) rgar( $entry, (string) $wpide_gf_redirect_field );\n\n    if ( isset( $wpide_gf_redirect_map[ $answer ] ) ) {\n        return array( 'redirect' => esc_url_raw( home_url( $wpide_gf_redirect_map[ $answer ] ) ) );\n    }\n\n    return $confirmation;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Set the form ID, field ID and the value-to-URL map at the top; other answers keep the form's normal confirmation."
        },
        {
            "id": "gf-webhook",
            "version": 1,
            "title": "Send Gravity Forms Entries to a Webhook",
            "description": "Posts every new Gravity Forms entry as JSON, with field labels as keys, to a webhook URL such as Zapier, Make, n8n or your CRM, without the paid webhooks add-on.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "webhook",
                "integration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_webhook_forms = array(); // Form IDs to send; empty = all forms.\n\nadd_action( 'gform_after_submission', function ( $entry, $form ) use ( $wpide_gf_webhook_forms ) {\n    $url = defined( 'WPIDE_GF_WEBHOOK_URL' ) ? (string) WPIDE_GF_WEBHOOK_URL : '';\n\n    if ( ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'https', 'http' ), true ) || 'spam' === rgar( $entry, 'status' ) ) {\n        return;\n    }\n    if ( $wpide_gf_webhook_forms && ! in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_webhook_forms ), true ) ) {\n        return;\n    }\n\n    $fields = array();\n    foreach ( $form['fields'] as $field ) {\n        if ( in_array( $field->type, array( 'html', 'section', 'page', 'captcha' ), true ) ) {\n            continue;\n        }\n        $label            = $field->adminLabel ? $field->adminLabel : $field->label;\n        $fields[ $label ] = $field->get_value_export( $entry );\n    }\n\n    $body = wp_json_encode( array(\n        'form_id'    => (int) $form['id'],\n        'form_title' => $form['title'],\n        'entry_id'   => (int) rgar( $entry, 'id' ),\n        'fields'     => $fields,\n        'source_url' => rgar( $entry, 'source_url' ),\n        'created'    => rgar( $entry, 'date_created' ),\n        'site'       => home_url(),\n    ) );\n\n    $headers = array( 'Content-Type' => 'application/json' );\n    if ( defined( 'WPIDE_GF_WEBHOOK_SECRET' ) && WPIDE_GF_WEBHOOK_SECRET ) {\n        $headers['X-Signature'] = hash_hmac( 'sha256', $body, WPIDE_GF_WEBHOOK_SECRET );\n    }\n\n    wp_remote_post( $url, array( 'body' => $body, 'headers' => $headers, 'timeout' => 5, 'blocking' => false ) );\n}, 10, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Define WPIDE_GF_WEBHOOK_URL (and optionally WPIDE_GF_WEBHOOK_SECRET for an X-Signature HMAC header) in wp-config.php; spam entries are never sent."
        },
        {
            "id": "gf-slack-notification",
            "version": 1,
            "title": "Post Gravity Forms Entries to Slack",
            "description": "Sends a short summary of each new Gravity Forms entry to a Slack channel with a link to the entry, so your team can respond to leads quickly.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "slack",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'gform_after_submission', function ( $entry, $form ) {\n    $url = defined( 'WPIDE_SLACK_WEBHOOK_URL' ) ? (string) WPIDE_SLACK_WEBHOOK_URL : '';\n\n    if ( 0 !== strpos( $url, 'https://hooks.slack.com/' ) || 'spam' === rgar( $entry, 'status' ) ) {\n        return;\n    }\n\n    $escape = function ( $text ) {\n        return str_replace( array( '&', '<', '>' ), array( '&amp;', '&lt;', '&gt;' ), (string) $text );\n    };\n\n    $lines = array( sprintf( '*New entry in \"%s\"*', $escape( $form['title'] ) ) );\n    foreach ( $form['fields'] as $field ) {\n        $value = trim( wp_strip_all_tags( (string) $field->get_value_export( $entry ) ) );\n        if ( '' !== $value && count( $lines ) < 6 ) {\n            $lines[] = sprintf( '*%s:* %s', $escape( $field->label ), $escape( wp_html_excerpt( $value, 300, '…' ) ) );\n        }\n    }\n    $lines[] = '<' . admin_url( 'admin.php?page=gf_entries&view=entry&id=' . (int) $form['id'] . '&lid=' . (int) rgar( $entry, 'id' ) ) . '|Open the entry>';\n\n    wp_safe_remote_post( $url, array(\n        'body'     => wp_json_encode( array( 'text' => implode( \"\\n\", $lines ) ) ),\n        'headers'  => array( 'Content-Type' => 'application/json' ),\n        'timeout'  => 5,\n        'blocking' => false,\n    ) );\n}, 10, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Create a Slack incoming webhook and define WPIDE_SLACK_WEBHOOK_URL in wp-config.php; only the first five filled fields are included."
        },
        {
            "id": "gf-notify-page-author",
            "version": 1,
            "title": "Send Gravity Forms Notifications to the Page Author",
            "description": "Sends the admin notification of a Gravity Forms form to the author of the post or page the form was submitted from, ideal for agent, listing or staff profile pages.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "notifications",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_author_notifications = array( 'Admin Notification' ); // Notification names to reroute.\n\nadd_filter( 'gform_notification', function ( $notification, $form, $entry ) use ( $wpide_gf_author_notifications ) {\n    if ( ! in_array( rgar( $notification, 'name' ), $wpide_gf_author_notifications, true ) ) {\n        return $notification;\n    }\n\n    $post_id = url_to_postid( (string) rgar( $entry, 'source_url' ) );\n    $post    = $post_id ? get_post( $post_id ) : null;\n    $author  = $post ? get_userdata( (int) $post->post_author ) : false;\n\n    if ( $author && is_email( $author->user_email ) ) {\n        $notification['toType'] = 'email';\n        $notification['to']     = $author->user_email;\n    }\n\n    return $notification;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Only notifications named in the list at the top are changed; the original recipient is used when the author has no email."
        },
        {
            "id": "gf-bcc-archive",
            "version": 1,
            "title": "Copy All Gravity Forms Notifications to an Archive Address",
            "description": "Adds a blind copy of every Gravity Forms notification to an archive or CRM inbox, so you keep a record of all submissions even if entries are deleted.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "notifications",
                "bcc"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_archive_email = 'forms-archive@example.com';\n\nadd_filter( 'gform_notification', function ( $notification ) use ( $wpide_gf_archive_email ) {\n    if ( ! is_email( $wpide_gf_archive_email ) ) {\n        return $notification;\n    }\n\n    $bcc = trim( (string) rgar( $notification, 'bcc' ) );\n    $notification['bcc'] = $bcc ? $bcc . ',' . $wpide_gf_archive_email : $wpide_gf_archive_email;\n\n    return $notification;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Existing BCC addresses on a notification are kept; set the archive address at the top."
        },
        {
            "id": "gf-disable-notifications-staging",
            "version": 1,
            "title": "Stop Gravity Forms Notifications on Staging Sites",
            "description": "Turns off all Gravity Forms email notifications when the site is not the production environment, so tests on staging never email clients or your team.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "notifications",
                "staging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_disable_notification', function ( $disabled ) {\n    return 'production' !== wp_get_environment_type() ? true : $disabled;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Uses the WP_ENVIRONMENT_TYPE setting; set it to staging, development or local in wp-config.php on copies of the site."
        },
        {
            "id": "gf-subject-entry-reference",
            "version": 1,
            "title": "Add the Entry Number to Gravity Forms Email Subjects",
            "description": "Prefixes every Gravity Forms notification subject with the form name and entry ID, so you can find, filter and refer to submissions easily.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "notifications",
                "subject"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_subject_format = '[%1$s #%2$d] ';\n\nadd_filter( 'gform_notification', function ( $notification, $form, $entry ) use ( $wpide_gf_subject_format ) {\n    $prefix = sprintf( $wpide_gf_subject_format, $form['title'], (int) rgar( $entry, 'id' ) );\n\n    if ( 0 !== strpos( (string) rgar( $notification, 'subject' ), $prefix ) ) {\n        $notification['subject'] = $prefix . rgar( $notification, 'subject' );\n    }\n\n    return $notification;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Change the format at the top; %1$s is the form title and %2$d the entry ID."
        },
        {
            "id": "gf-reply-to-submitter",
            "version": 1,
            "title": "Reply Directly to People Who Fill In Gravity Forms",
            "description": "Sets the Reply-To of Gravity Forms admin notifications to the email address the visitor entered, so hitting Reply answers them instead of your own site.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "notifications",
                "reply-to"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_notification', function ( $notification, $form, $entry ) {\n    if ( '' !== trim( (string) rgar( $notification, 'replyTo' ) ) ) {\n        return $notification;\n    }\n\n    foreach ( $form['fields'] as $field ) {\n        if ( 'email' === $field->type ) {\n            $email = sanitize_email( (string) rgar( $entry, (string) $field->id ) );\n            if ( is_email( $email ) ) {\n                $notification['replyTo'] = $email;\n            }\n            break;\n        }\n    }\n\n    return $notification;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Notifications that already have a Reply-To keep it; the first email field in the form is used."
        },
        {
            "id": "gf-scroll-to-confirmation",
            "version": 1,
            "title": "Scroll to the Gravity Forms Confirmation Message",
            "description": "Makes every Gravity Forms form jump to its confirmation or error message after submitting, so visitors on long pages and phones see the result.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "ux",
                "scroll"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_confirmation_anchor', '__return_true' );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Applies to all forms; return false in the filter for a form where you want to stay at the top."
        },
        {
            "id": "gf-button-element",
            "version": 1,
            "title": "Use a Real Button for the Gravity Forms Submit",
            "description": "Replaces the Gravity Forms submit input with a button element that carries your theme's button classes, so it matches the rest of the site and can hold icons.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "markup",
                "buttons"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_button_classes = 'wp-element-button';\n\nadd_filter( 'gform_submit_button', function ( $button, $form ) use ( $wpide_gf_button_classes ) {\n    if ( false === stripos( $button, '<input' ) ) {\n        return $button;\n    }\n\n    $processor = class_exists( 'WP_HTML_Tag_Processor' ) ? new WP_HTML_Tag_Processor( $button ) : null;\n    if ( ! $processor || ! $processor->next_tag( 'input' ) ) {\n        return $button;\n    }\n\n    $attributes = array();\n    foreach ( (array) $processor->get_attribute_names_with_prefix( '' ) as $name ) {\n        if ( 'type' === $name || 'value' === $name ) {\n            continue;\n        }\n        $value = $processor->get_attribute( $name );\n        $attributes[] = true === $value ? esc_attr( $name ) : sprintf( '%s=\"%s\"', esc_attr( $name ), esc_attr( 'class' === $name ? $value . ' ' . $wpide_gf_button_classes : $value ) );\n    }\n\n    $label = (string) $processor->get_attribute( 'value' );\n\n    return sprintf( '<button type=\"submit\" %1$s><span>%2$s</span></button>', implode( ' ', $attributes ), esc_html( $label ) );\n}, 10, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Set the classes at the top to your theme's button classes, such as wp-element-button or btn btn-primary."
        },
        {
            "id": "gf-disable-tabindex",
            "version": 1,
            "title": "Remove Tabindex From Gravity Forms Fields",
            "description": "Stops Gravity Forms adding tabindex numbers to fields, which can make keyboard users jump around the page in the wrong order, especially with several forms on one page.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "accessibility",
                "keyboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_tabindex', '__return_false' );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Fields then follow the natural document order, which is what screen reader and keyboard users expect."
        },
        {
            "id": "gf-validation-message",
            "version": 1,
            "title": "Clearer Gravity Forms Error Summary",
            "description": "Replaces the error message at the top of a Gravity Forms form with your own wording and lists how many fields need attention, announced to screen readers.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_error_text = 'Almost there! Please fix the %d highlighted field(s) below.';\n\nadd_filter( 'gform_validation_message', function ( $message, $form ) use ( $wpide_gf_error_text ) {\n    $count = 0;\n    foreach ( $form['fields'] as $field ) {\n        if ( ! empty( $field->failed_validation ) ) {\n            $count++;\n        }\n    }\n\n    return sprintf( '<div class=\"gform_validation_errors validation_error\" role=\"alert\"><h2 class=\"gform_submission_error\">%s</h2></div>', esc_html( sprintf( $wpide_gf_error_text, max( 1, $count ) ) ) );\n}, 10, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Edit the sentence at the top; %d is replaced with the number of fields that need fixing."
        },
        {
            "id": "gf-delete-old-entries",
            "version": 1,
            "title": "Delete Old Gravity Forms Entries Automatically",
            "description": "Deletes Gravity Forms entries older than the number of days you choose once a day, so you do not keep personal data longer than you need.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "privacy",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_keep_days   = 0;       // Days to keep entries, e.g. 180 (0 = off until you set it).\n$wpide_gf_purge_forms = array(); // Form IDs; empty = all forms.\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_gf_purge_entries' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_gf_purge_entries' );\n    }\n} );\n\nadd_action( 'wpide_gf_purge_entries', function () use ( $wpide_gf_keep_days, $wpide_gf_purge_forms ) {\n    if ( ! class_exists( 'GFAPI' ) || $wpide_gf_keep_days < 1 ) {\n        return;\n    }\n\n    $ids = GFAPI::get_entry_ids(\n        $wpide_gf_purge_forms ? array_map( 'intval', $wpide_gf_purge_forms ) : 0,\n        array( 'end_date' => wp_date( 'Y-m-d H:i:s', time() - $wpide_gf_keep_days * DAY_IN_SECONDS ) ),\n        null,\n        array( 'offset' => 0, 'page_size' => 100 )\n    );\n\n    foreach ( $ids as $id ) {\n        GFAPI::delete_entry( $id );\n    }\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Does nothing until you set the number of days at the top; it then deletes old entries permanently (including uploaded files) in batches of 100 per daily run, so export anything you need first."
        },
        {
            "id": "gf-clean-names-emails",
            "version": 1,
            "title": "Tidy Names and Emails in Gravity Forms Entries",
            "description": "Trims spaces, lowercases email addresses and capitalizes names in Gravity Forms entries before they are saved, so your lists and CRM exports look consistent.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "data",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_save_field_value', function ( $value, $entry, $field, $form, $input_id ) {\n    if ( ! is_string( $value ) || ! is_object( $field ) ) {\n        return $value;\n    }\n\n    $value = trim( $value );\n\n    if ( 'email' === $field->type ) {\n        return strtolower( $value );\n    }\n\n    if ( 'name' === $field->type && ( $value === strtolower( $value ) || $value === strtoupper( $value ) ) ) {\n        return function_exists( 'mb_convert_case' ) ? mb_convert_case( $value, MB_CASE_TITLE, 'UTF-8' ) : ucwords( strtolower( $value ) );\n    }\n\n    return $value;\n}, 10, 5 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Names already in mixed case (such as McDonald) are left as typed; only all-lowercase or all-uppercase names are changed."
        },
        {
            "id": "gf-default-upload-limits",
            "version": 1,
            "title": "Default Upload Limits for Gravity Forms File Fields",
            "description": "Gives every Gravity Forms file upload field a safe list of allowed file types and a maximum file size when the field does not set its own.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "uploads",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_upload_ext = 'pdf,doc,docx,jpg,jpeg,png'; // Allowed extensions.\n$wpide_gf_upload_mb  = 5;                           // Maximum size per file in MB.\n\n$wpide_gf_upload_defaults = function ( $form ) use ( $wpide_gf_upload_ext, $wpide_gf_upload_mb ) {\n    foreach ( $form['fields'] as $field ) {\n        if ( 'fileupload' !== $field->type ) {\n            continue;\n        }\n        if ( empty( $field->allowedExtensions ) ) {\n            $field->allowedExtensions = $wpide_gf_upload_ext;\n        }\n        if ( empty( $field->maxFileSize ) ) {\n            $field->maxFileSize = $wpide_gf_upload_mb;\n        }\n    }\n\n    return $form;\n};\n\nadd_filter( 'gform_pre_render', $wpide_gf_upload_defaults );\nadd_filter( 'gform_pre_validation', $wpide_gf_upload_defaults );\nadd_filter( 'gform_pre_submission_filter', $wpide_gf_upload_defaults );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Values set in the field's own settings always win; the server's upload limit must also allow the size."
        },
        {
            "id": "gf-custom-merge-tags",
            "version": 1,
            "title": "Extra Merge Tags for Gravity Forms",
            "description": "Adds {site_name}, {site_url}, {form_entry_count} and {current_year} merge tags you can use in Gravity Forms notifications and confirmations.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "merge-tags",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_custom_merge_tags', function ( $tags ) {\n    $tags[] = array( 'label' => 'Site name', 'tag' => '{site_name}' );\n    $tags[] = array( 'label' => 'Site URL', 'tag' => '{site_url}' );\n    $tags[] = array( 'label' => 'Entries in this form', 'tag' => '{form_entry_count}' );\n    $tags[] = array( 'label' => 'Current year', 'tag' => '{current_year}' );\n\n    return $tags;\n} );\n\nadd_filter( 'gform_replace_merge_tags', function ( $text, $form, $entry, $url_encode = false, $esc_html = false ) {\n    if ( false === strpos( (string) $text, '{' ) ) {\n        return $text;\n    }\n\n    $values = array(\n        '{site_name}'    => wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ),\n        '{site_url}'     => home_url( '/' ),\n        '{current_year}' => wp_date( 'Y' ),\n    );\n\n    if ( false !== strpos( $text, '{form_entry_count}' ) && is_array( $form ) && class_exists( 'GFAPI' ) ) {\n        $values['{form_entry_count}'] = (string) GFAPI::count_entries( (int) $form['id'], array( 'status' => 'active' ) );\n    }\n\n    foreach ( $values as $tag => $value ) {\n        if ( $esc_html ) {\n            $value = esc_html( $value );\n        }\n        $text = str_replace( $tag, $url_encode ? rawurlencode( $value ) : $value, $text );\n    }\n\n    return $text;\n}, 10, 5 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "The tags appear in the merge tag menu of notification and confirmation editors."
        },
        {
            "id": "gf-modern-styles",
            "version": 1,
            "title": "Clean Modern Styling for Gravity Forms",
            "description": "Gives Gravity Forms fields rounded borders, comfortable spacing, a visible focus ring, clear error colors and a bold submit button, while following your theme's fonts.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "styling",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".gform_wrapper {\n    --wpide-gf-accent: #0f766e;\n    --wpide-gf-border: #cbd5e1;\n    --wpide-gf-error: #b91c1c;\n    --wpide-gf-radius: 8px;\n}\n\n.gform_wrapper .gfield input[type=\"text\"],\n.gform_wrapper .gfield input[type=\"email\"],\n.gform_wrapper .gfield input[type=\"tel\"],\n.gform_wrapper .gfield input[type=\"url\"],\n.gform_wrapper .gfield input[type=\"number\"],\n.gform_wrapper .gfield select,\n.gform_wrapper .gfield textarea {\n    padding: 0.7em 0.9em;\n    border: 1px solid var(--wpide-gf-border);\n    border-radius: var(--wpide-gf-radius);\n    font-size: max(16px, 1em);\n    line-height: 1.4;\n}\n\n.gform_wrapper .gfield input:focus-visible,\n.gform_wrapper .gfield select:focus-visible,\n.gform_wrapper .gfield textarea:focus-visible {\n    outline: 2px solid var(--wpide-gf-accent);\n    outline-offset: 1px;\n    border-color: var(--wpide-gf-accent);\n}\n\n.gform_wrapper .gfield_label {\n    font-weight: 600;\n}\n\n.gform_wrapper .gform_footer input[type=\"submit\"],\n.gform_wrapper .gform_footer button[type=\"submit\"],\n.gform_wrapper .gform_page_footer input[type=\"button\"] {\n    padding: 0.75em 1.6em;\n    border: 0;\n    border-radius: var(--wpide-gf-radius);\n    background: var(--wpide-gf-accent);\n    color: #fff;\n    font-weight: 600;\n    cursor: pointer;\n}\n\n.gform_wrapper .gfield_error input,\n.gform_wrapper .gfield_error select,\n.gform_wrapper .gfield_error textarea {\n    border-color: var(--wpide-gf-error);\n}\n\n.gform_wrapper .gfield_validation_message,\n.gform_wrapper .validation_message {\n    color: var(--wpide-gf-error);\n    background: none;\n    border: 0;\n    padding: 0.3em 0 0;\n}\n\n.gform_wrapper .gform_validation_errors {\n    border-radius: var(--wpide-gf-radius);\n}",
            "requires": [
                "gravityforms"
            ],
            "notes": "Built for the Gravity Forms 2.5+ markup; change the custom properties at the top for your colors."
        },
        {
            "id": "gf-form-theme-scss",
            "version": 1,
            "title": "Gravity Forms Theme With SCSS Variables",
            "description": "Styles Gravity Forms from a handful of SCSS variables (accent, radius, spacing, error color), including choices, multi-page progress bars, buttons and dark mode.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "scss",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #db2777;\n$radius: 10px;\n$border: #d4d4d8;\n$error: #dc2626;\n$gap: 1.25rem;\n\n.gform_wrapper {\n    .gform_fields {\n        row-gap: $gap;\n    }\n\n    .gfield {\n        input:not([type=\"submit\"]):not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"button\"]),\n        select,\n        textarea {\n            padding: 0.75em 1em;\n            border: 1px solid $border;\n            border-radius: $radius;\n            font-size: max(16px, 1em);\n            transition: border-color 0.15s, box-shadow 0.15s;\n\n            &:focus {\n                outline: none;\n                border-color: $accent;\n                box-shadow: 0 0 0 3px rgba($accent, 0.2);\n            }\n        }\n\n        input[type=\"checkbox\"],\n        input[type=\"radio\"] {\n            accent-color: $accent;\n        }\n    }\n\n    .gf_progressbar_percentage {\n        background: $accent;\n        border-radius: $radius;\n    }\n\n    .gform_footer,\n    .gform_page_footer {\n        input[type=\"submit\"],\n        button,\n        input[type=\"button\"] {\n            padding: 0.8em 1.8em;\n            border: 0;\n            border-radius: $radius;\n            background: $accent;\n            color: #fff;\n            font-weight: 600;\n            cursor: pointer;\n\n            &:hover {\n                background: darken($accent, 8%);\n            }\n        }\n\n        .gform_previous_button {\n            background: transparent;\n            color: $accent;\n            box-shadow: inset 0 0 0 2px $accent;\n        }\n    }\n\n    .gfield_error {\n        input,\n        select,\n        textarea {\n            border-color: $error !important;\n        }\n    }\n\n    @media (prefers-color-scheme: dark) {\n        .gfield input:not([type=\"submit\"]):not([type=\"checkbox\"]):not([type=\"radio\"]),\n        .gfield select,\n        .gfield textarea {\n            background: #18181b;\n            color: #f4f4f5;\n            border-color: #3f3f46;\n        }\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        .gfield input,\n        .gfield select,\n        .gfield textarea {\n            transition: none;\n        }\n    }\n}",
            "requires": [
                "gravityforms"
            ],
            "notes": "Edit the variables at the top; written for the Gravity Forms 2.5+ markup."
        },
        {
            "id": "gf-date-year-range",
            "version": 1,
            "title": "Sensible Year Range for Gravity Forms Date Drop-Downs",
            "description": "Sets the years offered in Gravity Forms date drop-downs to a range that fits your form, such as the last 100 years for birth dates or the next two years for bookings.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "date",
                "fields"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_years_back    = 100;\n$wpide_gf_years_forward = 2;\n\nadd_filter( 'gform_date_min_year', function () use ( $wpide_gf_years_back ) {\n    return (int) wp_date( 'Y' ) - $wpide_gf_years_back;\n} );\n\nadd_filter( 'gform_date_max_year', function () use ( $wpide_gf_years_forward ) {\n    return (int) wp_date( 'Y' ) + $wpide_gf_years_forward;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Applies to date fields using the drop-down input type; adjust how many years back and forward to offer."
        },
        {
            "id": "gf-countries-shortlist",
            "version": 1,
            "title": "Put Your Main Countries First in Gravity Forms",
            "description": "Moves the countries you sell to most to the top of Gravity Forms address and country drop-downs, so most visitors find theirs without scrolling.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "address",
                "countries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_top_countries = array( 'United States', 'Canada', 'United Kingdom' );\n$wpide_gf_only_listed   = false; // True = offer only the countries above.\n\nadd_filter( 'gform_countries', function ( $countries ) use ( $wpide_gf_top_countries, $wpide_gf_only_listed ) {\n    $countries = (array) $countries;\n    $top       = array_values( array_intersect( $wpide_gf_top_countries, $countries ) );\n\n    if ( $wpide_gf_only_listed ) {\n        return $top;\n    }\n\n    return array_merge( $top, array_values( array_diff( $countries, $top ) ) );\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Use the English country names exactly as Gravity Forms lists them; set $wpide_gf_only_listed to true to offer only those countries."
        },
        {
            "id": "gf-ga4-conversion-event",
            "version": 1,
            "title": "Track Gravity Forms Submissions in Google Analytics 4",
            "description": "Sends a generate_lead event to Google Analytics 4 or Google Tag Manager when a Gravity Forms form shows its confirmation, including AJAX forms.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "analytics",
                "ga4"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_gf_event_name = 'generate_lead';\n\nadd_action( 'wp_footer', function () use ( $wpide_gf_event_name ) {\n    if ( ! wp_script_is( 'gform_gravityforms', 'enqueued' ) && ! wp_script_is( 'gform_gravityforms', 'done' ) ) {\n        return;\n    }\n    ?>\n<script>\n( function () {\n    var name = <?php echo wp_json_encode( $wpide_gf_event_name ); ?>;\n    function send( formId ) {\n        var data = { form_id: String( formId ), form_type: 'gravity-forms' };\n        if ( typeof window.gtag === 'function' ) { window.gtag( 'event', name, data ); }\n        else { window.dataLayer = window.dataLayer || []; window.dataLayer.push( Object.assign( { event: name }, data ) ); }\n    }\n    if ( window.jQuery ) {\n        window.jQuery( document ).on( 'gform_confirmation_loaded', function ( event, formId ) { send( formId ); } );\n    }\n    document.querySelectorAll( '.gform_confirmation_wrapper' ).forEach( function ( el ) {\n        var match = el.id.match( /gform_confirmation_wrapper_(\\d+)/ );\n        if ( match ) { send( match[1] ); }\n    } );\n} )();\n</script>\n    <?php\n}, 100 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Needs gtag.js or a GTM container already on the site; non-AJAX forms are tracked when the confirmation page loads."
        },
        {
            "id": "gf-block-ip-addresses",
            "version": 1,
            "title": "Block IP Addresses From Gravity Forms",
            "description": "Stops Gravity Forms submissions from IP addresses or ranges you list and marks them as spam, without blocking those visitors from reading the site.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "ip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_blocked_ips = array( '203.0.113.7', '198.51.100.' );\n\nadd_filter( 'gform_entry_is_spam', function ( $is_spam, $form, $entry ) use ( $wpide_gf_blocked_ips ) {\n    if ( $is_spam ) {\n        return $is_spam;\n    }\n\n    $ip = (string) rgar( $entry, 'ip' );\n\n    foreach ( $wpide_gf_blocked_ips as $blocked ) {\n        $prefix = in_array( substr( $blocked, -1 ), array( '.', ':' ), true );\n        if ( $ip === $blocked || ( $prefix && 0 === strpos( $ip, $blocked ) ) ) {\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Use exact addresses or prefixes ending in a dot or colon (198.51.100.) for ranges."
        },
        {
            "id": "gf-word-limits",
            "version": 1,
            "title": "Word Limits for Gravity Forms Paragraph Fields",
            "description": "Lets you require a minimum and maximum number of words in Gravity Forms paragraph fields, for essays, applications and testimonials.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "textarea"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) {\n    if ( 'textarea' !== $field->type || ! $result['is_valid'] || ! preg_match( '/\\bwords-(\\d+)-(\\d+)\\b/', (string) $field->cssClass, $m ) ) {\n        return $result;\n    }\n\n    $text  = trim( wp_strip_all_tags( (string) $value ) );\n    if ( '' === $text && ! $field->isRequired ) {\n        return $result;\n    }\n\n    $words = preg_match_all( '/[\\p{L}\\p{N}\\']+/u', $text );\n    $min   = (int) $m[1];\n    $max   = (int) $m[2];\n\n    if ( $words < $min ) {\n        $result = array( 'is_valid' => false, 'message' => sprintf( 'Please write at least %1$d words (currently %2$d).', $min, $words ) );\n    } elseif ( $max > 0 && $words > $max ) {\n        $result = array( 'is_valid' => false, 'message' => sprintf( 'Please keep it under %1$d words (currently %2$d).', $max, $words ) );\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Add a CSS class such as words-50-300 to a paragraph field to require 50 to 300 words; use words-50-0 for a minimum only."
        },
        {
            "id": "gf-future-dates-only",
            "version": 1,
            "title": "Allow Only Future Dates in Gravity Forms Date Fields",
            "description": "Rejects dates in the past in Gravity Forms date fields used for bookings, deliveries or appointments, with an optional number of days' notice.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "validation",
                "date"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_notice_days = 1; // Days of notice required; 0 allows today.\n\nadd_filter( 'gform_field_validation', function ( $result, $value, $form, $field ) use ( $wpide_gf_notice_days ) {\n    if ( 'date' !== $field->type || ! $result['is_valid'] || false === strpos( ' ' . $field->cssClass . ' ', ' future-date ' ) || empty( $value ) ) {\n        return $result;\n    }\n\n    if ( is_array( $value ) ) {\n        $value = implode( '/', array_values( $value ) );\n    }\n\n    $format = ! empty( $field->dateFormat ) ? $field->dateFormat : 'mdy';\n    $parts  = preg_split( '/[\\/\\-.]/', (string) $value );\n    if ( 3 !== count( $parts ) ) {\n        return $result;\n    }\n\n    $map  = array( 'mdy' => array( 2, 0, 1 ), 'dmy' => array( 2, 1, 0 ), 'dmy_dash' => array( 2, 1, 0 ), 'dmy_dot' => array( 2, 1, 0 ), 'ymd_slash' => array( 0, 1, 2 ), 'ymd_dash' => array( 0, 1, 2 ), 'ymd_dot' => array( 0, 1, 2 ) );\n    $idx  = isset( $map[ $format ] ) ? $map[ $format ] : $map['mdy'];\n    $date = sprintf( '%04d-%02d-%02d', (int) $parts[ $idx[0] ], (int) $parts[ $idx[1] ], (int) $parts[ $idx[2] ] );\n\n    if ( $date < wp_date( 'Y-m-d', time() + $wpide_gf_notice_days * DAY_IN_SECONDS ) ) {\n        $result['is_valid'] = false;\n        $result['message']  = $wpide_gf_notice_days ? sprintf( 'Please choose a date at least %d day(s) from today.', $wpide_gf_notice_days ) : 'Please choose today or a later date.';\n    }\n\n    return $result;\n}, 10, 4 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Add the CSS class future-date to a date field; set the minimum notice in days at the top (0 allows today)."
        },
        {
            "id": "gf-dont-store-ip",
            "version": 1,
            "title": "Stop Gravity Forms Storing Visitors' IP Addresses",
            "description": "Saves Gravity Forms entries without the visitor's IP address, which helps with privacy rules when you have no need to keep it.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "privacy",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_ip_address', '__return_empty_string' );",
            "requires": [
                "gravityforms"
            ],
            "notes": "IP-based features such as rate limiting by stored IP will no longer work after enabling this."
        },
        {
            "id": "gf-delete-entries-after-notify",
            "version": 1,
            "title": "Don't Keep Entries for Certain Gravity Forms Forms",
            "description": "Deletes the entry of the forms you list right after its notifications are sent, so sensitive submissions live only in your inbox and never in the database.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "privacy",
                "entries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_no_storage_forms = array(); // Form IDs whose entries should not be kept, e.g. array( 3 ).\n\nadd_action( 'gform_after_submission', function ( $entry, $form ) use ( $wpide_gf_no_storage_forms ) {\n    if ( in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_no_storage_forms ), true ) && ! empty( $entry['id'] ) ) {\n        GFAPI::delete_entry( (int) $entry['id'] );\n    }\n}, 999, 2 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Runs after notifications and add-on feeds that process on submission; delayed feeds such as payment add-ons would lose their entry, so only list simple contact forms."
        },
        {
            "id": "wpforms-block-email-domains",
            "version": 1,
            "title": "Block Email Domains in WPForms",
            "description": "Shows an error when an email address from a domain on your block list, such as throwaway inbox services, is entered in any WPForms email field.",
            "category": "forms",
            "tags": [
                "wpforms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_blocked_domains = array( 'mailinator.com', 'yopmail.com', 'tempmail.com', 'guerrillamail.com', 'sharklasers.com' );\n\nadd_action( 'wpforms_process_validate_email', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_blocked_domains ) {\n    $email  = strtolower( trim( is_array( $field_submit ) ? (string) reset( $field_submit ) : (string) $field_submit ) );\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    foreach ( $wpide_wpf_blocked_domains as $blocked ) {\n        if ( $domain && ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) ) {\n            $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n            $process->errors[ $form_data['id'] ][ $field_id ] = 'Please use a different email address.';\n            return;\n        }\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Subdomains of listed domains are blocked too; add domains as spam arrives."
        },
        {
            "id": "wpforms-business-email-only",
            "version": 1,
            "title": "Require a Work Email in WPForms",
            "description": "Asks for a company email address instead of Gmail, Yahoo or other free webmail in WPForms email fields, so B2B forms collect qualified leads.",
            "category": "forms",
            "tags": [
                "wpforms",
                "validation",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_free_providers = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'proton.me', 'protonmail.com', 'mail.com', 'yandex.com' );\n$wpide_wpf_business_forms = array(); // Form IDs; empty = all forms.\n\nadd_action( 'wpforms_process_validate_email', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_free_providers, $wpide_wpf_business_forms ) {\n    if ( $wpide_wpf_business_forms && ! in_array( (int) $form_data['id'], array_map( 'intval', $wpide_wpf_business_forms ), true ) ) {\n        return;\n    }\n\n    $email  = strtolower( trim( is_array( $field_submit ) ? (string) reset( $field_submit ) : (string) $field_submit ) );\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_wpf_free_providers, true ) ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $form_data['id'] ][ $field_id ] = 'Please use your work email address.';\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Limit it to specific forms with the list of form IDs at the top; empty checks every form."
        },
        {
            "id": "wpforms-blocked-words-spam",
            "version": 1,
            "title": "Block WPForms Submissions Containing Spam Words",
            "description": "Quietly discards WPForms submissions when any field contains a word or phrase from your block list, the same way WPForms treats a filled honeypot.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_spam_words = array( 'seo services', 'backlinks', 'crypto', 'casino', 'guest post', 'rank your website' );\n\nadd_filter( 'wpforms_process_honeypot', function ( $honeypot, $fields, $entry, $form_data ) use ( $wpide_wpf_spam_words ) {\n    if ( $honeypot ) {\n        return $honeypot;\n    }\n\n    $text = '';\n    foreach ( (array) $fields as $field ) {\n        $text .= ' ' . ( isset( $field['value'] ) ? (string) $field['value'] : '' );\n    }\n    if ( '' === trim( $text ) && ! empty( $entry['fields'] ) ) {\n        foreach ( (array) $entry['fields'] as $value ) {\n            $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n        }\n    }\n\n    foreach ( $wpide_wpf_spam_words as $word ) {\n        if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $text ) ) {\n            return 'Your message contains words we do not accept.';\n        }\n    }\n\n    return $honeypot;\n}, 10, 4 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Matching is case-insensitive on whole words; WPForms discards matching submissions silently, like its own honeypot, so bots get no hint."
        },
        {
            "id": "wpforms-no-links-in-message",
            "version": 1,
            "title": "Block Links in WPForms Paragraph Fields",
            "description": "Shows an error when a WPForms paragraph text field contains web links, which stops most link spam without adding a captcha.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_max_links = 0;\n\nadd_action( 'wpforms_process_validate_textarea', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_max_links ) {\n    $links = preg_match_all( '#(https?://|www\\.|\\[url|<a\\s)#i', (string) $field_submit );\n\n    if ( $links > $wpide_wpf_max_links ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $form_data['id'] ][ $field_id ] = 0 === $wpide_wpf_max_links ? 'Please remove the links from your message.' : sprintf( 'Please include no more than %d link(s).', $wpide_wpf_max_links );\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Set the number of links you allow at the top if customers often share a link."
        },
        {
            "id": "wpforms-non-latin-spam",
            "version": 1,
            "title": "Reject WPForms Messages in Foreign Scripts",
            "description": "Blocks WPForms submissions written mostly in Cyrillic, Chinese or another script your business never receives genuine messages in.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_blocked_scripts = array( 'Cyrillic', 'Han', 'Arabic', 'Hangul', 'Thai' );\n$wpide_wpf_script_share    = 0.3;\n\nadd_filter( 'wpforms_process_honeypot', function ( $honeypot, $fields, $entry, $form_data ) use ( $wpide_wpf_blocked_scripts, $wpide_wpf_script_share ) {\n    if ( $honeypot || empty( $entry['fields'] ) ) {\n        return $honeypot;\n    }\n\n    $text = '';\n    foreach ( (array) $entry['fields'] as $value ) {\n        $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n    }\n\n    $letters = preg_match_all( '/\\p{L}/u', $text );\n    if ( $letters < 10 ) {\n        return $honeypot;\n    }\n\n    $foreign = 0;\n    foreach ( $wpide_wpf_blocked_scripts as $script ) {\n        $foreign += (int) preg_match_all( '/\\p{' . $script . '}/u', $text );\n    }\n\n    return $foreign / $letters >= $wpide_wpf_script_share ? 'Your message could not be sent.' : $honeypot;\n}, 10, 4 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Remove scripts from the list that your customers actually write in."
        },
        {
            "id": "wpforms-rate-limit-ip",
            "version": 1,
            "title": "Limit WPForms Submissions per Visitor",
            "description": "Allows only a few WPForms submissions per IP address each hour and blocks the rest with a polite message, to stop flooding and repeat spam.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_max_per_hour = 3;\n\n$wpide_wpf_rate_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    return 'wpide_wpf_rate_' . md5( wp_salt() . $ip );\n};\n\nadd_action( 'wpforms_process_before', function ( $entry, $form_data ) use ( $wpide_wpf_max_per_hour, $wpide_wpf_rate_key ) {\n    if ( (int) get_transient( $wpide_wpf_rate_key() ) >= $wpide_wpf_max_per_hour ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $form_data['id'] ]['header'] = 'You have sent several messages recently. Please try again later.';\n    }\n}, 10, 2 );\n\nadd_action( 'wpforms_process_complete', function () use ( $wpide_wpf_rate_key ) {\n    $key = $wpide_wpf_rate_key();\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n} );",
            "requires": [
                "wpforms"
            ],
            "notes": "IPs are kept only as hashes in short-lived transients; raise the limit for forms used from shared offices or schools."
        },
        {
            "id": "wpforms-message-min-length",
            "version": 1,
            "title": "Minimum Message Length in WPForms",
            "description": "Requires WPForms paragraph fields to contain at least a few words, so you get enough detail to reply and fewer one-word junk messages.",
            "category": "forms",
            "tags": [
                "wpforms",
                "validation",
                "textarea"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_min_words = 5;\n\nadd_action( 'wpforms_process_validate_textarea', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_min_words ) {\n    $text     = trim( wp_strip_all_tags( (string) $field_submit ) );\n    $required = ! empty( $form_data['fields'][ $field_id ]['required'] );\n\n    if ( '' === $text && ! $required ) {\n        return;\n    }\n\n    if ( preg_match_all( '/[\\p{L}\\p{N}\\']+/u', $text ) < $wpide_wpf_min_words ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $form_data['id'] ][ $field_id ] = sprintf( 'Please write at least %d words so we can help.', $wpide_wpf_min_words );\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Empty optional fields are skipped; required fields must reach the minimum."
        },
        {
            "id": "wpforms-prefill-logged-in-user",
            "version": 1,
            "title": "Prefill WPForms With the Logged-In User's Details",
            "description": "Fills WPForms name and email fields with the logged-in visitor's details, so members and customers do not have to type them again.",
            "category": "forms",
            "tags": [
                "wpforms",
                "prefill",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_field_properties', function ( $properties, $field, $form_data ) {\n    if ( ! is_user_logged_in() || ! in_array( $field['type'], array( 'name', 'email' ), true ) ) {\n        return $properties;\n    }\n\n    $user = wp_get_current_user();\n\n    if ( 'email' === $field['type'] && empty( $properties['inputs']['primary']['attr']['value'] ) ) {\n        $properties['inputs']['primary']['attr']['value'] = $user->user_email;\n    }\n\n    if ( 'name' === $field['type'] && ( empty( $field['format'] ) || 'simple' === $field['format'] ) && empty( $properties['inputs']['primary']['attr']['value'] ) ) {\n        $properties['inputs']['primary']['attr']['value'] = $user->display_name;\n    }\n\n    return $properties;\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Works with the Simple name format and email fields; fields with their own default value are left alone."
        },
        {
            "id": "wpforms-dropdown-from-posts",
            "version": 1,
            "title": "Fill a WPForms Drop-Down With Your Posts",
            "description": "Fills a WPForms drop-down with the titles of published posts, pages or a custom post type, both when the form is shown and when it is processed, so entries keep the chosen title.",
            "category": "forms",
            "tags": [
                "wpforms",
                "dynamic",
                "select"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_post_choices = function ( $type ) {\n    $cache   = 'wpide_wpf_choices_' . $type;\n    $choices = get_transient( $cache );\n\n    if ( false === $choices ) {\n        $choices = array();\n        $ids     = get_posts( array( 'post_type' => $type, 'post_status' => 'publish', 'numberposts' => 200, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n        foreach ( $ids as $i => $id ) {\n            $choices[ $i + 1 ] = array( 'label' => wp_strip_all_tags( get_the_title( $id ) ), 'value' => '', 'image' => '' );\n        }\n        set_transient( $cache, $choices, HOUR_IN_SECONDS );\n    }\n\n    return $choices;\n};\n\n$wpide_wpf_fill = function ( $form_data ) use ( $wpide_wpf_post_choices ) {\n    if ( empty( $form_data['fields'] ) || ! is_array( $form_data['fields'] ) ) {\n        return $form_data;\n    }\n\n    foreach ( $form_data['fields'] as $id => $field ) {\n        if ( 'select' === $field['type'] && ! empty( $field['css'] ) && preg_match( '/\\bwpide-posts-([a-z0-9_-]+)\\b/', $field['css'], $m ) && post_type_exists( $m[1] ) ) {\n            $form_data['fields'][ $id ]['choices'] = $wpide_wpf_post_choices( $m[1] );\n        }\n    }\n\n    return $form_data;\n};\n\nadd_filter( 'wpforms_frontend_form_data', $wpide_wpf_fill );\nadd_filter( 'wpforms_process_before_form_data', $wpide_wpf_fill );\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    delete_transient( 'wpide_wpf_choices_' . $post->post_type );\n}, 10, 2 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Give the drop-down the CSS class wpide-posts-<post type> (for example wpide-posts-page) in the field's Advanced settings."
        },
        {
            "id": "wpforms-redirect-by-answer",
            "version": 1,
            "title": "Redirect WPForms Based on an Answer",
            "description": "Sends visitors to a different thank-you page after a WPForms form depending on the option they picked, such as separate pages for sales and support.",
            "category": "forms",
            "tags": [
                "wpforms",
                "redirect",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_redirect_form  = 0; // Form ID (0 = off until you set it).\n$wpide_wpf_redirect_field = 6; // Field ID with the answer.\n// Answer label => URL.\n$wpide_wpf_redirect_map = array(\n    'Sales'   => '/thank-you-sales/',\n    'Support' => '/thank-you-support/',\n);\n\nadd_filter( 'wpforms_process_redirect_url', function ( $url, $form_id, $fields ) use ( $wpide_wpf_redirect_form, $wpide_wpf_redirect_field, $wpide_wpf_redirect_map ) {\n    if ( (int) $form_id !== $wpide_wpf_redirect_form ) {\n        return $url;\n    }\n\n    $answer = isset( $fields[ $wpide_wpf_redirect_field ]['value'] ) ? (string) $fields[ $wpide_wpf_redirect_field ]['value'] : '';\n\n    return isset( $wpide_wpf_redirect_map[ $answer ] ) ? esc_url_raw( home_url( $wpide_wpf_redirect_map[ $answer ] ) ) : $url;\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Set the form ID, field ID and map at the top, and set the form's confirmation type to \"Go to URL (Redirect)\" with a fallback URL that this snippet swaps by answer."
        },
        {
            "id": "wpforms-webhook",
            "version": 1,
            "title": "Send WPForms Entries to a Webhook",
            "description": "Posts every WPForms submission as JSON with field labels as keys to a webhook URL, such as Zapier, Make, n8n or a CRM, without a paid add-on.",
            "category": "forms",
            "tags": [
                "wpforms",
                "webhook",
                "integration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_webhook_forms = array(); // Form IDs to send; empty = all.\n\nadd_action( 'wpforms_process_complete', function ( $fields, $entry, $form_data, $entry_id ) use ( $wpide_wpf_webhook_forms ) {\n    $url = defined( 'WPIDE_WPFORMS_WEBHOOK_URL' ) ? (string) WPIDE_WPFORMS_WEBHOOK_URL : '';\n\n    if ( ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'https', 'http' ), true ) ) {\n        return;\n    }\n    if ( $wpide_wpf_webhook_forms && ! in_array( (int) $form_data['id'], array_map( 'intval', $wpide_wpf_webhook_forms ), true ) ) {\n        return;\n    }\n\n    $data = array();\n    foreach ( (array) $fields as $field ) {\n        $label          = ! empty( $field['name'] ) ? $field['name'] : 'field_' . $field['id'];\n        $data[ $label ] = isset( $field['value'] ) ? $field['value'] : '';\n    }\n\n    $body = wp_json_encode( array(\n        'form_id'    => (int) $form_data['id'],\n        'form_title' => isset( $form_data['settings']['form_title'] ) ? $form_data['settings']['form_title'] : '',\n        'entry_id'   => (int) $entry_id,\n        'fields'     => $data,\n        'submitted'  => gmdate( 'c' ),\n        'site'       => home_url(),\n    ) );\n\n    $headers = array( 'Content-Type' => 'application/json' );\n    if ( defined( 'WPIDE_WPFORMS_WEBHOOK_SECRET' ) && WPIDE_WPFORMS_WEBHOOK_SECRET ) {\n        $headers['X-Signature'] = hash_hmac( 'sha256', $body, WPIDE_WPFORMS_WEBHOOK_SECRET );\n    }\n\n    wp_remote_post( $url, array( 'body' => $body, 'headers' => $headers, 'timeout' => 5, 'blocking' => false ) );\n}, 10, 4 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Define WPIDE_WPFORMS_WEBHOOK_URL (and optionally WPIDE_WPFORMS_WEBHOOK_SECRET for an X-Signature HMAC header) in wp-config.php."
        },
        {
            "id": "wpforms-slack-notification",
            "version": 1,
            "title": "Post WPForms Submissions to Slack",
            "description": "Sends a summary of each WPForms submission to a Slack channel through an incoming webhook, so the team sees new enquiries straight away.",
            "category": "forms",
            "tags": [
                "wpforms",
                "slack",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wpforms_process_complete', function ( $fields, $entry, $form_data ) {\n    $url = defined( 'WPIDE_SLACK_WEBHOOK_URL' ) ? (string) WPIDE_SLACK_WEBHOOK_URL : '';\n\n    if ( 0 !== strpos( $url, 'https://hooks.slack.com/' ) ) {\n        return;\n    }\n\n    $escape = function ( $text ) {\n        return str_replace( array( '&', '<', '>' ), array( '&amp;', '&lt;', '&gt;' ), (string) $text );\n    };\n\n    $title = isset( $form_data['settings']['form_title'] ) ? $form_data['settings']['form_title'] : 'WPForms';\n    $lines = array( sprintf( '*New submission: %s*', $escape( $title ) ) );\n\n    foreach ( (array) $fields as $field ) {\n        $value = isset( $field['value'] ) ? trim( wp_strip_all_tags( (string) $field['value'] ) ) : '';\n        if ( '' !== $value && count( $lines ) < 7 ) {\n            $lines[] = sprintf( '*%s:* %s', $escape( $field['name'] ), $escape( wp_html_excerpt( $value, 300, '…' ) ) );\n        }\n    }\n\n    wp_safe_remote_post( $url, array(\n        'body'     => wp_json_encode( array( 'text' => implode( \"\\n\", $lines ) ) ),\n        'headers'  => array( 'Content-Type' => 'application/json' ),\n        'timeout'  => 5,\n        'blocking' => false,\n    ) );\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Define WPIDE_SLACK_WEBHOOK_URL in wp-config.php; only the first six filled fields are included."
        },
        {
            "id": "wpforms-save-entries-lite",
            "version": 1,
            "title": "Save WPForms Lite Entries in the Admin",
            "description": "Keeps a copy of every WPForms Lite submission in a private \"Form Entries\" list in the admin, since the free version does not store entries.",
            "category": "forms",
            "tags": [
                "wpforms",
                "entries",
                "storage"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_type( 'wpide_wpf_entry', array(\n        'labels'          => array( 'name' => 'Form Entries', 'singular_name' => 'Form Entry', 'menu_name' => 'Form Entries' ),\n        'public'          => false,\n        'show_ui'         => true,\n        'show_in_menu'    => true,\n        'menu_icon'       => 'dashicons-feedback',\n        'supports'        => array( 'title', 'editor' ),\n        'capabilities'    => array( 'create_posts' => 'do_not_allow' ),\n        'map_meta_cap'    => true,\n    ) );\n} );\n\nadd_action( 'wpforms_process_complete', function ( $fields, $entry, $form_data ) {\n    $rows = array();\n    foreach ( (array) $fields as $field ) {\n        $rows[] = '<strong>' . esc_html( isset( $field['name'] ) ? $field['name'] : '' ) . ':</strong> ' . nl2br( esc_html( isset( $field['value'] ) ? (string) $field['value'] : '' ) );\n    }\n\n    $title = isset( $form_data['settings']['form_title'] ) ? $form_data['settings']['form_title'] : 'Form';\n\n    wp_insert_post( array(\n        'post_type'    => 'wpide_wpf_entry',\n        'post_status'  => 'private',\n        'post_title'   => sprintf( '%s – %s', $title, wp_date( 'Y-m-d H:i' ) ),\n        'post_content' => wp_kses_post( implode( \"<br>\\n\", $rows ) ),\n        'meta_input'   => array( '_wpide_wpf_form_id' => (int) $form_data['id'] ),\n    ) );\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Entries are private posts visible to administrators only; WPForms Pro already stores entries, so skip this snippet there."
        },
        {
            "id": "wpforms-route-by-department",
            "version": 1,
            "title": "Route WPForms Notifications by Department",
            "description": "Sends WPForms email notifications to a different address depending on the department or topic the visitor picked, instead of one shared inbox.",
            "category": "forms",
            "tags": [
                "wpforms",
                "notifications",
                "routing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_route_field = 6; // Field ID of the drop-down or radio.\n$wpide_wpf_routes      = array(\n    // 'Sales'   => 'sales@yourdomain.com',\n    // 'Support' => 'support@yourdomain.com',\n);\n\nadd_filter( 'wpforms_entry_email_atts', function ( $email, $fields ) use ( $wpide_wpf_route_field, $wpide_wpf_routes ) {\n    $choice = isset( $fields[ $wpide_wpf_route_field ]['value'] ) ? (string) $fields[ $wpide_wpf_route_field ]['value'] : '';\n\n    if ( isset( $wpide_wpf_routes[ $choice ] ) && is_email( $wpide_wpf_routes[ $choice ] ) ) {\n        $email['address'] = array( $wpide_wpf_routes[ $choice ] );\n    }\n\n    return $email;\n}, 10, 2 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Set the field ID of the drop-down and add each choice label with its address to the map at the top; other choices keep the notification's own recipients."
        },
        {
            "id": "wpforms-notify-page-author",
            "version": 1,
            "title": "Send WPForms Notifications to the Page Author",
            "description": "Sends WPForms notifications to the author of the post or page where the form was filled in, for agent, listing or team profile pages.",
            "category": "forms",
            "tags": [
                "wpforms",
                "notifications",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_author_forms = array(); // Form IDs, e.g. array( 1 ).\n\nadd_filter( 'wpforms_entry_email_atts', function ( $email, $fields, $entry, $form_data ) use ( $wpide_wpf_author_forms ) {\n    if ( ! in_array( (int) $form_data['id'], array_map( 'intval', $wpide_wpf_author_forms ), true ) ) {\n        return $email;\n    }\n\n    $page_id = isset( $_POST['page_id'] ) ? absint( $_POST['page_id'] ) : url_to_postid( (string) wp_get_referer() );\n    $post    = $page_id ? get_post( $page_id ) : null;\n    $author  = $post ? get_userdata( (int) $post->post_author ) : false;\n\n    if ( $author && is_email( $author->user_email ) ) {\n        $email['address'] = array( $author->user_email );\n    }\n\n    return $email;\n}, 10, 4 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Limit it to forms in the list at the top; the original recipients are used when the page cannot be found."
        },
        {
            "id": "wpforms-subject-prefix",
            "version": 1,
            "title": "Prefix WPForms Notification Subjects",
            "description": "Adds the form name and the submitter's name to the subject of WPForms notifications, so messages are easy to tell apart and filter in your inbox.",
            "category": "forms",
            "tags": [
                "wpforms",
                "notifications",
                "subject"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_subject_format = '[%1$s] %2$s – '; // %1$s form title, %2$s submitter name.\n\nadd_filter( 'wpforms_entry_email_atts', function ( $email, $fields, $entry, $form_data ) use ( $wpide_wpf_subject_format ) {\n    $name = '';\n    foreach ( (array) $fields as $field ) {\n        if ( isset( $field['type'] ) && 'name' === $field['type'] && ! empty( $field['value'] ) ) {\n            $name = (string) $field['value'];\n            break;\n        }\n    }\n\n    $title = isset( $form_data['settings']['form_title'] ) ? $form_data['settings']['form_title'] : '';\n    $email['subject'] = trim( sprintf( $wpide_wpf_subject_format, $title, $name ) ) . ' ' . $email['subject'];\n\n    return $email;\n}, 10, 4 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Uses the first Name field of the form; change the format at the top."
        },
        {
            "id": "wpforms-reply-to-submitter",
            "version": 1,
            "title": "Reply Directly to People Who Fill In WPForms",
            "description": "Sets the Reply-To of WPForms notifications to the email address the visitor entered when no Reply-To is configured, so pressing Reply answers them.",
            "category": "forms",
            "tags": [
                "wpforms",
                "notifications",
                "reply-to"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_entry_email_atts', function ( $email, $fields ) {\n    if ( ! empty( $email['replyto'] ) ) {\n        return $email;\n    }\n\n    foreach ( (array) $fields as $field ) {\n        if ( isset( $field['type'] ) && 'email' === $field['type'] && is_email( $field['value'] ) ) {\n            $email['replyto'] = sanitize_email( $field['value'] );\n            break;\n        }\n    }\n\n    return $email;\n}, 10, 2 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Uses the first email field of the form; notifications with their own Reply-To setting are not changed."
        },
        {
            "id": "wpforms-custom-smart-tags",
            "version": 1,
            "title": "Extra Smart Tags for WPForms",
            "description": "Adds {current_year}, {user_role} and {site_name_plain} smart tags you can use in WPForms notifications, confirmations and default values.",
            "category": "forms",
            "tags": [
                "wpforms",
                "smart-tags",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_smart_tags', function ( $tags ) {\n    $tags['current_year']    = 'Current Year';\n    $tags['user_role']       = 'User Role';\n    $tags['site_name_plain'] = 'Site Name (plain text)';\n\n    return $tags;\n} );\n\nadd_filter( 'wpforms_smarttags_process_value', function ( $value, $tag_name ) {\n    switch ( $tag_name ) {\n        case 'current_year':\n            return wp_date( 'Y' );\n        case 'user_role':\n            $roles = is_user_logged_in() ? (array) wp_get_current_user()->roles : array();\n            return $roles ? (string) reset( $roles ) : '';\n        case 'site_name_plain':\n            return wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    }\n\n    return $value;\n}, 10, 2 );",
            "requires": [
                "wpforms"
            ],
            "notes": "The tags appear in the Smart Tags list of the form builder."
        },
        {
            "id": "wpforms-personal-confirmation",
            "version": 1,
            "title": "Personal WPForms Confirmation Message",
            "description": "Greets visitors by their first name in the WPForms confirmation message after submitting, which feels friendlier than a generic thank-you.",
            "category": "forms",
            "tags": [
                "wpforms",
                "confirmation",
                "messages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_greeting = 'Thanks, %s!'; // %s is the first name.\n\nadd_filter( 'wpforms_frontend_confirmation_message', function ( $message, $form_data, $fields ) use ( $wpide_wpf_greeting ) {\n    foreach ( (array) $fields as $field ) {\n        if ( isset( $field['type'] ) && 'name' === $field['type'] && ! empty( $field['value'] ) ) {\n            $first = ! empty( $field['first'] ) ? $field['first'] : strtok( (string) $field['value'], ' ' );\n            return '<p class=\"wpide-greeting\"><strong>' . esc_html( sprintf( $wpide_wpf_greeting, $first ) ) . '</strong></p>' . $message;\n        }\n    }\n\n    return $message;\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Uses the first Name field; the greeting is added before your existing confirmation text."
        },
        {
            "id": "wpforms-disable-emails-staging",
            "version": 1,
            "title": "Stop WPForms Emails on Staging Sites",
            "description": "Turns off all WPForms notification emails when the site is not running as production, so test submissions on staging never reach clients.",
            "category": "forms",
            "tags": [
                "wpforms",
                "notifications",
                "staging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_disable_all_emails', function ( $disabled ) {\n    return 'production' !== wp_get_environment_type() ? true : $disabled;\n} );",
            "requires": [
                "wpforms"
            ],
            "notes": "Relies on WP_ENVIRONMENT_TYPE being set to staging, development or local on non-live copies."
        },
        {
            "id": "wpforms-modern-styles",
            "version": 1,
            "title": "Modern Styling for WPForms",
            "description": "Gives WPForms fields consistent borders, rounded corners, a visible focus ring, clear errors and a bold submit button that follow your theme's fonts.",
            "category": "forms",
            "tags": [
                "wpforms",
                "styling",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpforms-container {\n    --wpide-wpf-accent: #1d4ed8;\n    --wpide-wpf-border: #cbd5e1;\n    --wpide-wpf-error: #b91c1c;\n    --wpide-wpf-radius: 8px;\n}\n\n.wpforms-container .wpforms-field input[type=\"text\"],\n.wpforms-container .wpforms-field input[type=\"email\"],\n.wpforms-container .wpforms-field input[type=\"tel\"],\n.wpforms-container .wpforms-field input[type=\"url\"],\n.wpforms-container .wpforms-field input[type=\"number\"],\n.wpforms-container .wpforms-field select,\n.wpforms-container .wpforms-field textarea {\n    max-width: 100%;\n    padding: 0.7em 0.9em;\n    border: 1px solid var(--wpide-wpf-border);\n    border-radius: var(--wpide-wpf-radius);\n    font-size: max(16px, 1em);\n}\n\n.wpforms-container .wpforms-field input:focus-visible,\n.wpforms-container .wpforms-field select:focus-visible,\n.wpforms-container .wpforms-field textarea:focus-visible {\n    outline: 2px solid var(--wpide-wpf-accent);\n    outline-offset: 1px;\n    border-color: var(--wpide-wpf-accent);\n}\n\n.wpforms-container .wpforms-field-label {\n    font-weight: 600;\n}\n\n.wpforms-container button[type=\"submit\"].wpforms-submit {\n    padding: 0.75em 1.6em;\n    border: 0;\n    border-radius: var(--wpide-wpf-radius);\n    background: var(--wpide-wpf-accent);\n    color: #fff;\n    font-weight: 600;\n    cursor: pointer;\n}\n\n.wpforms-container .wpforms-field input.wpforms-error,\n.wpforms-container .wpforms-field textarea.wpforms-error,\n.wpforms-container .wpforms-field select.wpforms-error {\n    border-color: var(--wpide-wpf-error);\n}\n\n.wpforms-container label.wpforms-error,\n.wpforms-container em.wpforms-error {\n    color: var(--wpide-wpf-error);\n    font-size: 0.875em;\n}\n\n.wpforms-confirmation-container-full {\n    border-radius: var(--wpide-wpf-radius);\n}",
            "requires": [
                "wpforms"
            ],
            "notes": "Change the custom properties at the top; for full control set WPForms > Settings > Include Form Styling to \"Base styling only\"."
        },
        {
            "id": "wpforms-form-theme-scss",
            "version": 1,
            "title": "WPForms Theme With SCSS Variables",
            "description": "Styles WPForms from a few SCSS variables (accent, radius, spacing, error color), including choices, buttons, errors and a dark-mode variant.",
            "category": "forms",
            "tags": [
                "wpforms",
                "scss",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #ea580c;\n$radius: 10px;\n$border: #d6d3d1;\n$error: #dc2626;\n$gap: 1.2rem;\n\n.wpforms-container {\n    .wpforms-field {\n        padding: 0 0 $gap;\n\n        input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"submit\"]),\n        select,\n        textarea {\n            width: 100%;\n            max-width: 100%;\n            padding: 0.75em 1em;\n            border: 1px solid $border;\n            border-radius: $radius;\n            font-size: max(16px, 1em);\n            transition: border-color 0.15s, box-shadow 0.15s;\n\n            &:focus {\n                outline: none;\n                border-color: $accent;\n                box-shadow: 0 0 0 3px rgba($accent, 0.2);\n            }\n\n            &.wpforms-error {\n                border-color: $error;\n            }\n        }\n\n        input[type=\"checkbox\"],\n        input[type=\"radio\"] {\n            accent-color: $accent;\n        }\n    }\n\n    .wpforms-submit {\n        padding: 0.8em 1.8em;\n        border: 0;\n        border-radius: $radius;\n        background: $accent;\n        color: #fff;\n        font-weight: 600;\n        cursor: pointer;\n\n        &:hover {\n            background: darken($accent, 8%);\n        }\n    }\n\n    label.wpforms-error {\n        color: $error;\n    }\n\n    @media (prefers-color-scheme: dark) {\n        .wpforms-field input:not([type=\"checkbox\"]):not([type=\"radio\"]),\n        .wpforms-field select,\n        .wpforms-field textarea {\n            background: #1c1917;\n            color: #f5f5f4;\n            border-color: #44403c;\n        }\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        .wpforms-field input,\n        .wpforms-field select,\n        .wpforms-field textarea {\n            transition: none;\n        }\n    }\n}",
            "requires": [
                "wpforms"
            ],
            "notes": "Edit the variables at the top; works best with WPForms set to \"Base styling only\"."
        },
        {
            "id": "wpforms-submit-button-classes",
            "version": 1,
            "title": "Theme Button Classes on WPForms Submit Buttons",
            "description": "Adds your theme's button classes to every WPForms submit button, so forms match the other buttons on your site without extra CSS.",
            "category": "forms",
            "tags": [
                "wpforms",
                "buttons",
                "styling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_button_classes = 'wp-element-button';\n\nadd_filter( 'wpforms_frontend_form_data', function ( $form_data ) use ( $wpide_wpf_button_classes ) {\n    if ( ! isset( $form_data['settings'] ) || ! is_array( $form_data['settings'] ) ) {\n        return $form_data;\n    }\n\n    $current = isset( $form_data['settings']['submit_class'] ) ? (string) $form_data['settings']['submit_class'] : '';\n    $form_data['settings']['submit_class'] = trim( $current . ' ' . $wpide_wpf_button_classes );\n\n    return $form_data;\n} );",
            "requires": [
                "wpforms"
            ],
            "notes": "Set the classes your theme uses, such as wp-element-button for block themes or btn btn-primary for Bootstrap themes."
        },
        {
            "id": "wpforms-default-upload-limits",
            "version": 1,
            "title": "Default Upload Limits for WPForms File Fields",
            "description": "Gives every WPForms file upload field a safe list of allowed file types and a maximum size when the field does not set its own.",
            "category": "forms",
            "tags": [
                "wpforms",
                "uploads",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_upload_ext = 'pdf,doc,docx,jpg,jpeg,png'; // Allowed extensions.\n$wpide_wpf_upload_mb  = 5;                           // Maximum size in MB.\n\n$wpide_wpf_upload_defaults = function ( $form_data ) use ( $wpide_wpf_upload_ext, $wpide_wpf_upload_mb ) {\n    if ( empty( $form_data['fields'] ) || ! is_array( $form_data['fields'] ) ) {\n        return $form_data;\n    }\n\n    foreach ( $form_data['fields'] as $id => $field ) {\n        if ( 'file-upload' !== $field['type'] ) {\n            continue;\n        }\n        if ( empty( $field['extensions'] ) ) {\n            $form_data['fields'][ $id ]['extensions'] = $wpide_wpf_upload_ext;\n        }\n        if ( empty( $field['max_size'] ) ) {\n            $form_data['fields'][ $id ]['max_size'] = $wpide_wpf_upload_mb;\n        }\n    }\n\n    return $form_data;\n};\n\nadd_filter( 'wpforms_frontend_form_data', $wpide_wpf_upload_defaults );\nadd_filter( 'wpforms_process_before_form_data', $wpide_wpf_upload_defaults );",
            "requires": [
                "wpforms"
            ],
            "notes": "Applies to the File Upload field available in WPForms Pro and newer Lite versions; values set on the field always win."
        },
        {
            "id": "wpforms-ga4-conversion-event",
            "version": 1,
            "title": "Track WPForms Submissions in Google Analytics 4",
            "description": "Sends a generate_lead event to Google Analytics 4 or Google Tag Manager when an AJAX WPForms form is submitted successfully.",
            "category": "forms",
            "tags": [
                "wpforms",
                "analytics",
                "ga4"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_wpf_event_name = 'generate_lead';\n\nadd_action( 'wp_footer', function () use ( $wpide_wpf_event_name ) {\n    if ( ! wp_script_is( 'wpforms', 'enqueued' ) && ! wp_script_is( 'wpforms', 'done' ) ) {\n        return;\n    }\n    ?>\n<script>\n( function ( $ ) {\n    if ( ! $ ) { return; }\n    var name = <?php echo wp_json_encode( $wpide_wpf_event_name ); ?>;\n    $( document ).on( 'wpformsAjaxSubmitSuccess', function ( event ) {\n        var formId = String( $( event.target ).data( 'formid' ) || '' );\n        var data = { form_id: formId, form_type: 'wpforms' };\n        if ( typeof window.gtag === 'function' ) { window.gtag( 'event', name, data ); }\n        else { window.dataLayer = window.dataLayer || []; window.dataLayer.push( Object.assign( { event: name }, data ) ); }\n    } );\n} )( window.jQuery );\n</script>\n    <?php\n}, 100 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Turn on \"Enable AJAX form submission\" in each form's settings; needs gtag.js or a GTM container on the site."
        },
        {
            "id": "wpforms-normalize-data",
            "version": 1,
            "title": "Tidy Up WPForms Submissions",
            "description": "Trims spaces, lowercases email addresses and capitalizes all-lowercase names in WPForms submissions before they are emailed or stored.",
            "category": "forms",
            "tags": [
                "wpforms",
                "data",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_process_filter', function ( $fields ) {\n    foreach ( (array) $fields as $id => $field ) {\n        if ( ! isset( $field['value'] ) || ! is_string( $field['value'] ) ) {\n            continue;\n        }\n\n        $value = trim( $field['value'] );\n\n        if ( 'email' === $field['type'] ) {\n            $value = strtolower( $value );\n        } elseif ( 'name' === $field['type'] && ( $value === strtolower( $value ) || $value === strtoupper( $value ) ) ) {\n            $value = mb_convert_case( $value, MB_CASE_TITLE, 'UTF-8' );\n        }\n\n        $fields[ $id ]['value'] = $value;\n    }\n\n    return $fields;\n} );",
            "requires": [
                "wpforms"
            ],
            "notes": "Names typed in mixed case (such as McDonald) are left as they are."
        },
        {
            "id": "wpforms-block-ip-addresses",
            "version": 1,
            "title": "Block IP Addresses From WPForms",
            "description": "Rejects WPForms submissions from IP addresses or ranges you list, such as a persistent spammer, while they can still read the site.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "ip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_blocked_ips = array( '203.0.113.7', '198.51.100.' );\n\nadd_filter( 'wpforms_process_honeypot', function ( $honeypot ) use ( $wpide_wpf_blocked_ips ) {\n    if ( $honeypot ) {\n        return $honeypot;\n    }\n\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    foreach ( $wpide_wpf_blocked_ips as $blocked ) {\n        $prefix = in_array( substr( $blocked, -1 ), array( '.', ':' ), true );\n        if ( $ip === $blocked || ( $prefix && 0 === strpos( $ip, $blocked ) ) ) {\n            return 'Submissions from your network are not accepted.';\n        }\n    }\n\n    return $honeypot;\n} );",
            "requires": [
                "wpforms"
            ],
            "notes": "Use exact addresses, or prefixes ending in a dot or colon for ranges."
        },
        {
            "id": "wpforms-one-entry-per-email",
            "version": 1,
            "title": "One WPForms Submission per Email per Day",
            "description": "Stops the same email address from submitting a WPForms form more than once in 24 hours, useful for giveaways and quote requests, even in WPForms Lite.",
            "category": "forms",
            "tags": [
                "wpforms",
                "validation",
                "duplicates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_daily_forms = array(); // Form IDs to protect, e.g. array( 1 ).\n$wpide_wpf_daily_hours = 24;\n\nadd_action( 'wpforms_process_validate_email', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_daily_forms ) {\n    if ( ! in_array( (int) $form_data['id'], array_map( 'intval', $wpide_wpf_daily_forms ), true ) ) {\n        return;\n    }\n\n    $email = strtolower( trim( is_array( $field_submit ) ? (string) reset( $field_submit ) : (string) $field_submit ) );\n\n    if ( is_email( $email ) && get_transient( 'wpide_wpf_once_' . md5( $form_data['id'] . '|' . $email ) ) ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $form_data['id'] ][ $field_id ] = 'This email address has already been used today.';\n    }\n}, 10, 3 );\n\nadd_action( 'wpforms_process_complete', function ( $fields, $entry, $form_data ) use ( $wpide_wpf_daily_forms, $wpide_wpf_daily_hours ) {\n    if ( ! in_array( (int) $form_data['id'], array_map( 'intval', $wpide_wpf_daily_forms ), true ) ) {\n        return;\n    }\n\n    foreach ( (array) $fields as $field ) {\n        if ( isset( $field['type'] ) && 'email' === $field['type'] && is_email( $field['value'] ) ) {\n            set_transient( 'wpide_wpf_once_' . md5( $form_data['id'] . '|' . strtolower( trim( $field['value'] ) ) ), 1, $wpide_wpf_daily_hours * HOUR_IN_SECONDS );\n        }\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Only the forms listed at the top are checked; addresses are kept only as hashes in transients that expire after the waiting period."
        },
        {
            "id": "wpforms-hide-dashboard-widget",
            "version": 1,
            "title": "Hide the WPForms Dashboard Widget",
            "description": "Removes the WPForms entries widget from the WordPress dashboard for everyone, keeping the dashboard focused on what you use.",
            "category": "forms",
            "tags": [
                "wpforms",
                "dashboard",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    remove_meta_box( 'wpforms_reports_widget_lite', 'dashboard', 'normal' );\n    remove_meta_box( 'wpforms_reports_widget_pro', 'dashboard', 'normal' );\n}, 999 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Entries remain available under WPForms > Entries."
        },
        {
            "id": "fluentforms-block-email-domains",
            "version": 1,
            "title": "Block Email Domains in Fluent Forms",
            "description": "Shows an error when someone uses an email address from a domain on your block list, such as throwaway inbox services, in a Fluent Forms form.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_email_fields     = array( 'email' ); // Email field names to check.\n$wpide_ff_blocked_domains  = array( 'mailinator.com', 'yopmail.com', 'tempmail.com', 'guerrillamail.com', 'sharklasers.com' );\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data, $form, $fields ) use ( $wpide_ff_email_fields, $wpide_ff_blocked_domains ) {\n    foreach ( $wpide_ff_email_fields as $name ) {\n        if ( empty( $data[ $name ] ) || ! is_string( $data[ $name ] ) ) {\n            continue;\n        }\n\n        $domain = strtolower( (string) substr( strrchr( trim( $data[ $name ] ), '@' ), 1 ) );\n\n        foreach ( $wpide_ff_blocked_domains as $blocked ) {\n            if ( $domain && ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) ) {\n                $errors[ $name ] = array( 'Please use a different email address.' );\n                break;\n            }\n        }\n    }\n\n    return $errors;\n}, 10, 4 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Needs Fluent Forms 5.0 or later and checks the field named \"email\" unless you list other email field names at the top."
        },
        {
            "id": "fluentforms-business-email-only",
            "version": 1,
            "title": "Require a Work Email in Fluent Forms",
            "description": "Asks for a company email address instead of Gmail, Yahoo or other free webmail in Fluent Forms, so B2B forms bring in qualified leads.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_work_forms      = array();           // Form IDs; empty = all forms.\n$wpide_ff_work_field      = 'email';           // Email field name.\n$wpide_ff_free_providers  = array( 'gmail.com', 'googlemail.com', 'yahoo.com', 'hotmail.com', 'outlook.com', 'live.com', 'aol.com', 'icloud.com', 'gmx.com', 'proton.me', 'protonmail.com', 'mail.com', 'yandex.com' );\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data, $form ) use ( $wpide_ff_work_forms, $wpide_ff_work_field, $wpide_ff_free_providers ) {\n    if ( $wpide_ff_work_forms && ! in_array( (int) $form->id, array_map( 'intval', $wpide_ff_work_forms ), true ) ) {\n        return $errors;\n    }\n\n    $email  = isset( $data[ $wpide_ff_work_field ] ) && is_string( $data[ $wpide_ff_work_field ] ) ? strtolower( trim( $data[ $wpide_ff_work_field ] ) ) : '';\n    $domain = (string) substr( strrchr( $email, '@' ), 1 );\n\n    if ( $domain && in_array( $domain, $wpide_ff_free_providers, true ) ) {\n        $errors[ $wpide_ff_work_field ] = array( 'Please use your work email address.' );\n    }\n\n    return $errors;\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Limit it to forms in the list at the top; the field named \"email\" is checked unless you change it."
        },
        {
            "id": "fluentforms-blocked-words",
            "version": 1,
            "title": "Block Fluent Forms Messages Containing Spam Words",
            "description": "Stops Fluent Forms submissions when any text field contains a word or phrase from your block list, such as SEO offers or crypto pitches.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_spam_words = array( 'seo services', 'backlinks', 'crypto', 'casino', 'guest post', 'rank your website' );\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_spam_words ) {\n    foreach ( (array) $data as $name => $value ) {\n        if ( ! is_string( $value ) || 0 === strpos( (string) $name, '_' ) ) {\n            continue;\n        }\n\n        foreach ( $wpide_ff_spam_words as $word ) {\n            if ( preg_match( '/\\b' . preg_quote( $word, '/' ) . '\\b/iu', $value ) ) {\n                $errors[ $name ] = array( 'Your message contains words we do not accept.' );\n                return $errors;\n            }\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "The error is shown on the first text field that contains a blocked word; matching is case-insensitive on whole words."
        },
        {
            "id": "fluentforms-no-links-in-message",
            "version": 1,
            "title": "Block Links in Fluent Forms Messages",
            "description": "Shows an error when a Fluent Forms message field contains web links, which removes most link spam without a captcha.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "validation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_link_fields = array( 'message', 'description' ); // Field names to check.\n$wpide_ff_max_links   = 0;\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_link_fields, $wpide_ff_max_links ) {\n    foreach ( $wpide_ff_link_fields as $name ) {\n        if ( empty( $data[ $name ] ) || ! is_string( $data[ $name ] ) ) {\n            continue;\n        }\n\n        if ( preg_match_all( '#(https?://|www\\.|\\[url|<a\\s)#i', $data[ $name ] ) > $wpide_ff_max_links ) {\n            $errors[ $name ] = array( 'Please remove the links from your message.' );\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "List the names of the fields to check at the top; the default is the \"message\" field."
        },
        {
            "id": "fluentforms-non-latin-spam",
            "version": 1,
            "title": "Reject Fluent Forms Messages in Foreign Scripts",
            "description": "Stops Fluent Forms submissions that are written mostly in Cyrillic, Chinese or another script your business does not receive genuine messages in.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_blocked_scripts = array( 'Cyrillic', 'Han', 'Arabic', 'Hangul', 'Thai' );\n$wpide_ff_script_share    = 0.3;\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_blocked_scripts, $wpide_ff_script_share ) {\n    $text = '';\n    foreach ( (array) $data as $name => $value ) {\n        if ( is_string( $value ) && 0 !== strpos( (string) $name, '_' ) ) {\n            $text .= ' ' . $value;\n        }\n    }\n\n    $letters = preg_match_all( '/\\p{L}/u', $text );\n    if ( $letters < 10 ) {\n        return $errors;\n    }\n\n    $foreign = 0;\n    foreach ( $wpide_ff_blocked_scripts as $script ) {\n        $foreign += (int) preg_match_all( '/\\p{' . $script . '}/u', $text );\n    }\n\n    if ( $foreign / $letters >= $wpide_ff_script_share ) {\n        $errors['message'] = array( 'Your message could not be sent.' );\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Remove scripts from the list that your customers actually write in."
        },
        {
            "id": "fluentforms-rate-limit-ip",
            "version": 1,
            "title": "Limit Fluent Forms Submissions per Visitor",
            "description": "Allows only a few Fluent Forms submissions per IP address each hour and shows a polite message after that, which stops flooding and repeated spam.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_max_per_hour = 3;\n\n$wpide_ff_rate_key = function () {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    return 'wpide_ff_rate_' . md5( wp_salt() . $ip );\n};\n\nadd_filter( 'fluentform/validation_errors', function ( $errors ) use ( $wpide_ff_max_per_hour, $wpide_ff_rate_key ) {\n    if ( (int) get_transient( $wpide_ff_rate_key() ) >= $wpide_ff_max_per_hour ) {\n        $errors['_wpide_rate_limit'] = array( 'You have sent several messages recently. Please try again later.' );\n    }\n\n    return $errors;\n}, 20 );\n\nadd_action( 'fluentform/submission_inserted', function () use ( $wpide_ff_rate_key ) {\n    $key = $wpide_ff_rate_key();\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n} );",
            "requires": [
                "fluentforms"
            ],
            "notes": "IPs are stored only as hashes in short-lived transients; raise the limit for forms used from shared offices."
        },
        {
            "id": "fluentforms-message-min-length",
            "version": 1,
            "title": "Minimum Message Length in Fluent Forms",
            "description": "Requires the message field of Fluent Forms to contain at least a few words, so you get enough detail to answer and fewer junk submissions.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "textarea"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_words_field = 'message';\n$wpide_ff_min_words   = 5;\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_words_field, $wpide_ff_min_words ) {\n    if ( ! isset( $data[ $wpide_ff_words_field ] ) || ! is_string( $data[ $wpide_ff_words_field ] ) || '' === trim( $data[ $wpide_ff_words_field ] ) ) {\n        return $errors;\n    }\n\n    if ( preg_match_all( '/[\\p{L}\\p{N}\\']+/u', wp_strip_all_tags( $data[ $wpide_ff_words_field ] ) ) < $wpide_ff_min_words ) {\n        $errors[ $wpide_ff_words_field ] = array( sprintf( 'Please write at least %d words so we can help.', $wpide_ff_min_words ) );\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Change the field name and number of words at the top."
        },
        {
            "id": "fluentforms-confirm-email-match",
            "version": 1,
            "title": "\"Confirm Email\" Check for Fluent Forms",
            "description": "Makes visitors type their email address twice in a Fluent Forms form and shows an error when the two do not match, so you do not lose replies to typos.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_email_name   = 'email';\n$wpide_ff_confirm_name = 'email_confirm';\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_email_name, $wpide_ff_confirm_name ) {\n    if ( ! isset( $data[ $wpide_ff_confirm_name ] ) ) {\n        return $errors;\n    }\n\n    $email   = strtolower( trim( (string) ( isset( $data[ $wpide_ff_email_name ] ) ? $data[ $wpide_ff_email_name ] : '' ) ) );\n    $confirm = strtolower( trim( (string) $data[ $wpide_ff_confirm_name ] ) );\n\n    if ( $email !== $confirm ) {\n        $errors[ $wpide_ff_confirm_name ] = array( 'The email addresses do not match.' );\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Add a second email field named email_confirm to the form, or change both names at the top."
        },
        {
            "id": "fluentforms-phone-validation",
            "version": 1,
            "title": "Validate Phone Numbers in Fluent Forms",
            "description": "Checks Fluent Forms phone fields for a realistic number of digits and only the characters phone numbers use, instead of accepting any text.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_phone_fields = array( 'phone' );\n$wpide_ff_phone_min    = 7;\n$wpide_ff_phone_max    = 15;\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_phone_fields, $wpide_ff_phone_min, $wpide_ff_phone_max ) {\n    foreach ( $wpide_ff_phone_fields as $name ) {\n        $phone = isset( $data[ $name ] ) && is_string( $data[ $name ] ) ? trim( $data[ $name ] ) : '';\n        if ( '' === $phone ) {\n            continue;\n        }\n\n        $digits = strlen( preg_replace( '/\\D/', '', $phone ) );\n\n        if ( ! preg_match( '/^\\+?[0-9\\s().\\-]+$/', $phone ) || $digits < $wpide_ff_phone_min || $digits > $wpide_ff_phone_max ) {\n            $errors[ $name ] = array( 'Please enter a valid phone number.' );\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Set the phone field names at the top; empty optional fields are skipped."
        },
        {
            "id": "fluentforms-redirect-by-answer",
            "version": 1,
            "title": "Redirect Fluent Forms Based on an Answer",
            "description": "Sends visitors to a different page after submitting a Fluent Forms form depending on an answer, such as separate thank-you pages for each service.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "redirect",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_redirect_form  = 0;            // Form ID (0 = off until you set it).\n$wpide_ff_redirect_field = 'department'; // Field name holding the answer.\n$wpide_ff_redirect_map   = array(\n    'sales'   => '/thank-you-sales/',\n    'support' => '/thank-you-support/',\n);\n\nadd_filter( 'fluentform/form_submission_confirmation', function ( $confirmation, $data, $form ) use ( $wpide_ff_redirect_form, $wpide_ff_redirect_field, $wpide_ff_redirect_map ) {\n    if ( (int) $form->id !== $wpide_ff_redirect_form || ! is_array( $confirmation ) ) {\n        return $confirmation;\n    }\n\n    $answer = isset( $data[ $wpide_ff_redirect_field ] ) && is_scalar( $data[ $wpide_ff_redirect_field ] ) ? (string) $data[ $wpide_ff_redirect_field ] : '';\n\n    if ( isset( $wpide_ff_redirect_map[ $answer ] ) ) {\n        $confirmation['redirectTo']          = 'customUrl';\n        $confirmation['customUrl']           = esc_url_raw( home_url( $wpide_ff_redirect_map[ $answer ] ) );\n        $confirmation['enable_query_string'] = 'no';\n    }\n\n    return $confirmation;\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Set the form ID, the field name and the answer-to-URL map at the top; other answers keep the form's normal confirmation."
        },
        {
            "id": "fluentforms-webhook",
            "version": 1,
            "title": "Send Fluent Forms Entries to a Webhook",
            "description": "Posts each new Fluent Forms entry as JSON to a webhook URL such as Zapier, Make, n8n or your CRM, without the Pro webhook module.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "webhook",
                "integration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_webhook_forms = array(); // Form IDs to send; empty = all.\n\nadd_action( 'fluentform/submission_inserted', function ( $entry_id, $form_data, $form ) use ( $wpide_ff_webhook_forms ) {\n    $url = defined( 'WPIDE_FLUENT_WEBHOOK_URL' ) ? (string) WPIDE_FLUENT_WEBHOOK_URL : '';\n\n    if ( ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'https', 'http' ), true ) ) {\n        return;\n    }\n    if ( $wpide_ff_webhook_forms && ! in_array( (int) $form->id, array_map( 'intval', $wpide_ff_webhook_forms ), true ) ) {\n        return;\n    }\n\n    $fields = array();\n    foreach ( (array) $form_data as $key => $value ) {\n        if ( 0 !== strpos( (string) $key, '_' ) ) {\n            $fields[ $key ] = $value;\n        }\n    }\n\n    $body = wp_json_encode( array(\n        'form_id'    => (int) $form->id,\n        'form_title' => isset( $form->title ) ? $form->title : '',\n        'entry_id'   => (int) $entry_id,\n        'fields'     => $fields,\n        'submitted'  => gmdate( 'c' ),\n        'site'       => home_url(),\n    ) );\n\n    $headers = array( 'Content-Type' => 'application/json' );\n    if ( defined( 'WPIDE_FLUENT_WEBHOOK_SECRET' ) && WPIDE_FLUENT_WEBHOOK_SECRET ) {\n        $headers['X-Signature'] = hash_hmac( 'sha256', $body, WPIDE_FLUENT_WEBHOOK_SECRET );\n    }\n\n    wp_remote_post( $url, array( 'body' => $body, 'headers' => $headers, 'timeout' => 5, 'blocking' => false ) );\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Define WPIDE_FLUENT_WEBHOOK_URL (and optionally WPIDE_FLUENT_WEBHOOK_SECRET for an X-Signature HMAC header) in wp-config.php."
        },
        {
            "id": "fluentforms-route-by-department",
            "version": 1,
            "title": "Route Fluent Forms Notifications by Department",
            "description": "Sends Fluent Forms email notifications to a different inbox depending on the department or topic chosen in the form.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "notifications",
                "routing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_route_field = 'department';\n$wpide_ff_routes      = array(\n    // 'sales'   => 'sales@yourdomain.com',\n    // 'support' => 'support@yourdomain.com',\n);\n\nadd_filter( 'fluentform/email_to', function ( $address, $notification, $data ) use ( $wpide_ff_route_field, $wpide_ff_routes ) {\n    $choice = isset( $data[ $wpide_ff_route_field ] ) && is_string( $data[ $wpide_ff_route_field ] ) ? $data[ $wpide_ff_route_field ] : '';\n\n    return isset( $wpide_ff_routes[ $choice ] ) && is_email( $wpide_ff_routes[ $choice ] ) ? $wpide_ff_routes[ $choice ] : $address;\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Set the field name and add each option value with its address to the map at the top; Fluent Forms Pro also has conditional routing in the notification settings."
        },
        {
            "id": "fluentforms-disable-default-styles",
            "version": 1,
            "title": "Turn Off Fluent Forms Default Styles",
            "description": "Stops Fluent Forms loading its default stylesheet, so forms inherit your theme's form styling or your own CSS without overrides.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "styling",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fluentform/load_default_public', '__return_false' );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Fields may look unstyled until your theme or custom CSS covers them; pair it with the Fluent Forms styling snippet if needed."
        },
        {
            "id": "fluentforms-modern-styles",
            "version": 1,
            "title": "Modern Styling for Fluent Forms",
            "description": "Gives Fluent Forms fields consistent borders, rounded corners, a visible focus ring, clear errors and a bold submit button that follow your theme's fonts.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "styling",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".fluentform {\n    --wpide-ff-accent: #0369a1;\n    --wpide-ff-border: #cbd5e1;\n    --wpide-ff-error: #b91c1c;\n    --wpide-ff-radius: 8px;\n}\n\n.fluentform .ff-el-form-control {\n    padding: 0.7em 0.9em;\n    border: 1px solid var(--wpide-ff-border);\n    border-radius: var(--wpide-ff-radius);\n    font-size: max(16px, 1em);\n}\n\n.fluentform .ff-el-form-control:focus-visible {\n    outline: 2px solid var(--wpide-ff-accent);\n    outline-offset: 1px;\n    border-color: var(--wpide-ff-accent);\n}\n\n.fluentform .ff-el-input--label label {\n    font-weight: 600;\n}\n\n.fluentform .ff-btn-submit {\n    padding: 0.75em 1.6em;\n    border: 0;\n    border-radius: var(--wpide-ff-radius);\n    background: var(--wpide-ff-accent) !important;\n    color: #fff !important;\n    font-weight: 600;\n}\n\n.fluentform .ff-el-is-error .ff-el-form-control {\n    border-color: var(--wpide-ff-error);\n}\n\n.fluentform .ff-el-is-error .text-danger,\n.fluentform .error.text-danger {\n    color: var(--wpide-ff-error);\n    font-size: 0.875em;\n}\n\n.ff-message-success {\n    border-radius: var(--wpide-ff-radius);\n}",
            "requires": [
                "fluentforms"
            ],
            "notes": "Change the custom properties at the top for your colors."
        },
        {
            "id": "fluentforms-form-theme-scss",
            "version": 1,
            "title": "Fluent Forms Theme With SCSS Variables",
            "description": "Styles Fluent Forms from a few SCSS variables for accent color, radius, spacing and errors, including checkboxes, buttons and a dark-mode variant.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "scss",
                "styling"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "$accent: #059669;\n$radius: 10px;\n$border: #d1d5db;\n$error: #dc2626;\n$gap: 1.2rem;\n\n.fluentform {\n    .ff-el-group {\n        margin-bottom: $gap;\n    }\n\n    .ff-el-form-control {\n        width: 100%;\n        padding: 0.75em 1em;\n        border: 1px solid $border;\n        border-radius: $radius;\n        font-size: max(16px, 1em);\n        transition: border-color 0.15s, box-shadow 0.15s;\n\n        &:focus {\n            outline: none;\n            border-color: $accent;\n            box-shadow: 0 0 0 3px rgba($accent, 0.2);\n        }\n    }\n\n    input[type=\"checkbox\"],\n    input[type=\"radio\"] {\n        accent-color: $accent;\n    }\n\n    .ff-btn-submit {\n        padding: 0.8em 1.8em;\n        border: 0;\n        border-radius: $radius;\n        background: $accent;\n        color: #fff;\n        font-weight: 600;\n\n        &:hover {\n            background: darken($accent, 8%);\n        }\n    }\n\n    .ff-el-is-error .ff-el-form-control {\n        border-color: $error;\n    }\n\n    .text-danger {\n        color: $error;\n    }\n\n    @media (prefers-color-scheme: dark) {\n        .ff-el-form-control {\n            background: #111827;\n            color: #f9fafb;\n            border-color: #374151;\n        }\n    }\n\n    @media (prefers-reduced-motion: reduce) {\n        .ff-el-form-control {\n            transition: none;\n        }\n    }\n}",
            "requires": [
                "fluentforms"
            ],
            "notes": "Edit the variables at the top; works well together with turning off the default Fluent Forms styles."
        },
        {
            "id": "fluentforms-normalize-data",
            "version": 1,
            "title": "Tidy Up Fluent Forms Submissions",
            "description": "Trims spaces and lowercases email addresses in Fluent Forms submissions before they are saved, so entries and exports stay consistent.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "data",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fluentform/insert_response_data', function ( $data ) {\n    foreach ( (array) $data as $key => $value ) {\n        if ( ! is_string( $value ) || 0 === strpos( (string) $key, '_' ) ) {\n            continue;\n        }\n\n        $value        = trim( $value );\n        $data[ $key ] = false !== stripos( (string) $key, 'email' ) ? strtolower( $value ) : $value;\n    }\n\n    return $data;\n} );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Field names containing \"email\" are lowercased; other text fields are trimmed only."
        },
        {
            "id": "fluentforms-block-ip-addresses",
            "version": 1,
            "title": "Block IP Addresses From Fluent Forms",
            "description": "Stops Fluent Forms submissions from IP addresses or ranges you list, such as a persistent spammer, while they can still read the site.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "ip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_blocked_ips = array( '203.0.113.7', '198.51.100.' );\n\nadd_filter( 'fluentform/validation_errors', function ( $errors ) use ( $wpide_ff_blocked_ips ) {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    foreach ( $wpide_ff_blocked_ips as $blocked ) {\n        $prefix = in_array( substr( $blocked, -1 ), array( '.', ':' ), true );\n        if ( $ip === $blocked || ( $prefix && 0 === strpos( $ip, $blocked ) ) ) {\n            $errors['_wpide_blocked'] = array( 'Submissions from your network are not accepted.' );\n            break;\n        }\n    }\n\n    return $errors;\n}, 5 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Use exact addresses, or prefixes ending in a dot or colon for ranges."
        },
        {
            "id": "fluentforms-one-entry-per-email",
            "version": 1,
            "title": "One Fluent Forms Submission per Email per Day",
            "description": "Stops the same email address from submitting a Fluent Forms form more than once in 24 hours, useful for giveaways, trials and quote requests.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "duplicates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_daily_forms = array(); // Form IDs, e.g. array( 1 ).\n$wpide_ff_daily_field = 'email';\n$wpide_ff_daily_hours = 24;\n\n$wpide_ff_daily_key = function ( $form_id, $email ) {\n    return 'wpide_ff_once_' . md5( $form_id . '|' . strtolower( trim( $email ) ) );\n};\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data, $form ) use ( $wpide_ff_daily_forms, $wpide_ff_daily_field, $wpide_ff_daily_key ) {\n    if ( ! in_array( (int) $form->id, array_map( 'intval', $wpide_ff_daily_forms ), true ) || empty( $data[ $wpide_ff_daily_field ] ) || ! is_string( $data[ $wpide_ff_daily_field ] ) ) {\n        return $errors;\n    }\n\n    if ( get_transient( $wpide_ff_daily_key( $form->id, $data[ $wpide_ff_daily_field ] ) ) ) {\n        $errors[ $wpide_ff_daily_field ] = array( 'This email address has already been used today.' );\n    }\n\n    return $errors;\n}, 10, 3 );\n\nadd_action( 'fluentform/submission_inserted', function ( $entry_id, $data, $form ) use ( $wpide_ff_daily_forms, $wpide_ff_daily_field, $wpide_ff_daily_hours, $wpide_ff_daily_key ) {\n    if ( in_array( (int) $form->id, array_map( 'intval', $wpide_ff_daily_forms ), true ) && ! empty( $data[ $wpide_ff_daily_field ] ) && is_string( $data[ $wpide_ff_daily_field ] ) ) {\n        set_transient( $wpide_ff_daily_key( $form->id, $data[ $wpide_ff_daily_field ] ), 1, $wpide_ff_daily_hours * HOUR_IN_SECONDS );\n    }\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Checks the forms and email field listed at the top; addresses are stored only as hashes in transients that expire."
        },
        {
            "id": "fluentforms-dropdown-from-posts",
            "version": 1,
            "title": "Fill a Fluent Forms Drop-Down With Your Posts",
            "description": "Fills a Fluent Forms drop-down with the titles of your published posts, pages or a custom post type, so the options stay in sync with your content.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "dynamic",
                "select"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fluentform/rendering_field_data_select', function ( $data, $form ) {\n    $class = isset( $data['settings']['container_class'] ) ? (string) $data['settings']['container_class'] : '';\n\n    if ( ! preg_match( '/\\bwpide-posts-([a-z0-9_-]+)\\b/', $class, $m ) || ! post_type_exists( $m[1] ) ) {\n        return $data;\n    }\n\n    $cache   = 'wpide_ff_choices_' . $m[1];\n    $options = get_transient( $cache );\n\n    if ( false === $options ) {\n        $options = array();\n        $ids     = get_posts( array( 'post_type' => $m[1], 'post_status' => 'publish', 'numberposts' => 200, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true ) );\n        foreach ( $ids as $id ) {\n            $title     = wp_strip_all_tags( get_the_title( $id ) );\n            $options[] = array( 'label' => $title, 'value' => $title, 'calc_value' => '' );\n        }\n        set_transient( $cache, $options, HOUR_IN_SECONDS );\n    }\n\n    $data['settings']['advanced_options'] = $options;\n\n    return $data;\n}, 10, 2 );\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    delete_transient( 'wpide_ff_choices_' . $post->post_type );\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Add the container class wpide-posts-<post type> (for example wpide-posts-page) to the drop-down in its Advanced Options."
        },
        {
            "id": "fluentforms-ga4-conversion-event",
            "version": 1,
            "title": "GA4 Lead Event for Fluent Forms",
            "description": "Sends a generate_lead event to Google Analytics 4 or Google Tag Manager whenever a Fluent Forms form is submitted successfully.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "analytics",
                "ga4"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_ff_event_name = 'generate_lead';\n\nadd_action( 'wp_footer', function () use ( $wpide_ff_event_name ) {\n    if ( ! wp_script_is( 'fluent-form-submission', 'enqueued' ) && ! wp_script_is( 'fluent-form-submission', 'done' ) ) {\n        return;\n    }\n    ?>\n<script>\ndocument.addEventListener( 'fluentform_submission_success', function ( event ) {\n    var name = <?php echo wp_json_encode( $wpide_ff_event_name ); ?>;\n    var form = event.detail && event.detail.form;\n    var data = { form_id: form ? String( form.getAttribute( 'data-form_id' ) || '' ) : '', form_type: 'fluent-forms' };\n    if ( typeof window.gtag === 'function' ) { window.gtag( 'event', name, data ); }\n    else { window.dataLayer = window.dataLayer || []; window.dataLayer.push( Object.assign( { event: name }, data ) ); }\n} );\n</script>\n    <?php\n}, 100 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Needs gtag.js or a GTM container already on the site."
        },
        {
            "id": "form-contact-shortcode",
            "version": 1,
            "title": "Contact Form Shortcode Without a Plugin",
            "description": "Adds a [wpide_contact_form] shortcode with name, email and message fields that emails you each message, protected by a nonce, a hidden honeypot, a time trap and a per-visitor hourly limit.",
            "category": "forms",
            "tags": [
                "contact",
                "shortcode",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_contact = array(\n    'to'           => '',                                   // Recipient; empty = Settings > General admin email.\n    'subject'      => 'New message from your website',\n    'max_per_hour' => 3,                                    // Messages allowed per visitor per hour.\n    'min_seconds'  => 3,                                    // Faster submissions are treated as bots.\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_contact ) {\n    if ( empty( $_POST['wpide_contact_nonce'] ) || 'POST' !== strtoupper( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '' ) ) {\n        return;\n    }\n\n    $back   = remove_query_arg( 'wpide_contact', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $finish = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_contact', $status, $back ) . '#wpide-contact' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_contact_nonce'] ) ), 'wpide_contact' ) ) {\n        $finish( 'expired' );\n    }\n\n    $stamp = isset( $_POST['wpide_ts'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_ts'] ) ) : '';\n    $parts = explode( '.', $stamp, 2 );\n    $human = 2 === count( $parts ) && hash_equals( wp_hash( 'contact' . $parts[0] ), $parts[1] ) && time() - (int) $parts[0] >= $wpide_contact['min_seconds'];\n\n    if ( ! empty( $_POST['wpide_website'] ) || ! $human ) {\n        $finish( 'sent' ); // Pretend success so bots learn nothing.\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_contact_' . md5( wp_salt() . $ip );\n    if ( (int) get_transient( $key ) >= $wpide_contact['max_per_hour'] ) {\n        $finish( 'limit' );\n    }\n\n    $name    = str_replace( array( '<', '>', '\"' ), '', sanitize_text_field( wp_unslash( isset( $_POST['wpide_name'] ) ? $_POST['wpide_name'] : '' ) ) );\n    $email   = sanitize_email( wp_unslash( isset( $_POST['wpide_email'] ) ? $_POST['wpide_email'] : '' ) );\n    $message = sanitize_textarea_field( wp_unslash( isset( $_POST['wpide_message'] ) ? $_POST['wpide_message'] : '' ) );\n\n    if ( '' === $name || ! is_email( $email ) || strlen( $message ) < 10 ) {\n        $finish( 'invalid' );\n    }\n\n    $to   = is_email( $wpide_contact['to'] ) ? $wpide_contact['to'] : get_option( 'admin_email' );\n    $body = \"Name: $name\\nEmail: $email\\nPage: $back\\n\\n$message\";\n    $sent = wp_mail( $to, $wpide_contact['subject'], $body, array( 'Reply-To: ' . $name . ' <' . $email . '>' ) );\n\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n    $finish( $sent ? 'sent' : 'error' );\n} );\n\nadd_shortcode( 'wpide_contact_form', function () {\n    $messages = array(\n        'sent'    => array( 'success', 'Thank you! Your message has been sent.' ),\n        'invalid' => array( 'error', 'Please fill in your name, a valid email and a message of at least 10 characters.' ),\n        'limit'   => array( 'error', 'You have sent several messages recently. Please try again later.' ),\n        'expired' => array( 'error', 'The form expired. Please try again.' ),\n        'error'   => array( 'error', 'Sorry, the message could not be sent. Please email us directly.' ),\n    );\n    $status = isset( $_GET['wpide_contact'] ) ? sanitize_key( $_GET['wpide_contact'] ) : '';\n    $time   = (string) time();\n\n    ob_start();\n    ?>\n    <div id=\"wpide-contact\" class=\"wpide-form wpide-contact-form\">\n        <?php if ( isset( $messages[ $status ] ) ) : ?>\n            <p class=\"wpide-form-notice wpide-form-<?php echo esc_attr( $messages[ $status ][0] ); ?>\" role=\"<?php echo 'success' === $messages[ $status ][0] ? 'status' : 'alert'; ?>\"><?php echo esc_html( $messages[ $status ][1] ); ?></p>\n        <?php endif; ?>\n        <form method=\"post\" action=\"\">\n            <p><label for=\"wpide-contact-name\">Name</label><br><input type=\"text\" id=\"wpide-contact-name\" name=\"wpide_name\" required autocomplete=\"name\" maxlength=\"100\"></p>\n            <p><label for=\"wpide-contact-email\">Email</label><br><input type=\"email\" id=\"wpide-contact-email\" name=\"wpide_email\" required autocomplete=\"email\" maxlength=\"200\"></p>\n            <p><label for=\"wpide-contact-message\">Message</label><br><textarea id=\"wpide-contact-message\" name=\"wpide_message\" rows=\"6\" required minlength=\"10\" maxlength=\"5000\"></textarea></p>\n            <p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><label>Website <input type=\"text\" name=\"wpide_website\" tabindex=\"-1\" autocomplete=\"off\"></label></p>\n            <input type=\"hidden\" name=\"wpide_ts\" value=\"<?php echo esc_attr( $time . '.' . wp_hash( 'contact' . $time ) ); ?>\">\n            <?php wp_nonce_field( 'wpide_contact', 'wpide_contact_nonce', false ); ?>\n            <p><button type=\"submit\">Send message</button></p>\n        </form>\n    </div>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [],
            "notes": "Add [wpide_contact_form] to any page (messages go to the admin email unless you set a recipient), and exclude that page from full-page caching so its security token stays fresh."
        },
        {
            "id": "form-newsletter-signup",
            "version": 1,
            "title": "Newsletter Sign-Up With Double Opt-In",
            "description": "Adds a [wpide_newsletter] sign-up shortcode that stores subscribers in the admin, emails a confirmation link (double opt-in), and lets you export confirmed addresses as CSV.",
            "category": "forms",
            "tags": [
                "newsletter",
                "shortcode",
                "subscribers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_nl_subject = 'Please confirm your subscription';\n\nadd_action( 'init', function () {\n    register_post_type( 'wpide_subscriber', array( 'public' => false, 'show_ui' => false, 'supports' => array( 'title' ) ) );\n    if ( ! wp_next_scheduled( 'wpide_nl_cleanup' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_nl_cleanup' );\n    }\n} );\n\nadd_action( 'template_redirect', function () use ( $wpide_nl_subject ) {\n    // Confirmation link.\n    if ( isset( $_GET['wpide_nl_confirm'], $_GET['id'] ) ) {\n        $id    = absint( $_GET['id'] );\n        $token = sanitize_text_field( wp_unslash( $_GET['wpide_nl_confirm'] ) );\n        $saved = (string) get_post_meta( $id, '_wpide_nl_token', true );\n        if ( $saved && 'wpide_subscriber' === get_post_type( $id ) && hash_equals( $saved, $token ) ) {\n            wp_update_post( array( 'ID' => $id, 'post_status' => 'publish' ) );\n            delete_post_meta( $id, '_wpide_nl_token' );\n            wp_safe_redirect( add_query_arg( 'wpide_nl', 'confirmed', home_url( '/' ) ) );\n            exit;\n        }\n        return;\n    }\n\n    if ( empty( $_POST['wpide_nl_nonce'] ) ) {\n        return;\n    }\n\n    $back = remove_query_arg( 'wpide_nl', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $done = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_nl', $status, $back ) . '#wpide-newsletter' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_nl_nonce'] ) ), 'wpide_nl' ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_nl_hp'] ) ) {\n        $done( 'check' );\n    }\n\n    $email = strtolower( sanitize_email( wp_unslash( isset( $_POST['wpide_nl_email'] ) ? $_POST['wpide_nl_email'] : '' ) ) );\n    if ( ! is_email( $email ) ) {\n        $done( 'invalid' );\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_nl_rate_' . md5( wp_salt() . $ip );\n    if ( (int) get_transient( $key ) >= 5 ) {\n        $done( 'check' );\n    }\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n\n    $existing = get_posts( array( 'post_type' => 'wpide_subscriber', 'post_status' => array( 'publish', 'draft' ), 'title' => $email, 'numberposts' => 1, 'fields' => 'ids', 'no_found_rows' => true ) );\n    if ( $existing ) {\n        $done( 'check' ); // Same answer for known addresses, so the list cannot be probed.\n    }\n\n    $token = wp_generate_password( 32, false );\n    $id    = wp_insert_post( array( 'post_type' => 'wpide_subscriber', 'post_status' => 'draft', 'post_title' => $email, 'meta_input' => array( '_wpide_nl_token' => $token ) ) );\n\n    if ( $id && ! is_wp_error( $id ) ) {\n        $link = add_query_arg( array( 'wpide_nl_confirm' => $token, 'id' => $id ), home_url( '/' ) );\n        wp_mail( $email, $wpide_nl_subject, \"Thanks for signing up to \" . wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) . \".\\n\\nPlease confirm your subscription by opening this link:\\n$link\\n\\nIf you did not sign up, ignore this email.\" );\n    }\n\n    $done( 'check' );\n} );\n\nadd_shortcode( 'wpide_newsletter', function ( $atts ) {\n    $atts     = shortcode_atts( array( 'button' => 'Subscribe', 'placeholder' => 'Your email address' ), $atts, 'wpide_newsletter' );\n    $messages = array(\n        'check'     => 'Almost done! Please check your inbox and click the confirmation link.',\n        'confirmed' => 'Thank you, your subscription is confirmed.',\n        'invalid'   => 'Please enter a valid email address.',\n        'expired'   => 'The form expired. Please try again.',\n    );\n    $status = isset( $_GET['wpide_nl'] ) ? sanitize_key( $_GET['wpide_nl'] ) : '';\n\n    $html  = '<div id=\"wpide-newsletter\" class=\"wpide-form wpide-newsletter\">';\n    $html .= isset( $messages[ $status ] ) ? '<p class=\"wpide-form-notice\" role=\"status\">' . esc_html( $messages[ $status ] ) . '</p>' : '';\n    $html .= '<form method=\"post\" action=\"\"><label class=\"screen-reader-text\" for=\"wpide-nl-email\">Email address</label>';\n    $html .= '<input type=\"email\" id=\"wpide-nl-email\" name=\"wpide_nl_email\" required autocomplete=\"email\" placeholder=\"' . esc_attr( $atts['placeholder'] ) . '\"> ';\n    $html .= '<span style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input type=\"text\" name=\"wpide_nl_hp\" tabindex=\"-1\" autocomplete=\"off\"></span>';\n    $html .= wp_nonce_field( 'wpide_nl', 'wpide_nl_nonce', false, false );\n    $html .= '<button type=\"submit\">' . esc_html( $atts['button'] ) . '</button></form></div>';\n\n    return $html;\n} );\n\nadd_action( 'wpide_nl_cleanup', function () {\n    $old = get_posts( array( 'post_type' => 'wpide_subscriber', 'post_status' => 'draft', 'date_query' => array( array( 'before' => '7 days ago' ) ), 'numberposts' => 200, 'fields' => 'ids', 'no_found_rows' => true ) );\n    foreach ( $old as $id ) {\n        wp_delete_post( $id, true );\n    }\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Subscribers', 'Subscribers', 'manage_options', 'wpide-subscribers', function () {\n        $count = (int) wp_count_posts( 'wpide_subscriber' )->publish;\n        $url   = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_nl_export' ), 'wpide_nl_export' );\n        echo '<div class=\"wrap\"><h1>Subscribers</h1>';\n        printf( '<p>%d confirmed subscriber(s).</p><p><a class=\"button button-primary\" href=\"%s\">Download CSV</a></p></div>', (int) $count, esc_url( $url ) );\n    } );\n} );\n\nadd_action( 'admin_post_wpide_nl_export', function () {\n    if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'wpide_nl_export' ) ) {\n        wp_die( 'Not allowed.' );\n    }\n\n    $ids = get_posts( array( 'post_type' => 'wpide_subscriber', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids', 'no_found_rows' => true ) );\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=subscribers-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fputcsv( $out, array( 'email', 'confirmed' ) );\n    foreach ( $ids as $id ) {\n        fputcsv( $out, array( ltrim( get_the_title( $id ), '=+-@' ), get_post_field( 'post_modified', $id ) ) );\n    }\n    fclose( $out );\n    exit;\n} );",
            "requires": [],
            "notes": "Confirmed subscribers are listed under Tools > Subscribers with a CSV export for your mailing tool, and unconfirmed sign-ups older than 7 days are removed daily."
        },
        {
            "id": "form-quote-request-shortcode",
            "version": 1,
            "title": "Quote Request Form Shortcode",
            "description": "Adds a [wpide_quote_form] shortcode where visitors pick a service, budget and timeline and describe their project; requests are emailed to you with spam protection and rate limiting built in.",
            "category": "forms",
            "tags": [
                "quote",
                "shortcode",
                "leads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_quote = array(\n    'to'       => '', // Recipient; empty = admin email.\n    'services' => array( 'Website design', 'Online store', 'SEO', 'Maintenance', 'Something else' ),\n    'budgets'  => array( 'Under $1,000', '$1,000 – $5,000', '$5,000 – $15,000', 'Over $15,000' ),\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_quote ) {\n    if ( empty( $_POST['wpide_quote_nonce'] ) ) {\n        return;\n    }\n\n    $back = remove_query_arg( 'wpide_quote', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $done = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_quote', $status, $back ) . '#wpide-quote' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_quote_nonce'] ) ), 'wpide_quote' ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_quote_hp'] ) ) {\n        $done( 'sent' );\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_quote_' . md5( wp_salt() . $ip );\n    if ( (int) get_transient( $key ) >= 3 ) {\n        $done( 'limit' );\n    }\n\n    $get = function ( $name, $textarea = false ) {\n        $value = isset( $_POST[ $name ] ) ? wp_unslash( $_POST[ $name ] ) : '';\n        return $textarea ? sanitize_textarea_field( $value ) : sanitize_text_field( $value );\n    };\n\n    $name     = str_replace( array( '<', '>', '\"' ), '', $get( 'wpide_q_name' ) );\n    $email    = sanitize_email( $get( 'wpide_q_email' ) );\n    $service  = in_array( $get( 'wpide_q_service' ), $wpide_quote['services'], true ) ? $get( 'wpide_q_service' ) : '';\n    $budget   = in_array( $get( 'wpide_q_budget' ), $wpide_quote['budgets'], true ) ? $get( 'wpide_q_budget' ) : 'Not given';\n    $deadline = $get( 'wpide_q_deadline' );\n    $details  = $get( 'wpide_q_details', true );\n\n    if ( '' === $name || ! is_email( $email ) || '' === $service || strlen( $details ) < 20 ) {\n        $done( 'invalid' );\n    }\n\n    $body = \"Name: $name\\nEmail: $email\\nCompany: \" . $get( 'wpide_q_company' ) . \"\\nPhone: \" . $get( 'wpide_q_phone' ) . \"\\nService: $service\\nBudget: $budget\\nTimeline: $deadline\\n\\n$details\";\n    $to   = is_email( $wpide_quote['to'] ) ? $wpide_quote['to'] : get_option( 'admin_email' );\n    $sent = wp_mail( $to, sprintf( 'Quote request: %s from %s', $service, $name ), $body, array( 'Reply-To: ' . $name . ' <' . $email . '>' ) );\n\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n    $done( $sent ? 'sent' : 'error' );\n} );\n\nadd_shortcode( 'wpide_quote_form', function () use ( $wpide_quote ) {\n    $messages = array(\n        'sent'    => 'Thanks! We will get back to you with a quote within two working days.',\n        'invalid' => 'Please fill in the required fields and describe your project in a little more detail.',\n        'limit'   => 'You have sent several requests recently. Please try again later.',\n        'expired' => 'The form expired. Please try again.',\n        'error'   => 'Sorry, your request could not be sent. Please email us directly.',\n    );\n    $status = isset( $_GET['wpide_quote'] ) ? sanitize_key( $_GET['wpide_quote'] ) : '';\n\n    $options = function ( $list ) {\n        $html = '';\n        foreach ( $list as $item ) {\n            $html .= '<option value=\"' . esc_attr( $item ) . '\">' . esc_html( $item ) . '</option>';\n        }\n        return $html;\n    };\n\n    ob_start();\n    ?>\n    <div id=\"wpide-quote\" class=\"wpide-form wpide-quote-form\">\n        <?php if ( isset( $messages[ $status ] ) ) : ?>\n            <p class=\"wpide-form-notice\" role=\"status\"><?php echo esc_html( $messages[ $status ] ); ?></p>\n        <?php endif; ?>\n        <form method=\"post\" action=\"\">\n            <p><label for=\"wpide-q-name\">Your name *</label><br><input id=\"wpide-q-name\" name=\"wpide_q_name\" required autocomplete=\"name\"></p>\n            <p><label for=\"wpide-q-email\">Email *</label><br><input type=\"email\" id=\"wpide-q-email\" name=\"wpide_q_email\" required autocomplete=\"email\"></p>\n            <p><label for=\"wpide-q-company\">Company</label><br><input id=\"wpide-q-company\" name=\"wpide_q_company\" autocomplete=\"organization\"></p>\n            <p><label for=\"wpide-q-phone\">Phone</label><br><input type=\"tel\" id=\"wpide-q-phone\" name=\"wpide_q_phone\" autocomplete=\"tel\"></p>\n            <p><label for=\"wpide-q-service\">Service *</label><br><select id=\"wpide-q-service\" name=\"wpide_q_service\" required><option value=\"\">Choose…</option><?php echo $options( $wpide_quote['services'] ); // phpcs:ignore ?></select></p>\n            <p><label for=\"wpide-q-budget\">Budget</label><br><select id=\"wpide-q-budget\" name=\"wpide_q_budget\"><option value=\"\">Choose…</option><?php echo $options( $wpide_quote['budgets'] ); // phpcs:ignore ?></select></p>\n            <p><label for=\"wpide-q-deadline\">Ideal timeline</label><br><input id=\"wpide-q-deadline\" name=\"wpide_q_deadline\" placeholder=\"e.g. within 2 months\"></p>\n            <p><label for=\"wpide-q-details\">Project details *</label><br><textarea id=\"wpide-q-details\" name=\"wpide_q_details\" rows=\"6\" required minlength=\"20\"></textarea></p>\n            <p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_quote_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>\n            <?php wp_nonce_field( 'wpide_quote', 'wpide_quote_nonce', false ); ?>\n            <p><button type=\"submit\">Request a quote</button></p>\n        </form>\n    </div>\n    <?php\n    return ob_get_clean();\n} );",
            "requires": [],
            "notes": "Edit the services, budgets and recipient at the top; replies go straight to the visitor thanks to the Reply-To header."
        },
        {
            "id": "form-callback-request",
            "version": 1,
            "title": "\"Request a Call Back\" Form Shortcode",
            "description": "Adds a short [wpide_callback_form] form where visitors leave their name, phone number and the best time to call, and emails the request to you.",
            "category": "forms",
            "tags": [
                "callback",
                "shortcode",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_callback = array(\n    'to'    => '', // Recipient; empty = admin email.\n    'slots' => array( 'Morning (9–12)', 'Afternoon (12–17)', 'Evening (17–19)', 'Any time' ),\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_callback ) {\n    if ( empty( $_POST['wpide_cb_nonce'] ) ) {\n        return;\n    }\n\n    $back = remove_query_arg( 'wpide_cb', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $done = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_cb', $status, $back ) . '#wpide-callback' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_cb_nonce'] ) ), 'wpide_cb' ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_cb_hp'] ) ) {\n        $done( 'sent' );\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_cb_' . md5( wp_salt() . $ip );\n    if ( (int) get_transient( $key ) >= 3 ) {\n        $done( 'limit' );\n    }\n\n    $name  = sanitize_text_field( wp_unslash( isset( $_POST['wpide_cb_name'] ) ? $_POST['wpide_cb_name'] : '' ) );\n    $phone = preg_replace( '/[^0-9+()\\s.\\-]/', '', (string) wp_unslash( isset( $_POST['wpide_cb_phone'] ) ? $_POST['wpide_cb_phone'] : '' ) );\n    $slot  = sanitize_text_field( wp_unslash( isset( $_POST['wpide_cb_slot'] ) ? $_POST['wpide_cb_slot'] : '' ) );\n    $slot  = in_array( $slot, $wpide_callback['slots'], true ) ? $slot : 'Any time';\n\n    if ( '' === $name || strlen( preg_replace( '/\\D/', '', $phone ) ) < 7 ) {\n        $done( 'invalid' );\n    }\n\n    $to   = is_email( $wpide_callback['to'] ) ? $wpide_callback['to'] : get_option( 'admin_email' );\n    $sent = wp_mail( $to, 'Call back request: ' . $name, \"Name: $name\\nPhone: $phone\\nBest time: $slot\\nPage: $back\" );\n\n    set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n    $done( $sent ? 'sent' : 'error' );\n} );\n\nadd_shortcode( 'wpide_callback_form', function () use ( $wpide_callback ) {\n    $messages = array(\n        'sent'    => 'Thanks! We will call you back at the time you chose.',\n        'invalid' => 'Please enter your name and a valid phone number.',\n        'limit'   => 'You have already sent a request. We will be in touch soon.',\n        'expired' => 'The form expired. Please try again.',\n        'error'   => 'Sorry, the request could not be sent. Please call us instead.',\n    );\n    $status = isset( $_GET['wpide_cb'] ) ? sanitize_key( $_GET['wpide_cb'] ) : '';\n\n    $html  = '<div id=\"wpide-callback\" class=\"wpide-form wpide-callback-form\">';\n    $html .= isset( $messages[ $status ] ) ? '<p class=\"wpide-form-notice\" role=\"status\">' . esc_html( $messages[ $status ] ) . '</p>' : '';\n    $html .= '<form method=\"post\" action=\"\">';\n    $html .= '<p><label for=\"wpide-cb-name\">Name</label><br><input id=\"wpide-cb-name\" name=\"wpide_cb_name\" required autocomplete=\"name\"></p>';\n    $html .= '<p><label for=\"wpide-cb-phone\">Phone number</label><br><input type=\"tel\" id=\"wpide-cb-phone\" name=\"wpide_cb_phone\" required autocomplete=\"tel\"></p>';\n    $html .= '<p><label for=\"wpide-cb-slot\">Best time to call</label><br><select id=\"wpide-cb-slot\" name=\"wpide_cb_slot\">';\n    foreach ( $wpide_callback['slots'] as $slot ) {\n        $html .= '<option>' . esc_html( $slot ) . '</option>';\n    }\n    $html .= '</select></p><p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_cb_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>';\n    $html .= wp_nonce_field( 'wpide_cb', 'wpide_cb_nonce', false, false );\n    $html .= '<p><button type=\"submit\">Call me back</button></p></form></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Change the time slots and recipient at the top; works well in a sidebar or at the end of service pages."
        },
        {
            "id": "form-event-rsvp",
            "version": 1,
            "title": "Event RSVP Form With a Guest Limit",
            "description": "Adds an [wpide_rsvp] shortcode for events: guests reply with their name, email and party size, replies are saved on the event post with a running total, and the form closes when it is full.",
            "category": "forms",
            "tags": [
                "rsvp",
                "events",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_rsvp_nonce'] ) || empty( $_POST['wpide_rsvp_post'] ) ) {\n        return;\n    }\n\n    $post_id = absint( $_POST['wpide_rsvp_post'] );\n    $back    = remove_query_arg( 'wpide_rsvp', get_permalink( $post_id ) ? get_permalink( $post_id ) : home_url( '/' ) );\n    $done    = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_rsvp', $status, $back ) . '#wpide-rsvp' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_rsvp_nonce'] ) ), 'wpide_rsvp_' . $post_id ) || 'publish' !== get_post_status( $post_id ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_rsvp_hp'] ) ) {\n        $done( 'thanks' );\n    }\n\n    $name   = sanitize_text_field( wp_unslash( isset( $_POST['wpide_rsvp_name'] ) ? $_POST['wpide_rsvp_name'] : '' ) );\n    $email  = strtolower( sanitize_email( wp_unslash( isset( $_POST['wpide_rsvp_email'] ) ? $_POST['wpide_rsvp_email'] : '' ) ) );\n    $guests = max( 1, min( 10, absint( isset( $_POST['wpide_rsvp_guests'] ) ? $_POST['wpide_rsvp_guests'] : 1 ) ) );\n    $limit  = absint( get_post_meta( $post_id, '_wpide_rsvp_limit', true ) );\n\n    if ( '' === $name || ! is_email( $email ) ) {\n        $done( 'invalid' );\n    }\n\n    $list  = get_post_meta( $post_id, '_wpide_rsvp_list', true );\n    $list  = is_array( $list ) ? $list : array();\n    $total = array_sum( wp_list_pluck( $list, 'guests' ) );\n\n    if ( isset( $list[ md5( $email ) ] ) ) {\n        $done( 'duplicate' );\n    }\n    if ( $limit && $total + $guests > $limit ) {\n        $done( 'full' );\n    }\n\n    $list[ md5( $email ) ] = array( 'name' => $name, 'email' => $email, 'guests' => $guests, 'time' => time() );\n    update_post_meta( $post_id, '_wpide_rsvp_list', $list );\n\n    wp_mail( $email, 'Your RSVP for ' . wp_strip_all_tags( get_the_title( $post_id ) ), sprintf( \"Hi %s,\\n\\nThanks for your RSVP for %d guest(s). See you there!\\n\\n%s\", $name, $guests, get_permalink( $post_id ) ) );\n    $done( 'thanks' );\n} );\n\nadd_shortcode( 'wpide_rsvp', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'limit' => 0 ), $atts, 'wpide_rsvp' );\n    $post_id = get_the_ID();\n\n    if ( ! $post_id ) {\n        return '';\n    }\n    if ( (int) get_post_meta( $post_id, '_wpide_rsvp_limit', true ) !== (int) $atts['limit'] && current_user_can( 'edit_post', $post_id ) ) {\n        update_post_meta( $post_id, '_wpide_rsvp_limit', absint( $atts['limit'] ) );\n    }\n\n    $list  = get_post_meta( $post_id, '_wpide_rsvp_list', true );\n    $total = is_array( $list ) ? array_sum( wp_list_pluck( $list, 'guests' ) ) : 0;\n    $limit = absint( $atts['limit'] );\n    $left  = $limit ? max( 0, $limit - $total ) : null;\n\n    $messages = array(\n        'thanks'    => 'Thank you! Your RSVP is confirmed and a confirmation email is on its way.',\n        'duplicate' => 'This email address has already replied.',\n        'full'      => 'Sorry, there are not enough places left for your party.',\n        'invalid'   => 'Please enter your name and a valid email address.',\n        'expired'   => 'The form expired. Please try again.',\n    );\n    $status = isset( $_GET['wpide_rsvp'] ) ? sanitize_key( $_GET['wpide_rsvp'] ) : '';\n\n    $html  = '<div id=\"wpide-rsvp\" class=\"wpide-form wpide-rsvp\">';\n    $html .= isset( $messages[ $status ] ) ? '<p class=\"wpide-form-notice\" role=\"status\">' . esc_html( $messages[ $status ] ) . '</p>' : '';\n\n    if ( null !== $left && 0 === $left ) {\n        return $html . '<p><strong>This event is fully booked.</strong></p></div>';\n    }\n\n    $html .= null !== $left ? '<p>' . esc_html( sprintf( '%d place(s) left.', $left ) ) . '</p>' : '';\n    $html .= '<form method=\"post\" action=\"\">';\n    $html .= '<p><label for=\"wpide-rsvp-name\">Name</label><br><input id=\"wpide-rsvp-name\" name=\"wpide_rsvp_name\" required autocomplete=\"name\"></p>';\n    $html .= '<p><label for=\"wpide-rsvp-email\">Email</label><br><input type=\"email\" id=\"wpide-rsvp-email\" name=\"wpide_rsvp_email\" required autocomplete=\"email\"></p>';\n    $html .= '<p><label for=\"wpide-rsvp-guests\">Number of guests (including you)</label><br><input type=\"number\" id=\"wpide-rsvp-guests\" name=\"wpide_rsvp_guests\" min=\"1\" max=\"10\" value=\"1\"></p>';\n    $html .= '<p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_rsvp_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>';\n    $html .= '<input type=\"hidden\" name=\"wpide_rsvp_post\" value=\"' . esc_attr( $post_id ) . '\">';\n    $html .= wp_nonce_field( 'wpide_rsvp_' . $post_id, 'wpide_rsvp_nonce', false, false );\n    $html .= '<p><button type=\"submit\">RSVP</button></p></form></div>';\n\n    return $html;\n} );\n\nadd_action( 'add_meta_boxes', function ( $post_type, $post ) {\n    if ( $post && get_post_meta( $post->ID, '_wpide_rsvp_list', true ) ) {\n        add_meta_box( 'wpide-rsvp-list', 'RSVPs', function ( $post ) {\n            $list = (array) get_post_meta( $post->ID, '_wpide_rsvp_list', true );\n            printf( '<p><strong>%d guest(s)</strong> from %d replies</p><ul>', (int) array_sum( wp_list_pluck( $list, 'guests' ) ), count( $list ) );\n            foreach ( $list as $row ) {\n                printf( '<li>%s &lt;%s&gt; × %d</li>', esc_html( $row['name'] ), esc_html( $row['email'] ), (int) $row['guests'] );\n            }\n            echo '</ul>';\n        }, null, 'side' );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Use [wpide_rsvp limit=\"50\"] on the event post or page; each email address can reply once and the guest list appears in a box on that post's edit screen."
        },
        {
            "id": "form-testimonial-submission",
            "version": 1,
            "title": "Testimonial Submission Form for Customers",
            "description": "Adds a [wpide_testimonial_form] shortcode where customers submit a testimonial with their name and rating; each one is saved as a pending post for you to review and publish.",
            "category": "forms",
            "tags": [
                "testimonials",
                "shortcode",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_testi_nonce'] ) ) {\n        return;\n    }\n\n    $back = remove_query_arg( 'wpide_testi', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $done = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_testi', $status, $back ) . '#wpide-testimonial' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_testi_nonce'] ) ), 'wpide_testi' ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_testi_hp'] ) ) {\n        $done( 'thanks' );\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_testi_' . md5( wp_salt() . $ip );\n    if ( get_transient( $key ) ) {\n        $done( 'limit' );\n    }\n\n    $name   = sanitize_text_field( wp_unslash( isset( $_POST['wpide_testi_name'] ) ? $_POST['wpide_testi_name'] : '' ) );\n    $role   = sanitize_text_field( wp_unslash( isset( $_POST['wpide_testi_role'] ) ? $_POST['wpide_testi_role'] : '' ) );\n    $text   = sanitize_textarea_field( wp_unslash( isset( $_POST['wpide_testi_text'] ) ? $_POST['wpide_testi_text'] : '' ) );\n    $rating = max( 1, min( 5, absint( isset( $_POST['wpide_testi_rating'] ) ? $_POST['wpide_testi_rating'] : 5 ) ) );\n\n    if ( '' === $name || strlen( $text ) < 20 ) {\n        $done( 'invalid' );\n    }\n\n    $type = post_type_exists( 'testimonial' ) ? 'testimonial' : 'post';\n    $args = array(\n        'post_type'    => $type,\n        'post_status'  => 'pending',\n        'post_title'   => $name . ( $role ? ', ' . $role : '' ),\n        'post_content' => $text,\n        'meta_input'   => array( '_wpide_rating' => $rating ),\n    );\n    if ( 'post' === $type ) {\n        $term = term_exists( 'Testimonials', 'category' );\n        $term = $term ? $term : wp_insert_term( 'Testimonials', 'category' );\n        if ( ! is_wp_error( $term ) ) {\n            $args['post_category'] = array( (int) $term['term_id'] );\n        }\n    }\n\n    $id = wp_insert_post( wp_slash( $args ) );\n    if ( $id && ! is_wp_error( $id ) ) {\n        set_transient( $key, 1, DAY_IN_SECONDS );\n        wp_mail( get_option( 'admin_email' ), 'New testimonial to review', sprintf( \"%s left a %d-star testimonial:\\n\\n%s\\n\\nReview it: %s\", $name, $rating, $text, admin_url( 'post.php?post=' . $id . '&action=edit' ) ) );\n    }\n\n    $done( 'thanks' );\n} );\n\nadd_shortcode( 'wpide_testimonial_form', function () {\n    $messages = array(\n        'thanks'  => 'Thank you for your kind words! Your testimonial will appear once we have reviewed it.',\n        'invalid' => 'Please enter your name and at least a couple of sentences.',\n        'limit'   => 'Thanks, we have already received your testimonial.',\n        'expired' => 'The form expired. Please try again.',\n    );\n    $status = isset( $_GET['wpide_testi'] ) ? sanitize_key( $_GET['wpide_testi'] ) : '';\n\n    $html  = '<div id=\"wpide-testimonial\" class=\"wpide-form wpide-testimonial-form\">';\n    $html .= isset( $messages[ $status ] ) ? '<p class=\"wpide-form-notice\" role=\"status\">' . esc_html( $messages[ $status ] ) . '</p>' : '';\n    $html .= '<form method=\"post\" action=\"\">';\n    $html .= '<p><label for=\"wpide-testi-name\">Your name</label><br><input id=\"wpide-testi-name\" name=\"wpide_testi_name\" required autocomplete=\"name\"></p>';\n    $html .= '<p><label for=\"wpide-testi-role\">Company or role (optional)</label><br><input id=\"wpide-testi-role\" name=\"wpide_testi_role\" autocomplete=\"organization\"></p>';\n    $html .= '<fieldset><legend>Rating</legend>';\n    for ( $i = 5; $i >= 1; $i-- ) {\n        $html .= sprintf( '<label style=\"margin-right:.75em\"><input type=\"radio\" name=\"wpide_testi_rating\" value=\"%1$d\"%2$s> %1$d ★</label>', $i, 5 === $i ? ' checked' : '' );\n    }\n    $html .= '</fieldset>';\n    $html .= '<p><label for=\"wpide-testi-text\">Your testimonial</label><br><textarea id=\"wpide-testi-text\" name=\"wpide_testi_text\" rows=\"5\" required minlength=\"20\"></textarea></p>';\n    $html .= '<p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_testi_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>';\n    $html .= wp_nonce_field( 'wpide_testi', 'wpide_testi_nonce', false, false );\n    $html .= '<p><button type=\"submit\">Send testimonial</button></p></form></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Submissions become pending posts of the testimonial post type if one exists (otherwise regular posts in a \"Testimonials\" category), and the admin is emailed about each one."
        },
        {
            "id": "form-job-application",
            "version": 1,
            "title": "Job Application Form With CV Upload",
            "description": "Adds a [wpide_job_application] shortcode where candidates send their details and a CV (PDF or Word, size-limited); the CV is attached to the email you receive and then deleted from the server.",
            "category": "forms",
            "tags": [
                "jobs",
                "upload",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_jobs = array(\n    'to'     => '',      // Recipient; empty = admin email.\n    'max_mb' => 5,       // Maximum CV size.\n    'types'  => array( 'pdf' => 'application/pdf', 'doc' => 'application/msword', 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ),\n);\n\nadd_action( 'template_redirect', function () use ( $wpide_jobs ) {\n    if ( empty( $_POST['wpide_job_nonce'] ) ) {\n        return;\n    }\n\n    $back = remove_query_arg( 'wpide_job', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $done = function ( $status ) use ( $back ) {\n        wp_safe_redirect( add_query_arg( 'wpide_job', $status, $back ) . '#wpide-job' );\n        exit;\n    };\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_job_nonce'] ) ), 'wpide_job' ) ) {\n        $done( 'expired' );\n    }\n    if ( ! empty( $_POST['wpide_job_hp'] ) ) {\n        $done( 'sent' );\n    }\n\n    $name     = str_replace( array( '<', '>', '\"' ), '', sanitize_text_field( wp_unslash( isset( $_POST['wpide_job_name'] ) ? $_POST['wpide_job_name'] : '' ) ) );\n    $email    = sanitize_email( wp_unslash( isset( $_POST['wpide_job_email'] ) ? $_POST['wpide_job_email'] : '' ) );\n    $position = sanitize_text_field( wp_unslash( isset( $_POST['wpide_job_position'] ) ? $_POST['wpide_job_position'] : '' ) );\n    $letter   = sanitize_textarea_field( wp_unslash( isset( $_POST['wpide_job_letter'] ) ? $_POST['wpide_job_letter'] : '' ) );\n    $file     = isset( $_FILES['wpide_job_cv'] ) ? $_FILES['wpide_job_cv'] : null; // phpcs:ignore\n\n    if ( '' === $name || ! is_email( $email ) || ! $file || UPLOAD_ERR_OK !== $file['error'] ) {\n        $done( 'invalid' );\n    }\n    if ( $file['size'] > $wpide_jobs['max_mb'] * MB_IN_BYTES ) {\n        $done( 'size' );\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/file.php';\n    $upload = wp_handle_upload( $file, array( 'test_form' => false, 'mimes' => $wpide_jobs['types'] ) );\n\n    if ( empty( $upload['file'] ) ) {\n        $done( 'type' );\n    }\n\n    $to   = is_email( $wpide_jobs['to'] ) ? $wpide_jobs['to'] : get_option( 'admin_email' );\n    $sent = wp_mail( $to, sprintf( 'Application: %s – %s', $position ? $position : 'Open application', $name ), \"Name: $name\\nEmail: $email\\nPosition: $position\\n\\n$letter\", array( 'Reply-To: ' . $name . ' <' . $email . '>' ), array( $upload['file'] ) );\n\n    wp_delete_file( $upload['file'] );\n    $done( $sent ? 'sent' : 'error' );\n} );\n\nadd_shortcode( 'wpide_job_application', function ( $atts ) use ( $wpide_jobs ) {\n    $atts     = shortcode_atts( array( 'position' => '' ), $atts, 'wpide_job_application' );\n    $messages = array(\n        'sent'    => 'Thank you for applying! We will review your application and be in touch.',\n        'invalid' => 'Please fill in your name and email and attach your CV.',\n        'size'    => sprintf( 'Your CV is too large. The maximum size is %d MB.', $wpide_jobs['max_mb'] ),\n        'type'    => 'Please upload your CV as a PDF or Word document.',\n        'expired' => 'The form expired. Please try again.',\n        'error'   => 'Sorry, your application could not be sent. Please email us directly.',\n    );\n    $status = isset( $_GET['wpide_job'] ) ? sanitize_key( $_GET['wpide_job'] ) : '';\n\n    $html  = '<div id=\"wpide-job\" class=\"wpide-form wpide-job-form\">';\n    $html .= isset( $messages[ $status ] ) ? '<p class=\"wpide-form-notice\" role=\"status\">' . esc_html( $messages[ $status ] ) . '</p>' : '';\n    $html .= '<form method=\"post\" action=\"\" enctype=\"multipart/form-data\">';\n    $html .= '<p><label for=\"wpide-job-name\">Full name</label><br><input id=\"wpide-job-name\" name=\"wpide_job_name\" required autocomplete=\"name\"></p>';\n    $html .= '<p><label for=\"wpide-job-email\">Email</label><br><input type=\"email\" id=\"wpide-job-email\" name=\"wpide_job_email\" required autocomplete=\"email\"></p>';\n    $html .= '<p><label for=\"wpide-job-cv\">CV (PDF or Word, max ' . (int) $wpide_jobs['max_mb'] . ' MB)</label><br><input type=\"file\" id=\"wpide-job-cv\" name=\"wpide_job_cv\" required accept=\".pdf,.doc,.docx\"></p>';\n    $html .= '<p><label for=\"wpide-job-letter\">Why are you a great fit?</label><br><textarea id=\"wpide-job-letter\" name=\"wpide_job_letter\" rows=\"6\"></textarea></p>';\n    $html .= '<input type=\"hidden\" name=\"wpide_job_position\" value=\"' . esc_attr( $atts['position'] ) . '\">';\n    $html .= '<p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_job_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>';\n    $html .= wp_nonce_field( 'wpide_job', 'wpide_job_nonce', false, false );\n    $html .= '<p><button type=\"submit\">Send application</button></p></form></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Use [wpide_job_application position=\"Senior Designer\"] to name the role and change the size limit and recipient at the top; CVs are never kept on the server."
        },
        {
            "id": "form-simple-poll",
            "version": 1,
            "title": "Simple Poll Shortcode",
            "description": "Adds a [wpide_poll] shortcode that shows a question with answer buttons and, after voting, the results as accessible bars. One vote per visitor, stored without personal data.",
            "category": "forms",
            "tags": [
                "poll",
                "voting",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_poll_nonce'] ) || empty( $_POST['wpide_poll_id'] ) ) {\n        return;\n    }\n\n    $poll   = sanitize_key( wp_unslash( $_POST['wpide_poll_id'] ) );\n    $choice = isset( $_POST['wpide_poll_choice'] ) ? absint( $_POST['wpide_poll_choice'] ) : -1;\n    $back   = wp_get_referer() ? wp_get_referer() : home_url( '/' );\n\n    if ( wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_poll_nonce'] ) ), 'wpide_poll_' . $poll ) && $choice >= 0 && $choice < 20 && empty( $_COOKIE[ 'wpide_poll_' . $poll ] ) ) {\n        $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n        $key = 'wpide_poll_ip_' . md5( wp_salt() . $poll . $ip );\n\n        if ( ! get_transient( $key ) ) {\n            $votes            = (array) get_option( 'wpide_poll_' . $poll, array() );\n            $votes[ $choice ] = ( isset( $votes[ $choice ] ) ? (int) $votes[ $choice ] : 0 ) + 1;\n            update_option( 'wpide_poll_' . $poll, $votes, false );\n            set_transient( $key, 1, DAY_IN_SECONDS );\n        }\n\n        if ( ! headers_sent() ) {\n            setcookie( 'wpide_poll_' . $poll, '1', time() + YEAR_IN_SECONDS, COOKIEPATH, COOKIE_DOMAIN, is_ssl(), true );\n        }\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_voted', $poll, $back ) . '#wpide-poll-' . $poll );\n    exit;\n} );\n\nadd_shortcode( 'wpide_poll', function ( $atts ) {\n    $atts    = shortcode_atts( array( 'id' => 'poll', 'question' => 'What do you think?', 'options' => 'Yes|No' ), $atts, 'wpide_poll' );\n    $poll    = sanitize_key( $atts['id'] );\n    $options = array_slice( array_map( 'trim', explode( '|', $atts['options'] ) ), 0, 20 );\n    $voted   = ! empty( $_COOKIE[ 'wpide_poll_' . $poll ] ) || ( isset( $_GET['wpide_voted'] ) && sanitize_key( $_GET['wpide_voted'] ) === $poll );\n\n    $html = '<div id=\"wpide-poll-' . esc_attr( $poll ) . '\" class=\"wpide-poll\"><p class=\"wpide-poll-question\"><strong>' . esc_html( $atts['question'] ) . '</strong></p>';\n\n    if ( $voted ) {\n        $votes = (array) get_option( 'wpide_poll_' . $poll, array() );\n        $total = max( 1, array_sum( $votes ) );\n        $html .= '<ul class=\"wpide-poll-results\" style=\"list-style:none;padding:0;\">';\n        foreach ( $options as $i => $label ) {\n            $count = isset( $votes[ $i ] ) ? (int) $votes[ $i ] : 0;\n            $pct   = round( $count / $total * 100 );\n            $html .= sprintf( '<li style=\"margin:.4em 0;\"><span>%1$s – %2$d%%</span><div role=\"img\" aria-label=\"%1$s: %2$d percent\" style=\"background:rgba(127,127,127,.2);border-radius:4px;height:.6em;\"><div style=\"width:%2$d%%;height:100%%;background:currentColor;border-radius:4px;\"></div></div></li>', esc_html( $label ), (int) $pct );\n        }\n        return $html . '</ul><p><small>' . esc_html( sprintf( '%d vote(s)', array_sum( $votes ) ) ) . '</small></p></div>';\n    }\n\n    $html .= '<form method=\"post\" action=\"\">';\n    foreach ( $options as $i => $label ) {\n        $html .= sprintf( '<button type=\"submit\" name=\"wpide_poll_choice\" value=\"%d\" style=\"margin:0 .5em .5em 0;\">%s</button>', (int) $i, esc_html( $label ) );\n    }\n    $html .= '<input type=\"hidden\" name=\"wpide_poll_id\" value=\"' . esc_attr( $poll ) . '\">' . wp_nonce_field( 'wpide_poll_' . $poll, 'wpide_poll_nonce', false, false ) . '</form></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Use [wpide_poll id=\"coffee\" question=\"Tea or coffee?\" options=\"Tea|Coffee|Neither\"] with a unique id per poll; votes are counted in an option per poll."
        },
        {
            "id": "form-report-a-problem",
            "version": 1,
            "title": "\"Report a Problem on This Page\" Form",
            "description": "Adds a small expandable form at the end of posts where readers can report a typo, broken link or outdated information; reports are emailed to the post author with the page link.",
            "category": "forms",
            "tags": [
                "feedback",
                "content",
                "corrections"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_report_types = array( 'post' ); // Post types that show the form.\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_report_types ) {\n    if ( ! is_singular( $wpide_report_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $sent = isset( $_GET['wpide_report'] ) && 'sent' === $_GET['wpide_report'];\n    $form = '<details class=\"wpide-report\"' . ( $sent ? ' open' : '' ) . '><summary>Spotted a mistake? Let us know</summary>';\n    $form .= $sent ? '<p role=\"status\">Thank you! We will take a look.</p>' : '';\n    $form .= '<form method=\"post\" action=\"\"><p><label for=\"wpide-report-type\">What is wrong?</label><br><select id=\"wpide-report-type\" name=\"wpide_report_type\"><option>Typo or grammar</option><option>Broken link</option><option>Outdated information</option><option>Something else</option></select></p>';\n    $form .= '<p><label for=\"wpide-report-text\">Details</label><br><textarea id=\"wpide-report-text\" name=\"wpide_report_text\" rows=\"3\" required maxlength=\"1000\"></textarea></p>';\n    $form .= '<p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_report_hp\" tabindex=\"-1\" autocomplete=\"off\"></p>';\n    $form .= '<input type=\"hidden\" name=\"wpide_report_post\" value=\"' . esc_attr( get_the_ID() ) . '\">' . wp_nonce_field( 'wpide_report', 'wpide_report_nonce', false, false );\n    $form .= '<p><button type=\"submit\">Send report</button></p></form></details>';\n\n    return $content . $form;\n}, 20 );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_report_nonce'] ) || empty( $_POST['wpide_report_post'] ) ) {\n        return;\n    }\n\n    $post_id = absint( $_POST['wpide_report_post'] );\n    $link    = get_permalink( $post_id );\n\n    if ( ! $link || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_report_nonce'] ) ), 'wpide_report' ) || ! empty( $_POST['wpide_report_hp'] ) ) {\n        return;\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_report_' . md5( wp_salt() . $ip );\n    $text = sanitize_textarea_field( wp_unslash( isset( $_POST['wpide_report_text'] ) ? $_POST['wpide_report_text'] : '' ) );\n    $type = sanitize_text_field( wp_unslash( isset( $_POST['wpide_report_type'] ) ? $_POST['wpide_report_type'] : '' ) );\n\n    if ( '' !== trim( $text ) && (int) get_transient( $key ) < 5 ) {\n        $author = get_userdata( (int) get_post_field( 'post_author', $post_id ) );\n        $to     = $author && is_email( $author->user_email ) ? $author->user_email : get_option( 'admin_email' );\n        wp_mail( $to, 'Problem reported: ' . wp_strip_all_tags( get_the_title( $post_id ) ), \"Type: $type\\nPage: $link\\n\\n$text\" );\n        set_transient( $key, (int) get_transient( $key ) + 1, DAY_IN_SECONDS );\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_report', 'sent', $link ) . '#content' );\n    exit;\n} );",
            "requires": [],
            "notes": "Shows on single posts by default (change the post types at the top) and accepts at most five reports per visitor per day."
        },
        {
            "id": "form-page-feedback",
            "version": 1,
            "title": "\"Was This Page Helpful?\" Buttons",
            "description": "Adds Yes and No buttons at the end of posts and pages so readers can rate them, and shows the helpful score in a column in the admin post list.",
            "category": "forms",
            "tags": [
                "feedback",
                "content",
                "ratings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_helpful_types = array( 'post', 'page' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_helpful_types ) {\n    if ( ! is_singular( $wpide_helpful_types ) || ! in_the_loop() || ! is_main_query() ) {\n        return $content;\n    }\n\n    $id = get_the_ID();\n    if ( isset( $_GET['wpide_helpful'] ) && (int) $_GET['wpide_helpful'] === $id ) {\n        return $content . '<p class=\"wpide-helpful\" role=\"status\">Thanks for your feedback!</p>';\n    }\n\n    $form  = '<form class=\"wpide-helpful\" method=\"post\" action=\"\" style=\"display:flex;gap:.5em;align-items:center;flex-wrap:wrap;margin-top:2em;\">';\n    $form .= '<span>Was this page helpful?</span>';\n    $form .= '<button type=\"submit\" name=\"wpide_helpful_vote\" value=\"yes\">Yes</button><button type=\"submit\" name=\"wpide_helpful_vote\" value=\"no\">No</button>';\n    $form .= '<input type=\"hidden\" name=\"wpide_helpful_post\" value=\"' . esc_attr( $id ) . '\">' . wp_nonce_field( 'wpide_helpful', 'wpide_helpful_nonce', false, false ) . '</form>';\n\n    return $content . $form;\n}, 25 );\n\nadd_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_helpful_nonce'] ) || empty( $_POST['wpide_helpful_post'] ) ) {\n        return;\n    }\n\n    $post_id = absint( $_POST['wpide_helpful_post'] );\n    $vote    = isset( $_POST['wpide_helpful_vote'] ) && 'yes' === $_POST['wpide_helpful_vote'] ? 'yes' : 'no';\n    $link    = get_permalink( $post_id );\n\n    if ( ! $link || ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_helpful_nonce'] ) ), 'wpide_helpful' ) ) {\n        return;\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_helpful_' . md5( wp_salt() . $post_id . $ip );\n\n    if ( ! get_transient( $key ) ) {\n        $meta = '_wpide_helpful_' . $vote;\n        update_post_meta( $post_id, $meta, (int) get_post_meta( $post_id, $meta, true ) + 1 );\n        set_transient( $key, 1, WEEK_IN_SECONDS );\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_helpful', $post_id, $link ) );\n    exit;\n} );\n\nforeach ( $wpide_helpful_types as $wpide_type ) {\n    add_filter( \"manage_{$wpide_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_helpful'] = 'Helpful';\n        return $columns;\n    } );\n    add_action( \"manage_{$wpide_type}_posts_custom_column\", function ( $column, $post_id ) {\n        if ( 'wpide_helpful' !== $column ) {\n            return;\n        }\n        $yes = (int) get_post_meta( $post_id, '_wpide_helpful_yes', true );\n        $no  = (int) get_post_meta( $post_id, '_wpide_helpful_no', true );\n        echo $yes + $no ? esc_html( sprintf( '%d%% (%d votes)', round( $yes / ( $yes + $no ) * 100 ), $yes + $no ) ) : '—';\n    }, 10, 2 );\n}",
            "requires": [],
            "notes": "Votes are saved as post meta through a nonce-checked request, limited to one per visitor per page; change the post types at the top."
        },
        {
            "id": "form-gated-download",
            "version": 1,
            "title": "Email-Gated Download Form",
            "description": "Adds a [wpide_gated_download] shortcode that asks for an email address before a guide or PDF can be downloaded, then emails a private download link valid for 48 hours.",
            "category": "forms",
            "tags": [
                "lead-magnet",
                "downloads",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    // Download link from the email.\n    if ( isset( $_GET['wpide_dl'], $_GET['f'], $_GET['e'] ) ) {\n        $file    = absint( $_GET['f'] );\n        $expires = absint( $_GET['e'] );\n        $sig     = sanitize_text_field( wp_unslash( $_GET['wpide_dl'] ) );\n\n        if ( $expires >= time() && hash_equals( wp_hash( 'dl|' . $file . '|' . $expires ), $sig ) && 'attachment' === get_post_type( $file ) ) {\n            wp_safe_redirect( wp_get_attachment_url( $file ) );\n            exit;\n        }\n        wp_die( 'This download link has expired. Please request a new one.', 'Link expired', array( 'response' => 410 ) );\n    }\n\n    if ( empty( $_POST['wpide_gate_nonce'] ) || empty( $_POST['wpide_gate_file'] ) ) {\n        return;\n    }\n\n    $file = absint( $_POST['wpide_gate_file'] );\n    $back = remove_query_arg( 'wpide_gate', wp_get_referer() ? wp_get_referer() : home_url( '/' ) );\n    $email = sanitize_email( wp_unslash( isset( $_POST['wpide_gate_email'] ) ? $_POST['wpide_gate_email'] : '' ) );\n\n    if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST['wpide_gate_nonce'] ) ), 'wpide_gate_' . $file ) || 'attachment' !== get_post_type( $file ) || ! is_email( $email ) || ! empty( $_POST['wpide_gate_hp'] ) ) {\n        wp_safe_redirect( add_query_arg( 'wpide_gate', 'invalid', $back ) );\n        exit;\n    }\n\n    $ip  = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key = 'wpide_gate_' . md5( wp_salt() . $ip );\n    if ( (int) get_transient( $key ) < 5 ) {\n        $expires = time() + 2 * DAY_IN_SECONDS;\n        $link    = add_query_arg( array( 'wpide_dl' => wp_hash( 'dl|' . $file . '|' . $expires ), 'f' => $file, 'e' => $expires ), home_url( '/' ) );\n        $title   = wp_strip_all_tags( get_the_title( $file ) );\n\n        wp_mail( $email, 'Your download: ' . $title, \"Here is your download link (valid for 48 hours):\\n\\n$link\\n\\nEnjoy!\" );\n        wp_mail( get_option( 'admin_email' ), 'New download request', \"$email requested \\\"$title\\\".\" );\n        set_transient( $key, (int) get_transient( $key ) + 1, HOUR_IN_SECONDS );\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_gate', 'sent', $back ) );\n    exit;\n} );\n\nadd_shortcode( 'wpide_gated_download', function ( $atts ) {\n    $atts = shortcode_atts( array( 'file' => 0, 'title' => 'Download the guide', 'button' => 'Email me the link' ), $atts, 'wpide_gated_download' );\n    $file = absint( $atts['file'] );\n\n    if ( ! $file ) {\n        return current_user_can( 'edit_posts' ) ? '<p><em>Gated download: add file=\"ATTACHMENT_ID\" to the shortcode.</em></p>' : '';\n    }\n\n    $status = isset( $_GET['wpide_gate'] ) ? sanitize_key( $_GET['wpide_gate'] ) : '';\n    $html   = '<div class=\"wpide-form wpide-gated-download\"><p><strong>' . esc_html( $atts['title'] ) . '</strong></p>';\n\n    if ( 'sent' === $status ) {\n        return $html . '<p role=\"status\">Check your inbox, the download link is on its way.</p></div>';\n    }\n\n    $html .= 'invalid' === $status ? '<p role=\"alert\">Please enter a valid email address.</p>' : '';\n    $html .= '<form method=\"post\" action=\"\"><label class=\"screen-reader-text\" for=\"wpide-gate-' . $file . '\">Email address</label>';\n    $html .= '<input type=\"email\" id=\"wpide-gate-' . $file . '\" name=\"wpide_gate_email\" required autocomplete=\"email\" placeholder=\"you@example.com\"> ';\n    $html .= '<span style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><input name=\"wpide_gate_hp\" tabindex=\"-1\" autocomplete=\"off\"></span>';\n    $html .= '<input type=\"hidden\" name=\"wpide_gate_file\" value=\"' . esc_attr( $file ) . '\">' . wp_nonce_field( 'wpide_gate_' . $file, 'wpide_gate_nonce', false, false );\n    $html .= '<button type=\"submit\">' . esc_html( $atts['button'] ) . '</button></form></div>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Use [wpide_gated_download file=\"123\" title=\"Free pricing guide\"] with the media attachment ID; the file URL is never shown on the page and collected emails are sent to the admin."
        },
        {
            "id": "form-comment-consent-required",
            "version": 1,
            "title": "Required Privacy Consent Checkbox on Comments",
            "description": "Adds a required \"I agree to the privacy policy\" checkbox to the comment form and rejects comments that are submitted without it, with a link to your privacy page.",
            "category": "forms",
            "tags": [
                "comments",
                "privacy",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_submit_field', function ( $field ) {\n    if ( is_user_logged_in() ) {\n        return $field;\n    }\n\n    $policy = get_privacy_policy_url();\n    $label  = $policy ? sprintf( 'I agree to the storage of my data as described in the <a href=\"%s\" target=\"_blank\" rel=\"noopener\">privacy policy</a>.', esc_url( $policy ) ) : 'I agree to the storage of my data for handling this comment.';\n\n    return '<p class=\"comment-form-privacy-consent\"><input type=\"checkbox\" id=\"wpide-comment-consent\" name=\"wpide_comment_consent\" value=\"1\" required> <label for=\"wpide-comment-consent\">' . $label . '</label></p>' . $field;\n} );\n\nadd_filter( 'preprocess_comment', function ( $commentdata ) {\n    if ( is_user_logged_in() || ( isset( $commentdata['comment_type'] ) && in_array( $commentdata['comment_type'], array( 'pingback', 'trackback' ), true ) ) ) {\n        return $commentdata;\n    }\n\n    if ( empty( $_POST['wpide_comment_consent'] ) ) {\n        wp_die( 'Please tick the privacy consent box to post your comment.', 'Consent required', array( 'response' => 400, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n} );",
            "requires": [],
            "notes": "Links to the privacy policy page set under Settings > Privacy; logged-in users are not asked."
        },
        {
            "id": "form-comment-placeholders",
            "version": 1,
            "title": "Placeholders in Comment Form Fields",
            "description": "Adds helpful placeholder text to the comment, name, email and website fields of the comment form while keeping the visible labels for accessibility.",
            "category": "forms",
            "tags": [
                "comments",
                "placeholders",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_placeholders = array(\n    'comment' => 'Share your thoughts…',\n    'author'  => 'Your name',\n    'email'   => 'you@example.com (never published)',\n    'url'     => 'https://your-site.com (optional)',\n);\n\n$wpide_add_placeholder = function ( $html, $name ) use ( $wpide_comment_placeholders ) {\n    if ( empty( $wpide_comment_placeholders[ $name ] ) || false !== strpos( $html, 'placeholder=' ) ) {\n        return $html;\n    }\n\n    return preg_replace( '/<(input|textarea)\\b/', '<$1 placeholder=\"' . esc_attr( $wpide_comment_placeholders[ $name ] ) . '\"', $html, 1 );\n};\n\nadd_filter( 'comment_form_default_fields', function ( $fields ) use ( $wpide_add_placeholder ) {\n    foreach ( array( 'author', 'email', 'url' ) as $name ) {\n        if ( isset( $fields[ $name ] ) ) {\n            $fields[ $name ] = $wpide_add_placeholder( $fields[ $name ], $name );\n        }\n    }\n    return $fields;\n} );\n\nadd_filter( 'comment_form_defaults', function ( $defaults ) use ( $wpide_add_placeholder ) {\n    $defaults['comment_field'] = $wpide_add_placeholder( $defaults['comment_field'], 'comment' );\n    return $defaults;\n} );",
            "requires": [],
            "notes": "Edit the texts at the top; placeholders never replace labels, so screen readers still announce each field."
        },
        {
            "id": "form-comment-button-element",
            "version": 1,
            "title": "Theme-Styled Button for the Comment Form",
            "description": "Replaces the comment form's submit input with a real button element that carries your theme's button classes, so it matches the other buttons on your site.",
            "category": "forms",
            "tags": [
                "comments",
                "buttons",
                "styling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_button_classes = 'wp-element-button';\n\nadd_filter( 'comment_form_defaults', function ( $defaults ) use ( $wpide_comment_button_classes ) {\n    $defaults['class_submit']  = trim( ( isset( $defaults['class_submit'] ) ? $defaults['class_submit'] : 'submit' ) . ' ' . $wpide_comment_button_classes );\n    $defaults['submit_button'] = '<button name=\"%1$s\" type=\"submit\" id=\"%2$s\" class=\"%3$s\">%4$s</button>';\n\n    return $defaults;\n} );",
            "requires": [],
            "notes": "Set the classes your theme uses, such as wp-element-button for block themes or btn btn-primary for Bootstrap-based themes."
        },
        {
            "id": "form-comment-star-rating",
            "version": 1,
            "title": "Star Rating Field in Comments",
            "description": "Adds an optional 1 to 5 star rating to the comment form, saves it with the comment and shows the stars above the comment text, for review-style posts.",
            "category": "forms",
            "tags": [
                "comments",
                "ratings",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_rating_types = array( 'post' ); // Post types that get the rating field.\n\n$wpide_rating_field = function () use ( $wpide_rating_types ) {\n    if ( ! is_singular( $wpide_rating_types ) ) {\n        return;\n    }\n\n    echo '<fieldset class=\"comment-form-rating\" style=\"border:0;padding:0;margin:0 0 1em;\"><legend>Your rating (optional)</legend>';\n    for ( $i = 5; $i >= 1; $i-- ) {\n        printf( '<label style=\"margin-right:.6em;\"><input type=\"radio\" name=\"wpide_rating\" value=\"%1$d\"> <span aria-hidden=\"true\">%2$s</span><span class=\"screen-reader-text\">%1$d stars</span></label>', (int) $i, esc_html( str_repeat( '★', $i ) ) );\n    }\n    echo '</fieldset>';\n};\n\nadd_action( 'comment_form_logged_in_after', $wpide_rating_field );\nadd_action( 'comment_form_after_fields', $wpide_rating_field );\n\nadd_action( 'comment_post', function ( $comment_id ) use ( $wpide_rating_types ) {\n    $rating = isset( $_POST['wpide_rating'] ) ? absint( $_POST['wpide_rating'] ) : 0;\n    $post   = get_post( (int) get_comment( $comment_id )->comment_post_ID );\n\n    if ( $rating >= 1 && $rating <= 5 && $post && in_array( $post->post_type, $wpide_rating_types, true ) ) {\n        add_comment_meta( $comment_id, 'wpide_rating', $rating, true );\n    }\n} );\n\nadd_filter( 'comment_text', function ( $text, $comment = null ) {\n    if ( ! $comment || is_admin() ) {\n        return $text;\n    }\n\n    $rating = (int) get_comment_meta( $comment->comment_ID, 'wpide_rating', true );\n\n    return $rating ? sprintf( '<p class=\"comment-rating\" aria-label=\"%1$d out of 5 stars\"><span aria-hidden=\"true\">%2$s</span></p>', $rating, esc_html( str_repeat( '★', $rating ) . str_repeat( '☆', 5 - $rating ) ) ) . $text : $text;\n}, 10, 2 );",
            "requires": [],
            "notes": "Limit it to certain post types with the list at the top; ratings are stored as comment meta named wpide_rating."
        },
        {
            "id": "form-comment-hide-logged-in-as",
            "version": 1,
            "title": "Hide the \"Logged in as\" Line Above Comments",
            "description": "Removes the \"Logged in as … Edit your profile. Log out?\" line from the top of the comment form for a cleaner look on member sites.",
            "category": "forms",
            "tags": [
                "comments",
                "cleanup",
                "members"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_defaults', function ( $defaults ) {\n    $defaults['logged_in_as'] = '';\n\n    return $defaults;\n} );",
            "requires": [],
            "notes": "Members can still log out from the admin bar or your account menu."
        },
        {
            "id": "form-comment-cookies-checked",
            "version": 1,
            "title": "Pre-Tick \"Save My Name\" on the Comment Form",
            "description": "Ticks the \"Save my name, email, and website in this browser\" box on the comment form by default, so returning commenters do not retype their details.",
            "category": "forms",
            "tags": [
                "comments",
                "cookies",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_default_fields', function ( $fields ) {\n    if ( isset( $fields['cookies'] ) && false === strpos( $fields['cookies'], 'checked' ) ) {\n        $fields['cookies'] = str_replace( 'type=\"checkbox\"', 'type=\"checkbox\" checked=\"checked\"', $fields['cookies'] );\n    }\n\n    return $fields;\n} );",
            "requires": [],
            "notes": "Check your privacy rules first; some jurisdictions require consent boxes to start unticked."
        },
        {
            "id": "form-comment-ajax-submit",
            "version": 1,
            "title": "Post Comments Without Reloading the Page",
            "description": "Submits the comment form in the background and shows the new comment (or the \"awaiting moderation\" note) right away, instead of reloading the page.",
            "category": "forms",
            "tags": [
                "comments",
                "ajax",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_ajax_wpide_comment', 'wpide_ajax_comment' );\nadd_action( 'wp_ajax_nopriv_wpide_comment', 'wpide_ajax_comment' );\n\nfunction wpide_ajax_comment() {\n    $comment = wp_handle_comment_submission( wp_unslash( $_POST ) );\n\n    if ( is_wp_error( $comment ) ) {\n        wp_send_json_error( array( 'message' => wp_strip_all_tags( $comment->get_error_message() ) ), 400 );\n    }\n\n    $user = wp_get_current_user();\n    do_action( 'set_comment_cookies', $comment, $user, ! empty( $_POST['wp-comment-cookies-consent'] ) );\n\n    $approved = '1' === (string) $comment->comment_approved;\n    $html     = sprintf(\n        '<li id=\"comment-%1$d\" class=\"comment\"><article class=\"comment-body\"><footer class=\"comment-meta\"><b class=\"fn\">%2$s</b></footer><div class=\"comment-content\">%3$s</div>%4$s</article></li>',\n        (int) $comment->comment_ID,\n        esc_html( get_comment_author( $comment ) ),\n        wp_kses_post( wpautop( $comment->comment_content ) ),\n        $approved ? '' : '<p class=\"comment-awaiting-moderation\"><em>Your comment is awaiting moderation.</em></p>'\n    );\n\n    wp_send_json_success( array( 'html' => $html, 'approved' => $approved ) );\n}\n\nadd_action( 'wp_footer', function () {\n    if ( ! is_singular() || ! comments_open() ) {\n        return;\n    }\n    ?>\n<script>\n( function () {\n    var form = document.getElementById( 'commentform' );\n    if ( ! form || ! window.fetch || ! window.FormData ) { return; }\n    form.addEventListener( 'submit', function ( event ) {\n        event.preventDefault();\n        var button = form.querySelector( '[type=\"submit\"]' );\n        var data = new FormData( form );\n        data.append( 'action', 'wpide_comment' );\n        if ( button ) { button.disabled = true; }\n        var status = form.querySelector( '.wpide-comment-status' );\n        if ( ! status ) { status = document.createElement( 'p' ); status.className = 'wpide-comment-status'; status.setAttribute( 'role', 'status' ); form.appendChild( status ); }\n        fetch( <?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, { method: 'POST', body: data, credentials: 'same-origin' } )\n            .then( function ( r ) { return r.json(); } )\n            .then( function ( res ) {\n                if ( ! res.success ) { status.textContent = res.data && res.data.message ? res.data.message : 'Your comment could not be posted.'; return; }\n                var list = document.querySelector( '.comment-list, .commentlist, .wp-block-comment-template' );\n                if ( list ) { list.insertAdjacentHTML( 'beforeend', res.data.html ); }\n                form.reset();\n                status.textContent = res.data.approved ? 'Thanks, your comment has been posted.' : 'Thanks, your comment is awaiting moderation.';\n            } )\n            .catch( function () { form.submit(); } )\n            .finally( function () { if ( button ) { button.disabled = false; } } );\n    } );\n} )();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Uses WordPress's own comment handling, so spam checks and moderation rules still apply; falls back to the normal form if JavaScript fails."
        },
        {
            "id": "form-registration-confirm-email",
            "version": 1,
            "title": "\"Confirm Email\" Field on the Registration Form",
            "description": "Adds a second email field to the WordPress registration form and refuses the sign-up when the two addresses differ, so new users do not lock themselves out with a typo.",
            "category": "forms",
            "tags": [
                "registration",
                "validation",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'register_form', function () {\n    $value = isset( $_POST['wpide_email_confirm'] ) ? sanitize_email( wp_unslash( $_POST['wpide_email_confirm'] ) ) : '';\n    printf( '<p><label for=\"wpide_email_confirm\">Confirm email</label><input type=\"email\" name=\"wpide_email_confirm\" id=\"wpide_email_confirm\" class=\"input\" value=\"%s\" size=\"25\" autocomplete=\"email\" required></p>', esc_attr( $value ) );\n} );\n\nadd_filter( 'registration_errors', function ( $errors, $login, $email ) {\n    $confirm = isset( $_POST['wpide_email_confirm'] ) ? sanitize_email( wp_unslash( $_POST['wpide_email_confirm'] ) ) : '';\n\n    if ( strtolower( trim( $confirm ) ) !== strtolower( trim( $email ) ) ) {\n        $errors->add( 'wpide_email_mismatch', '<strong>Error:</strong> The email addresses do not match.' );\n    }\n\n    return $errors;\n}, 10, 3 );",
            "requires": [],
            "notes": "Applies to the standard wp-login.php?action=register form."
        },
        {
            "id": "form-registration-phone-field",
            "version": 1,
            "title": "Phone Number Field on Registration and Profiles",
            "description": "Adds a phone number field to the WordPress registration form, saves it with the new account and lets users and admins edit it on the profile screen.",
            "category": "forms",
            "tags": [
                "registration",
                "users",
                "phone"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_phone_required = true;\n\nadd_action( 'register_form', function () use ( $wpide_phone_required ) {\n    $value = isset( $_POST['wpide_phone'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_phone'] ) ) : '';\n    printf( '<p><label for=\"wpide_phone\">Phone number</label><input type=\"tel\" name=\"wpide_phone\" id=\"wpide_phone\" class=\"input\" value=\"%s\" autocomplete=\"tel\"%s></p>', esc_attr( $value ), $wpide_phone_required ? ' required' : '' );\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) use ( $wpide_phone_required ) {\n    $phone  = isset( $_POST['wpide_phone'] ) ? (string) wp_unslash( $_POST['wpide_phone'] ) : '';\n    $digits = strlen( preg_replace( '/\\D/', '', $phone ) );\n\n    if ( ( $wpide_phone_required || '' !== $phone ) && ( $digits < 7 || $digits > 15 || ! preg_match( '/^\\+?[0-9\\s().\\-]+$/', $phone ) ) ) {\n        $errors->add( 'wpide_phone', '<strong>Error:</strong> Please enter a valid phone number.' );\n    }\n\n    return $errors;\n} );\n\nadd_action( 'user_register', function ( $user_id ) {\n    if ( isset( $_POST['wpide_phone'] ) ) {\n        update_user_meta( $user_id, 'phone', sanitize_text_field( wp_unslash( $_POST['wpide_phone'] ) ) );\n    }\n} );\n\nadd_filter( 'user_contactmethods', function ( $methods ) {\n    $methods['phone'] = 'Phone number';\n\n    return $methods;\n} );",
            "requires": [],
            "notes": "Set $wpide_phone_required to false to make it optional; the number is stored in user meta named phone."
        },
        {
            "id": "form-registration-auto-login",
            "version": 1,
            "title": "Log New Users In Right After Registering",
            "description": "Signs people in straight after they register through the WordPress registration form and sends them to a welcome page, instead of making them wait for an email.",
            "category": "forms",
            "tags": [
                "registration",
                "login",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_welcome_url = '/welcome/';\n\nadd_action( 'register_new_user', function ( $user_id ) use ( $wpide_welcome_url ) {\n    if ( is_admin() || wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || is_user_logged_in() ) {\n        return;\n    }\n\n    wp_set_current_user( $user_id );\n    wp_set_auth_cookie( $user_id, false, is_ssl() );\n\n    add_filter( 'registration_redirect', function () use ( $wpide_welcome_url ) {\n        return home_url( $wpide_welcome_url );\n    }, 99 );\n}, 99 );",
            "requires": [],
            "notes": "Change the welcome URL at the top, and only use this when users choose their own password at registration, because the emailed set-password link still arrives."
        },
        {
            "id": "form-lost-password-message",
            "version": 1,
            "title": "Helpful Message on the Lost Password Form",
            "description": "Shows a short explanation above the lost password form telling people what will happen next and where to get help if the email does not arrive.",
            "category": "forms",
            "tags": [
                "login",
                "password",
                "messages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_lostpass_text = 'Enter your username or email address and we will email you a link to choose a new password. The email can take a few minutes; check your spam folder too. Still stuck? Email support@example.com.';\n\nadd_filter( 'login_message', function ( $message ) use ( $wpide_lostpass_text ) {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : '';\n\n    if ( in_array( $action, array( 'lostpassword', 'retrievepassword' ), true ) ) {\n        return '<p class=\"message\">' . esc_html( $wpide_lostpass_text ) . '</p>';\n    }\n\n    return $message;\n} );",
            "requires": [],
            "notes": "Edit the text and support address at the top."
        },
        {
            "id": "form-save-progress",
            "version": 1,
            "title": "Save Long Form Answers in the Browser",
            "description": "Keeps what visitors type into a long form saved in their browser, so a reload, a crash or an accidental back click does not wipe out their answers. Cleared after submitting.",
            "category": "forms",
            "tags": [
                "forms",
                "autosave",
                "ux"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    var forms = document.querySelectorAll('form.wpide-save-progress, .wpide-save-progress form');\n    if (!forms.length || !window.localStorage) {\n        return;\n    }\n\n    forms.forEach(function (form, index) {\n        var key = 'wpideForm:' + location.pathname + ':' + (form.id || index);\n        var stored = {};\n        try { stored = JSON.parse(localStorage.getItem(key) || '{}'); } catch (e) { stored = {}; }\n\n        var fields = form.querySelectorAll('input:not([type=\"password\"]):not([type=\"file\"]):not([type=\"hidden\"]):not([type=\"submit\"]), textarea, select');\n\n        fields.forEach(function (field) {\n            if (!field.name || !(field.name in stored)) { return; }\n            if (field.type === 'checkbox' || field.type === 'radio') {\n                field.checked = stored[field.name].indexOf(field.value) !== -1;\n            } else if (!field.value) {\n                field.value = stored[field.name];\n            }\n        });\n\n        var save = function () {\n            var data = {};\n            fields.forEach(function (field) {\n                if (!field.name) { return; }\n                if (field.type === 'checkbox' || field.type === 'radio') {\n                    data[field.name] = data[field.name] || [];\n                    if (field.checked) { data[field.name].push(field.value); }\n                } else {\n                    data[field.name] = field.value;\n                }\n            });\n            try { localStorage.setItem(key, JSON.stringify(data)); } catch (e) {}\n        };\n\n        var timer;\n        form.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(save, 400); });\n        form.addEventListener('change', save);\n        form.addEventListener('submit', function () { try { localStorage.removeItem(key); } catch (e) {} });\n    });\n})();",
            "requires": [],
            "notes": "Add the class wpide-save-progress to a form (most form builders have a CSS class setting); password, file and hidden fields are never stored."
        },
        {
            "id": "form-textarea-character-counter",
            "version": 1,
            "title": "Character Counter for Any Text Box With a Limit",
            "description": "Shows a live \"120 / 500 characters\" counter under every text box that has a maximum length, announced politely to screen readers as the limit gets close.",
            "category": "forms",
            "tags": [
                "forms",
                "accessibility",
                "textarea"
            ],
            "code_type": "js",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "(function () {\n    document.querySelectorAll('textarea[maxlength]').forEach(function (area) {\n        var max = parseInt(area.getAttribute('maxlength'), 10);\n        if (!max || max > 10000 || area.dataset.wpideCounter) {\n            return;\n        }\n        area.dataset.wpideCounter = '1';\n\n        var counter = document.createElement('p');\n        counter.className = 'wpide-char-counter';\n        counter.style.cssText = 'margin:.25em 0 0;font-size:.85em;opacity:.75;';\n        counter.setAttribute('aria-live', 'polite');\n        area.insertAdjacentElement('afterend', counter);\n\n        var update = function () {\n            var used = area.value.length;\n            counter.textContent = used + ' / ' + max + ' characters';\n            counter.style.opacity = used > max * 0.9 ? '1' : '.75';\n            counter.setAttribute('aria-live', used > max * 0.9 ? 'polite' : 'off');\n        };\n\n        area.addEventListener('input', update);\n        update();\n    });\n})();",
            "requires": [],
            "notes": "Works with any form that sets a maxlength of up to 10,000 characters on its textareas, including form plugins; very high technical limits (like the comment box's default) are ignored."
        },
        {
            "id": "form-mobile-friendly-inputs",
            "version": 1,
            "title": "Stop Phones Zooming Into Form Fields",
            "description": "Sets form fields to at least 16px text so iPhones stop zooming in when a field is tapped, and makes inputs full width on small screens.",
            "category": "forms",
            "tags": [
                "forms",
                "mobile",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "input[type=\"text\"],\ninput[type=\"email\"],\ninput[type=\"tel\"],\ninput[type=\"url\"],\ninput[type=\"number\"],\ninput[type=\"password\"],\ninput[type=\"search\"],\ninput[type=\"date\"],\nselect,\ntextarea {\n    font-size: max(16px, 1em);\n}\n\n@media (max-width: 600px) {\n    form input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"submit\"]):not([type=\"button\"]):not([type=\"hidden\"]),\n    form select,\n    form textarea {\n        width: 100%;\n        max-width: 100%;\n        box-sizing: border-box;\n    }\n}",
            "requires": [],
            "notes": "Applies to all forms on the front of the site; the full-width rule only kicks in below 600px."
        },
        {
            "id": "form-validation-states-css",
            "version": 1,
            "title": "Clear Valid and Invalid States for Form Fields",
            "description": "Colors form fields red with a short hint once a visitor has interacted with them and entered something invalid, without flagging untouched empty fields.",
            "category": "forms",
            "tags": [
                "forms",
                "validation",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ":root {\n    --wpide-invalid: #c62828;\n    --wpide-valid: #2e7d32;\n}\n\nform input:user-invalid,\nform select:user-invalid,\nform textarea:user-invalid {\n    border-color: var(--wpide-invalid);\n    outline-color: var(--wpide-invalid);\n    background-image: linear-gradient(to right, var(--wpide-invalid), var(--wpide-invalid));\n    background-size: 3px 100%;\n    background-repeat: no-repeat;\n}\n\nform input:user-valid:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"submit\"]),\nform textarea:user-valid {\n    border-color: var(--wpide-valid);\n}\n\nform input[type=\"checkbox\"]:user-invalid,\nform input[type=\"radio\"]:user-invalid {\n    outline: 2px solid var(--wpide-invalid);\n    outline-offset: 2px;\n}",
            "requires": [],
            "notes": "Uses the :user-invalid selector supported by current browsers; older browsers simply show no color change."
        },
        {
            "id": "form-required-asterisk",
            "version": 1,
            "title": "Mark Required Form Fields With an Asterisk",
            "description": "Adds a red asterisk after the label of every required field in standard HTML forms, so visitors can see at a glance what they must fill in.",
            "category": "forms",
            "tags": [
                "forms",
                "accessibility",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "form label:not(:has(.required)):has(+ input[required])::after,\nform label:not(:has(.required)):has(+ select[required])::after,\nform label:not(:has(.required)):has(+ textarea[required])::after {\n    content: \" *\";\n    color: #c62828;\n    font-weight: 700;\n}",
            "requires": [],
            "notes": "Applies when the label sits directly before a field with the required attribute; labels that already contain a .required marker (like the comment form) are skipped."
        },
        {
            "id": "cf7-sequential-reference",
            "version": 1,
            "title": "Sequential Reference Number Mail Tag for Contact Form 7",
            "description": "Adds a [_wpide_ref] mail tag that gives every Contact Form 7 submission a running reference number like WEB-1042, which you can quote in the subject or autoresponder.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "mail-tags",
                "reference"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_ref_prefix = 'WEB-';\n$wpide_cf7_ref_start  = 1000;\n\nadd_filter( 'wpcf7_special_mail_tags', function ( $output, $name, $html = false ) use ( $wpide_cf7_ref_prefix, $wpide_cf7_ref_start ) {\n    if ( '_wpide_ref' !== $name ) {\n        return $output;\n    }\n\n    static $ref = null;\n\n    if ( null === $ref ) {\n        $next = max( (int) get_option( 'wpide_cf7_ref', $wpide_cf7_ref_start ), $wpide_cf7_ref_start ) + 1;\n        update_option( 'wpide_cf7_ref', $next, false );\n        $ref = $wpide_cf7_ref_prefix . $next;\n    }\n\n    return $ref;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "Change the prefix and starting number at the top; the counter is shared by all forms and stored in an option."
        },
        {
            "id": "cf7-disallowed-keys-spam",
            "version": 1,
            "title": "Use the Comment Blocklist for Contact Form 7",
            "description": "Marks Contact Form 7 submissions as spam when they contain any word, IP address or email from WordPress's own Disallowed Comment Keys list, so one blocklist protects comments and forms.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpcf7_spam', function ( $spam, $submission = null ) {\n    if ( $spam || ! $submission ) {\n        return $spam;\n    }\n\n    $keys = trim( (string) get_option( 'disallowed_keys' ) );\n    if ( '' === $keys ) {\n        return false;\n    }\n\n    $text = (string) $submission->get_meta( 'remote_ip' ) . ' ' . (string) $submission->get_meta( 'user_agent' );\n    foreach ( (array) $submission->get_posted_data() as $value ) {\n        $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n    }\n\n    foreach ( preg_split( '/\\R/', $keys ) as $word ) {\n        $word = trim( $word );\n        if ( '' !== $word && false !== stripos( $text, $word ) ) {\n            $submission->add_spam_log( array( 'agent' => 'wpide-disallowed-keys', 'reason' => 'Matches the Disallowed Comment Keys list.' ) );\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 2 );",
            "requires": [
                "cf7"
            ],
            "notes": "Manage the list under Settings > Discussion > Disallowed Comment Keys, one entry per line."
        },
        {
            "id": "gf-disallowed-keys-spam",
            "version": 1,
            "title": "Use the Comment Blocklist for Gravity Forms",
            "description": "Sends Gravity Forms entries to spam when they contain any word, IP address or email from WordPress's Disallowed Comment Keys list, so one blocklist covers comments and forms.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_entry_is_spam', function ( $is_spam, $form, $entry ) {\n    if ( $is_spam ) {\n        return $is_spam;\n    }\n\n    $keys = trim( (string) get_option( 'disallowed_keys' ) );\n    if ( '' === $keys ) {\n        return false;\n    }\n\n    $text = (string) rgar( $entry, 'ip' ) . ' ' . (string) rgar( $entry, 'user_agent' );\n    foreach ( $form['fields'] as $field ) {\n        $text .= ' ' . (string) $field->get_value_export( $entry );\n    }\n\n    foreach ( preg_split( '/\\R/', $keys ) as $word ) {\n        $word = trim( $word );\n        if ( '' !== $word && false !== stripos( $text, $word ) ) {\n            return true;\n        }\n    }\n\n    return false;\n}, 10, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Manage the list under Settings > Discussion > Disallowed Comment Keys, one entry per line."
        },
        {
            "id": "gf-entry-count-shortcode",
            "version": 1,
            "title": "Show How Many People Filled In a Gravity Form",
            "description": "Adds a [wpide_gf_count form=\"1\"] shortcode that prints the number of entries a Gravity Forms form has received, for petitions, sign-ups and social proof.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "shortcode",
                "social-proof"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'wpide_gf_count', function ( $atts ) {\n    $atts = shortcode_atts( array( 'form' => 0, 'offset' => 0 ), $atts, 'wpide_gf_count' );\n    $form = absint( $atts['form'] );\n\n    if ( ! $form || ! class_exists( 'GFAPI' ) ) {\n        return '';\n    }\n\n    $count = get_transient( 'wpide_gf_count_' . $form );\n    if ( false === $count ) {\n        $count = (int) GFAPI::count_entries( $form, array( 'status' => 'active' ) );\n        set_transient( 'wpide_gf_count_' . $form, $count, 10 * MINUTE_IN_SECONDS );\n    }\n\n    return '<span class=\"wpide-gf-count\">' . esc_html( number_format_i18n( $count + absint( $atts['offset'] ) ) ) . '</span>';\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Counts active entries only, cached for ten minutes; add offset=\"100\" to start from a higher number."
        },
        {
            "id": "gf-progress-bar-from-zero",
            "version": 1,
            "title": "Start the Gravity Forms Progress Bar at 0%",
            "description": "Makes multi-page Gravity Forms progress bars start at 0% on the first page and reach 100% only on submit, which better matches how far people really are.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "multi-page",
                "ux"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'gform_progressbar_start_at_zero', '__return_true' );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Affects every multi-page form that uses the progress bar indicator."
        },
        {
            "id": "gf-custom-spinner",
            "version": 1,
            "title": "Lighter Loading Spinner for Gravity Forms",
            "description": "Replaces the Gravity Forms AJAX loading spinner with a small inline SVG spinner in your brand color, with no extra file to download.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "ajax",
                "styling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_spinner_color = '#2563eb';\n\nadd_filter( 'gform_ajax_spinner_url', function () use ( $wpide_gf_spinner_color ) {\n    $svg = '<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"9\" fill=\"none\" stroke=\"' . esc_attr( $wpide_gf_spinner_color ) . '\" stroke-width=\"3\" stroke-dasharray=\"42 15\" stroke-linecap=\"round\"><animateTransform attributeName=\"transform\" type=\"rotate\" from=\"0 12 12\" to=\"360 12 12\" dur=\"0.8s\" repeatCount=\"indefinite\"/></circle></svg>';\n\n    return 'data:image/svg+xml;base64,' . base64_encode( $svg );\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Change the color at the top; the spinner is embedded as a data URI."
        },
        {
            "id": "gf-one-entry-per-user",
            "version": 1,
            "title": "One Gravity Forms Entry per Logged-In User",
            "description": "Lets each logged-in member submit a Gravity Forms form only once, for surveys, applications or votes, and shows a friendly message instead of the form afterwards.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "members",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_once_forms   = array(); // Form IDs, e.g. array( 1 ).\n$wpide_gf_once_message = 'You have already submitted this form. Thank you!';\n\n$wpide_gf_user_has_entry = function ( $form_id ) {\n    $user = get_current_user_id();\n\n    return $user && GFAPI::count_entries( (int) $form_id, array( 'status' => 'active', 'field_filters' => array( array( 'key' => 'created_by', 'value' => $user ) ) ) ) > 0;\n};\n\nadd_filter( 'gform_get_form_filter', function ( $html, $form ) use ( $wpide_gf_once_forms, $wpide_gf_once_message, $wpide_gf_user_has_entry ) {\n    if ( in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_once_forms ), true ) && $wpide_gf_user_has_entry( $form['id'] ) ) {\n        return '<p class=\"wpide-gf-once\" role=\"status\">' . esc_html( $wpide_gf_once_message ) . '</p>';\n    }\n\n    return $html;\n}, 10, 2 );\n\nadd_filter( 'gform_validation', function ( $validation ) use ( $wpide_gf_once_forms, $wpide_gf_once_message, $wpide_gf_user_has_entry ) {\n    $form = $validation['form'];\n\n    if ( in_array( (int) $form['id'], array_map( 'intval', $wpide_gf_once_forms ), true ) && $wpide_gf_user_has_entry( $form['id'] ) ) {\n        $validation['is_valid'] = false;\n        foreach ( $form['fields'] as $field ) {\n            if ( ! in_array( $field->type, array( 'hidden', 'html', 'section', 'page' ), true ) ) {\n                $field->failed_validation  = true;\n                $field->validation_message = $wpide_gf_once_message;\n                break;\n            }\n        }\n        $validation['form'] = $form;\n    }\n\n    return $validation;\n} );",
            "requires": [
                "gravityforms"
            ],
            "notes": "List the form IDs at the top and turn on \"Require user to be logged in\" in those forms' settings."
        },
        {
            "id": "wpforms-disallowed-keys-spam",
            "version": 1,
            "title": "Use the Comment Blocklist for WPForms",
            "description": "Blocks WPForms submissions that contain any word, IP address or email from WordPress's Disallowed Comment Keys list, so one blocklist protects comments and forms.",
            "category": "forms",
            "tags": [
                "wpforms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpforms_process_honeypot', function ( $honeypot, $fields, $entry ) {\n    $keys = trim( (string) get_option( 'disallowed_keys' ) );\n\n    if ( $honeypot || '' === $keys ) {\n        return $honeypot;\n    }\n\n    $text = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    foreach ( (array) ( isset( $entry['fields'] ) ? $entry['fields'] : array() ) as $value ) {\n        $text .= ' ' . ( is_array( $value ) ? implode( ' ', $value ) : (string) $value );\n    }\n\n    foreach ( preg_split( '/\\R/', $keys ) as $word ) {\n        $word = trim( $word );\n        if ( '' !== $word && false !== stripos( $text, $word ) ) {\n            return 'Your submission could not be accepted.';\n        }\n    }\n\n    return $honeypot;\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Manage the list under Settings > Discussion > Disallowed Comment Keys, one entry per line."
        },
        {
            "id": "wpforms-pattern-validation",
            "version": 1,
            "title": "Custom Pattern Rules for WPForms Text Fields",
            "description": "Validates WPForms single-line text fields against your own patterns, such as postcodes, order numbers or VAT IDs, chosen per field with a CSS class.",
            "category": "forms",
            "tags": [
                "wpforms",
                "validation",
                "regex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// CSS class => array( pattern, error message ).\n$wpide_wpf_patterns = array(\n    'wpide-postcode' => array( '/^[A-Za-z0-9 \\-]{3,10}$/', 'Please enter a valid postcode.' ),\n    'wpide-order'    => array( '/^#?\\d{4,10}$/', 'Order numbers contain 4 to 10 digits.' ),\n);\n\nadd_action( 'wpforms_process_validate_text', function ( $field_id, $field_submit, $form_data ) use ( $wpide_wpf_patterns ) {\n    $css   = isset( $form_data['fields'][ $field_id ]['css'] ) ? preg_split( '/\\s+/', (string) $form_data['fields'][ $field_id ]['css'] ) : array();\n    $value = trim( (string) $field_submit );\n\n    if ( '' === $value ) {\n        return;\n    }\n\n    foreach ( $css as $class ) {\n        if ( isset( $wpide_wpf_patterns[ $class ] ) && ! preg_match( $wpide_wpf_patterns[ $class ][0], $value ) ) {\n            $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n            $process->errors[ $form_data['id'] ][ $field_id ] = $wpide_wpf_patterns[ $class ][1];\n            return;\n        }\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "Give a text field one of the CSS classes from the list at the top (Advanced tab); patterns are PHP regular expressions matched against the whole value."
        },
        {
            "id": "wpforms-one-entry-per-user",
            "version": 1,
            "title": "One WPForms Submission per Logged-In User",
            "description": "Lets each logged-in member submit the WPForms forms you choose only once, for surveys, votes or applications, and replaces the form with a message afterwards.",
            "category": "forms",
            "tags": [
                "wpforms",
                "members",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_wpf_once_forms   = array(); // Form IDs, e.g. array( 1 ).\n$wpide_wpf_once_message = 'You have already submitted this form. Thank you!';\n\nadd_filter( 'wpforms_frontend_load', function ( $load, $form_data ) use ( $wpide_wpf_once_forms, $wpide_wpf_once_message ) {\n    $id = isset( $form_data['id'] ) ? (int) $form_data['id'] : 0;\n\n    if ( $load && is_user_logged_in() && in_array( $id, array_map( 'intval', $wpide_wpf_once_forms ), true ) && get_user_meta( get_current_user_id(), '_wpide_wpf_done_' . $id, true ) ) {\n        echo '<p class=\"wpide-wpf-once\" role=\"status\">' . esc_html( $wpide_wpf_once_message ) . '</p>';\n        return false;\n    }\n\n    return $load;\n}, 10, 2 );\n\nadd_action( 'wpforms_process_before', function ( $entry, $form_data ) use ( $wpide_wpf_once_forms, $wpide_wpf_once_message ) {\n    $id = (int) $form_data['id'];\n\n    if ( is_user_logged_in() && in_array( $id, array_map( 'intval', $wpide_wpf_once_forms ), true ) && get_user_meta( get_current_user_id(), '_wpide_wpf_done_' . $id, true ) ) {\n        $process = method_exists( wpforms(), 'obj' ) ? wpforms()->obj( 'process' ) : wpforms()->process;\n        $process->errors[ $id ]['header'] = $wpide_wpf_once_message;\n    }\n}, 10, 2 );\n\nadd_action( 'wpforms_process_complete', function ( $fields, $entry, $form_data ) use ( $wpide_wpf_once_forms ) {\n    $id = (int) $form_data['id'];\n\n    if ( is_user_logged_in() && in_array( $id, array_map( 'intval', $wpide_wpf_once_forms ), true ) ) {\n        update_user_meta( get_current_user_id(), '_wpide_wpf_done_' . $id, time() );\n    }\n}, 10, 3 );",
            "requires": [
                "wpforms"
            ],
            "notes": "List the form IDs at the top; submissions are remembered in user meta, so it works in WPForms Lite too, and logged-out visitors are not limited."
        },
        {
            "id": "fluentforms-disallowed-keys-spam",
            "version": 1,
            "title": "Use the Comment Blocklist for Fluent Forms",
            "description": "Blocks Fluent Forms submissions that contain any word, IP address or email from WordPress's Disallowed Comment Keys list, so one blocklist protects comments and forms.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "spam",
                "blocklist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'fluentform/validation_errors', function ( $errors, $data ) {\n    $keys = trim( (string) get_option( 'disallowed_keys' ) );\n\n    if ( '' === $keys ) {\n        return $errors;\n    }\n\n    $text = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    foreach ( (array) $data as $name => $value ) {\n        if ( 0 !== strpos( (string) $name, '_' ) ) {\n            $text .= ' ' . ( is_array( $value ) ? implode( ' ', array_filter( $value, 'is_scalar' ) ) : (string) $value );\n        }\n    }\n\n    foreach ( preg_split( '/\\R/', $keys ) as $word ) {\n        $word = trim( $word );\n        if ( '' !== $word && false !== stripos( $text, $word ) ) {\n            $errors['_wpide_blocklist'] = array( 'Your submission could not be accepted.' );\n            break;\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Manage the list under Settings > Discussion > Disallowed Comment Keys, one entry per line."
        },
        {
            "id": "fluentforms-pattern-validation",
            "version": 1,
            "title": "Custom Pattern Rules for Fluent Forms Fields",
            "description": "Validates chosen Fluent Forms fields against your own patterns, such as postcodes, order numbers or membership IDs, each with its own error message.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "validation",
                "regex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Field name => array( pattern, error message ).\n$wpide_ff_patterns = array(\n    'postcode'     => array( '/^[A-Za-z0-9 \\-]{3,10}$/', 'Please enter a valid postcode.' ),\n    'order_number' => array( '/^#?\\d{4,10}$/', 'Order numbers contain 4 to 10 digits.' ),\n);\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data ) use ( $wpide_ff_patterns ) {\n    foreach ( $wpide_ff_patterns as $name => $rule ) {\n        $value = isset( $data[ $name ] ) && is_string( $data[ $name ] ) ? trim( $data[ $name ] ) : '';\n\n        if ( '' !== $value && ! preg_match( $rule[0], $value ) ) {\n            $errors[ $name ] = array( $rule[1] );\n        }\n    }\n\n    return $errors;\n}, 10, 2 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "Keys are field names (the Name Attribute in the field's Advanced Options); patterns are PHP regular expressions matched against the whole value."
        },
        {
            "id": "comment-javascript-token",
            "version": 1,
            "title": "JavaScript Check Against Comment Spam Bots",
            "description": "Adds a hidden field to the comment form that only a real browser running JavaScript fills in; comments without it go to the spam queue instead of moderation.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "javascript"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cjs_token = function ( $offset = 0 ) {\n    return substr( wp_hash( 'comment-js-' . gmdate( 'Ymd', time() - $offset * DAY_IN_SECONDS ) ), 0, 16 );\n};\n\nadd_action( 'comment_form', function () use ( $wpide_cjs_token ) {\n    printf(\n        '<input type=\"hidden\" name=\"wpide_cjs\" id=\"wpide-cjs\" value=\"\"><script>document.getElementById(\"wpide-cjs\").value=%s;</script>',\n        wp_json_encode( $wpide_cjs_token() )\n    );\n} );\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_cjs_token ) {\n    if ( 'spam' === $approved || is_user_logged_in() || ( isset( $commentdata['comment_type'] ) && in_array( $commentdata['comment_type'], array( 'pingback', 'trackback' ), true ) ) ) {\n        return $approved;\n    }\n    if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {\n        return $approved;\n    }\n\n    $sent = isset( $_POST['wpide_cjs'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_cjs'] ) ) : '';\n\n    if ( '' !== $sent && ( hash_equals( $wpide_cjs_token(), $sent ) || hash_equals( $wpide_cjs_token( 1 ), $sent ) ) ) {\n        return $approved;\n    }\n\n    return 'spam';\n}, 20, 2 );",
            "requires": [],
            "notes": "Comments are sent to spam, never deleted, so you can rescue a real one; works with page caching because the token changes only once a day."
        },
        {
            "id": "comment-blocked-email-domains",
            "version": 1,
            "title": "Send Comments From Blocked Email Domains to Spam",
            "description": "Sends comments to the spam queue when the commenter's email address belongs to a throwaway inbox service or any other domain on your list.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_blocked_domains = array( 'mailinator.com', 'yopmail.com', 'tempmail.com', 'guerrillamail.com', 'sharklasers.com', '10minutemail.com', 'trashmail.com', 'getnada.com' );\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_comment_blocked_domains ) {\n    if ( 'spam' === $approved || empty( $commentdata['comment_author_email'] ) ) {\n        return $approved;\n    }\n\n    $domain = strtolower( (string) substr( strrchr( $commentdata['comment_author_email'], '@' ), 1 ) );\n\n    foreach ( $wpide_comment_blocked_domains as $blocked ) {\n        if ( $domain === $blocked || substr( $domain, -strlen( '.' . $blocked ) ) === '.' . $blocked ) {\n            return 'spam';\n        }\n    }\n\n    return $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Subdomains of listed domains match too; extend the list as spam arrives."
        },
        {
            "id": "comment-foreign-script-spam",
            "version": 1,
            "title": "Send Comments in Foreign Scripts to Spam",
            "description": "Sends comments written mostly in Cyrillic, Chinese, Arabic or another script your readers do not use to the spam queue, where most of them belong.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "language"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_scripts = array( 'Cyrillic', 'Han', 'Arabic', 'Hangul', 'Thai' );\n$wpide_comment_share   = 0.3; // Share of letters that marks a comment as spam.\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_comment_scripts, $wpide_comment_share ) {\n    if ( 'spam' === $approved || empty( $commentdata['comment_content'] ) ) {\n        return $approved;\n    }\n\n    $text    = $commentdata['comment_content'] . ' ' . ( isset( $commentdata['comment_author'] ) ? $commentdata['comment_author'] : '' );\n    $letters = preg_match_all( '/\\p{L}/u', $text );\n\n    if ( $letters < 10 ) {\n        return $approved;\n    }\n\n    $foreign = 0;\n    foreach ( $wpide_comment_scripts as $script ) {\n        $foreign += (int) preg_match_all( '/\\p{' . $script . '}/u', $text );\n    }\n\n    return $foreign / $letters >= $wpide_comment_share ? 'spam' : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Remove scripts from the list if your community writes in them."
        },
        {
            "id": "comment-daily-limit-per-ip",
            "version": 1,
            "title": "Daily Comment Limit per Visitor",
            "description": "Holds comments for moderation once a single IP address has posted more than a set number of comments in 24 hours, which stops comment floods and spam runs.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_comment_daily_max = 10;\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_comment_daily_max ) {\n    global $wpdb;\n\n    if ( 'spam' === $approved || is_user_logged_in() || empty( $commentdata['comment_author_IP'] ) ) {\n        return $approved;\n    }\n\n    $count = (int) $wpdb->get_var( $wpdb->prepare(\n        \"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_author_IP = %s AND comment_date_gmt > %s\",\n        $commentdata['comment_author_IP'],\n        gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS )\n    ) );\n\n    return $count >= $wpide_comment_daily_max ? 0 : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Logged-in users are not limited; extra comments are held, not rejected, so you can still approve genuine ones."
        },
        {
            "id": "registration-time-trap",
            "version": 1,
            "title": "Reject Registrations Submitted Too Fast",
            "description": "Stamps the WordPress registration form with a signed time and refuses sign-ups sent within a few seconds of loading it, which stops most registration bots.",
            "category": "security",
            "tags": [
                "registration",
                "spam",
                "bots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_reg_min_seconds = 5;\n\nadd_action( 'register_form', function () {\n    $time = (string) time();\n    printf( '<input type=\"hidden\" name=\"wpide_reg_ts\" value=\"%s\">', esc_attr( $time . '.' . wp_hash( 'reg-ts-' . $time ) ) );\n} );\n\nadd_filter( 'registration_errors', function ( $errors ) use ( $wpide_reg_min_seconds ) {\n    $stamp = isset( $_POST['wpide_reg_ts'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_reg_ts'] ) ) : '';\n    $parts = explode( '.', $stamp, 2 );\n    $valid = 2 === count( $parts ) && ctype_digit( $parts[0] ) && hash_equals( wp_hash( 'reg-ts-' . $parts[0] ), $parts[1] );\n\n    if ( ! $valid || time() - (int) $parts[0] < $wpide_reg_min_seconds ) {\n        $errors->add( 'wpide_too_fast', '<strong>Error:</strong> Please take a moment and try again.' );\n    }\n\n    return $errors;\n} );",
            "requires": [],
            "notes": "Real people need a few seconds to type a username and email; raise the minimum if bots adapt."
        },
        {
            "id": "comment-require-referer",
            "version": 1,
            "title": "Block Comments Posted Without Visiting Your Site",
            "description": "Rejects comment submissions that do not come from a page on your own site, which stops bots that post directly to wp-comments-post.php.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "bots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'preprocess_comment', function ( $commentdata ) {\n    if ( is_user_logged_in() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || wp_doing_ajax() || ( isset( $commentdata['comment_type'] ) && in_array( $commentdata['comment_type'], array( 'pingback', 'trackback' ), true ) ) ) {\n        return $commentdata;\n    }\n\n    $referer = isset( $_SERVER['HTTP_REFERER'] ) ? (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';\n    $origin  = isset( $_SERVER['HTTP_ORIGIN'] ) ? (string) wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ), PHP_URL_HOST ) : '';\n    $host    = (string) wp_parse_url( home_url(), PHP_URL_HOST );\n\n    if ( $host !== $referer && $host !== $origin ) {\n        wp_die( 'Please post your comment from the article page.', 'Comment rejected', array( 'response' => 403, 'back_link' => true ) );\n    }\n\n    return $commentdata;\n} );",
            "requires": [],
            "notes": "Browsers send the page address when a form is submitted; very strict privacy setups that strip it entirely would be blocked, so watch for complaints."
        },
        {
            "id": "comment-suspicious-website-tlds",
            "version": 1,
            "title": "Spam Comments Linking to Suspicious Domains",
            "description": "Sends comments to spam when the commenter's website field points to a domain ending you have chosen to distrust, such as .xyz, .top or .click.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_spam_tlds = array( 'xyz', 'top', 'click', 'loan', 'work', 'icu', 'buzz', 'cam', 'rest' );\n\nadd_filter( 'pre_comment_approved', function ( $approved, $commentdata ) use ( $wpide_spam_tlds ) {\n    if ( 'spam' === $approved || empty( $commentdata['comment_author_url'] ) ) {\n        return $approved;\n    }\n\n    $host = strtolower( (string) wp_parse_url( $commentdata['comment_author_url'], PHP_URL_HOST ) );\n    $tld  = substr( strrchr( '.' . $host, '.' ), 1 );\n\n    return in_array( $tld, $wpide_spam_tlds, true ) ? 'spam' : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Edit the endings at the top to match the spam you actually receive."
        },
        {
            "id": "comment-duplicate-across-posts",
            "version": 1,
            "title": "Spam Comments Copied Across Several Posts",
            "description": "Sends a comment to spam when exactly the same text was already posted on another post in the last day, a typical sign of a spam bot working through your site.",
            "category": "security",
            "tags": [
                "comments",
                "spam",
                "duplicates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_comment_approved', function ( $approved, $commentdata ) {\n    global $wpdb;\n\n    if ( 'spam' === $approved || is_user_logged_in() || empty( $commentdata['comment_content'] ) || strlen( trim( $commentdata['comment_content'] ) ) < 30 ) {\n        return $approved;\n    }\n\n    $found = $wpdb->get_var( $wpdb->prepare(\n        \"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_date_gmt > %s AND comment_post_ID <> %d AND comment_content = %s LIMIT 1\",\n        gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ),\n        (int) $commentdata['comment_post_ID'],\n        $commentdata['comment_content']\n    ) );\n\n    return $found ? 'spam' : $approved;\n}, 20, 2 );",
            "requires": [],
            "notes": "Short comments under 30 characters (like \"Thanks!\") are ignored so genuine readers are not caught."
        },
        {
            "id": "email-log-to-file-rotation",
            "version": 1,
            "title": "Log Every Email to Daily Files With Rotation",
            "description": "Writes one line per outgoing email (time, recipient, subject, result) to a daily log file in a protected uploads folder, and deletes files older than the number of days you choose.",
            "category": "email",
            "tags": [
                "email",
                "logging",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mail_log_days = 14; // Days of log files to keep.\n\n$wpide_mail_log_write = function ( $line ) {\n    $uploads = wp_upload_dir( null, false );\n    $dir     = trailingslashit( $uploads['basedir'] ) . 'wpide-mail-logs';\n\n    if ( ! is_dir( $dir ) ) {\n        wp_mkdir_p( $dir );\n        file_put_contents( $dir . '/index.php', \"<?php\\n// Silence is golden.\\n\" );\n        file_put_contents( $dir . '/.htaccess', \"Require all denied\\nDeny from all\\n\" );\n    }\n\n    $file = sprintf( '%s/mail-%s-%s.log', $dir, gmdate( 'Y-m-d' ), substr( wp_hash( 'mail-log' ), 0, 12 ) );\n    file_put_contents( $file, '[' . gmdate( 'c' ) . '] ' . str_replace( array( \"\\r\", \"\\n\" ), ' ', $line ) . \"\\n\", FILE_APPEND | LOCK_EX );\n};\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_mail_log_write ) {\n    $wpide_mail_log_write( sprintf( 'QUEUED to=%s subject=\"%s\"', implode( ',', (array) $atts['to'] ), wp_strip_all_tags( (string) $atts['subject'] ) ) );\n\n    return $atts;\n}, PHP_INT_MAX );\n\nadd_action( 'wp_mail_succeeded', function ( $data ) use ( $wpide_mail_log_write ) {\n    $wpide_mail_log_write( sprintf( 'SENT to=%s', implode( ',', (array) $data['to'] ) ) );\n} );\n\nadd_action( 'wp_mail_failed', function ( $error ) use ( $wpide_mail_log_write ) {\n    $data = (array) $error->get_error_data();\n    $wpide_mail_log_write( sprintf( 'FAILED to=%s error=\"%s\"', isset( $data['to'] ) ? implode( ',', (array) $data['to'] ) : '?', $error->get_error_message() ) );\n} );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_mail_log_rotate' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_mail_log_rotate' );\n    }\n} );\n\nadd_action( 'wpide_mail_log_rotate', function () use ( $wpide_mail_log_days ) {\n    $uploads = wp_upload_dir( null, false );\n    $files   = glob( trailingslashit( $uploads['basedir'] ) . 'wpide-mail-logs/mail-*.log' );\n\n    foreach ( (array) $files as $file ) {\n        if ( filemtime( $file ) < time() - $wpide_mail_log_days * DAY_IN_SECONDS ) {\n            wp_delete_file( $file );\n        }\n    }\n} );",
            "requires": [],
            "notes": "Files live in uploads/wpide-mail-logs with an unguessable name, an index.php and an Apache deny rule (on Nginx keep that folder private), and message bodies are not logged."
        },
        {
            "id": "email-log-database-table",
            "version": 1,
            "title": "Full Email Log With Search, Viewer and Resend",
            "description": "Records every outgoing email in its own database table and adds Tools > Mail Log, where you can search, read the full message, resend it, and clear old entries automatically.",
            "category": "email",
            "tags": [
                "email",
                "logging",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mlog_days  = 30; // Days to keep entries.\n$wpide_mlog_state = new stdClass();\n$wpide_mlog_state->id = 0;\n\nadd_action( 'admin_init', function () {\n    global $wpdb;\n\n    if ( '1' === get_option( 'wpide_mail_log_db' ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n    dbDelta( \"CREATE TABLE {$wpdb->prefix}wpide_mail_log (\n        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n        sent_at datetime NOT NULL,\n        to_addr text NOT NULL,\n        subject text NOT NULL,\n        headers text NOT NULL,\n        message longtext NOT NULL,\n        status varchar(20) NOT NULL DEFAULT 'queued',\n        error text NOT NULL,\n        PRIMARY KEY  (id),\n        KEY sent_at (sent_at)\n    ) {$wpdb->get_charset_collate()};\" );\n\n    update_option( 'wpide_mail_log_db', '1', true );\n} );\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_mlog_state ) {\n    global $wpdb;\n\n    if ( '1' !== get_option( 'wpide_mail_log_db' ) ) {\n        return $atts;\n    }\n\n    $wpdb->insert( $wpdb->prefix . 'wpide_mail_log', array(\n        'sent_at' => current_time( 'mysql', true ),\n        'to_addr' => implode( ', ', (array) $atts['to'] ),\n        'subject' => (string) $atts['subject'],\n        'headers' => is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'],\n        'message' => (string) $atts['message'],\n        'status'  => 'queued',\n        'error'   => '',\n    ) );\n    $wpide_mlog_state->id = (int) $wpdb->insert_id;\n\n    return $atts;\n}, PHP_INT_MAX );\n\nadd_action( 'wp_mail_succeeded', function () use ( $wpide_mlog_state ) {\n    global $wpdb;\n    if ( $wpide_mlog_state->id ) {\n        $wpdb->update( $wpdb->prefix . 'wpide_mail_log', array( 'status' => 'sent' ), array( 'id' => $wpide_mlog_state->id ) );\n        $wpide_mlog_state->id = 0;\n    }\n} );\n\nadd_action( 'wp_mail_failed', function ( $error ) use ( $wpide_mlog_state ) {\n    global $wpdb;\n    if ( $wpide_mlog_state->id ) {\n        $wpdb->update( $wpdb->prefix . 'wpide_mail_log', array( 'status' => 'failed', 'error' => $error->get_error_message() ), array( 'id' => $wpide_mlog_state->id ) );\n        $wpide_mlog_state->id = 0;\n    }\n} );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_mail_log_purge' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_mail_log_purge' );\n    }\n} );\n\nadd_action( 'wpide_mail_log_purge', function () use ( $wpide_mlog_days ) {\n    global $wpdb;\n    if ( '1' === get_option( 'wpide_mail_log_db' ) ) {\n        $wpdb->query( $wpdb->prepare( \"DELETE FROM {$wpdb->prefix}wpide_mail_log WHERE sent_at < %s\", gmdate( 'Y-m-d H:i:s', time() - $wpide_mlog_days * DAY_IN_SECONDS ) ) );\n    }\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Mail Log', 'Mail Log', 'manage_options', 'wpide-mail-log', function () {\n        global $wpdb;\n        $table = $wpdb->prefix . 'wpide_mail_log';\n        $base  = admin_url( 'tools.php?page=wpide-mail-log' );\n\n        echo '<div class=\"wrap\"><h1>Mail Log</h1>';\n\n        if ( '1' !== get_option( 'wpide_mail_log_db' ) ) {\n            echo '<p>The log table is being created. Reload this page.</p></div>';\n            return;\n        }\n\n        // Actions.\n        if ( isset( $_GET['resend'] ) && check_admin_referer( 'wpide_mlog_resend' ) ) {\n            $row = $wpdb->get_row( $wpdb->prepare( \"SELECT * FROM {$table} WHERE id = %d\", absint( $_GET['resend'] ) ) );\n            if ( $row ) {\n                $ok = wp_mail( array_map( 'trim', explode( ',', $row->to_addr ) ), $row->subject, $row->message, array_filter( explode( \"\\n\", $row->headers ) ) );\n                printf( '<div class=\"notice notice-%s\"><p>%s</p></div>', $ok ? 'success' : 'error', $ok ? 'Email resent.' : 'The email could not be resent.' );\n            }\n        }\n        if ( isset( $_POST['wpide_mlog_clear'] ) && check_admin_referer( 'wpide_mlog_clear' ) ) {\n            $wpdb->query( \"DELETE FROM {$table}\" ); // phpcs:ignore\n            echo '<div class=\"notice notice-success\"><p>Log cleared.</p></div>';\n        }\n\n        // Single message view.\n        if ( isset( $_GET['view'] ) ) {\n            $row = $wpdb->get_row( $wpdb->prepare( \"SELECT * FROM {$table} WHERE id = %d\", absint( $_GET['view'] ) ) );\n            if ( $row ) {\n                printf( '<p><a href=\"%s\">&larr; Back to the log</a></p>', esc_url( $base ) );\n                printf( '<table class=\"widefat striped\"><tbody><tr><th>To</th><td>%s</td></tr><tr><th>Subject</th><td>%s</td></tr><tr><th>Date (UTC)</th><td>%s</td></tr><tr><th>Status</th><td>%s %s</td></tr><tr><th>Headers</th><td><pre style=\"white-space:pre-wrap\">%s</pre></td></tr></tbody></table>', esc_html( $row->to_addr ), esc_html( $row->subject ), esc_html( $row->sent_at ), esc_html( $row->status ), esc_html( $row->error ), esc_html( $row->headers ) );\n                echo '<h2>Message</h2><iframe sandbox=\"\" style=\"width:100%;min-height:420px;background:#fff;border:1px solid #c3c4c7\" srcdoc=\"' . esc_attr( false !== stripos( $row->message, '<html' ) || false !== stripos( $row->headers, 'text/html' ) ? $row->message : '<pre style=\"white-space:pre-wrap;font:14px/1.5 sans-serif\">' . esc_html( $row->message ) . '</pre>' ) . '\"></iframe>';\n                printf( '<p><a class=\"button\" href=\"%s\">Resend this email</a></p></div>', esc_url( wp_nonce_url( add_query_arg( 'resend', (int) $row->id, $base ), 'wpide_mlog_resend' ) ) );\n                return;\n            }\n        }\n\n        $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';\n        $paged  = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 );\n        $where  = $search ? $wpdb->prepare( 'WHERE to_addr LIKE %s OR subject LIKE %s', '%' . $wpdb->esc_like( $search ) . '%', '%' . $wpdb->esc_like( $search ) . '%' ) : '';\n        $rows   = $wpdb->get_results( \"SELECT id, sent_at, to_addr, subject, status FROM {$table} {$where} ORDER BY id DESC LIMIT 50 OFFSET \" . ( ( $paged - 1 ) * 50 ) ); // phpcs:ignore\n\n        printf( '<form method=\"get\"><input type=\"hidden\" name=\"page\" value=\"wpide-mail-log\"><p class=\"search-box\"><input type=\"search\" name=\"s\" value=\"%s\" placeholder=\"Recipient or subject\"> <button class=\"button\">Search</button></p></form>', esc_attr( $search ) );\n        echo '<table class=\"widefat striped\"><thead><tr><th>Date (UTC)</th><th>To</th><th>Subject</th><th>Status</th><th></th></tr></thead><tbody>';\n        if ( ! $rows ) {\n            echo '<tr><td colspan=\"5\">No emails logged yet.</td></tr>';\n        }\n        foreach ( $rows as $row ) {\n            printf( '<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td><a href=\"%s\">View</a></td></tr>', esc_html( $row->sent_at ), esc_html( $row->to_addr ), esc_html( $row->subject ), esc_html( $row->status ), esc_url( add_query_arg( 'view', (int) $row->id, $base ) ) );\n        }\n        echo '</tbody></table>';\n        if ( 50 === count( $rows ) ) {\n            printf( '<p><a class=\"button\" href=\"%s\">Older emails &rarr;</a></p>', esc_url( add_query_arg( array( 'paged' => $paged + 1, 's' => $search ), $base ) ) );\n        }\n        echo '<form method=\"post\" style=\"margin-top:1em\">';\n        wp_nonce_field( 'wpide_mlog_clear' );\n        echo '<button class=\"button button-link-delete\" name=\"wpide_mlog_clear\" value=\"1\" onclick=\"return confirm(\\'Delete all log entries?\\')\">Clear the log</button></form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Creates the {prefix}wpide_mail_log table the first time an administrator opens the admin and purges old entries daily; message bodies can hold personal data, so keep the retention short."
        },
        {
            "id": "email-local-mailpit",
            "version": 1,
            "title": "Catch All Email Locally With Mailpit or MailHog",
            "description": "On local and development sites, sends every WordPress email to a local mail catcher such as Mailpit, MailHog or the one built into Herd, Local or DDEV, so nothing reaches real inboxes.",
            "category": "email",
            "tags": [
                "email",
                "development",
                "smtp"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_catcher_host = '127.0.0.1';\n$wpide_catcher_port = 1025;\n\nadd_action( 'phpmailer_init', function ( $phpmailer ) use ( $wpide_catcher_host, $wpide_catcher_port ) {\n    if ( ! in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ) {\n        return;\n    }\n\n    $phpmailer->isSMTP();\n    $phpmailer->Host        = $wpide_catcher_host;\n    $phpmailer->Port        = $wpide_catcher_port;\n    $phpmailer->SMTPAuth    = false;\n    $phpmailer->SMTPSecure  = '';\n    $phpmailer->SMTPAutoTLS = false;\n}, 999 );",
            "requires": [],
            "notes": "Only active when WP_ENVIRONMENT_TYPE is local or development; change the host and port to match your tool (Mailpit and MailHog use port 1025)."
        },
        {
            "id": "email-smtp-debug-log",
            "version": 1,
            "title": "Write SMTP Conversation to the Debug Log",
            "description": "Records the conversation between WordPress and your SMTP server in the PHP error log while you troubleshoot, with passwords and login tokens blanked out.",
            "category": "email",
            "tags": [
                "email",
                "smtp",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    if ( ! defined( 'WPIDE_SMTP_DEBUG' ) || ! WPIDE_SMTP_DEBUG ) {\n        return;\n    }\n\n    $phpmailer->SMTPDebug   = 2;\n    $phpmailer->Debugoutput = function ( $text ) {\n        $text = trim( (string) $text );\n\n        // Hide credentials: AUTH arguments and base64 lines sent by the client.\n        $text = preg_replace( '/^(CLIENT -> SERVER:\\s*AUTH\\s+\\S+)\\s+.+$/i', '$1 [redacted]', $text );\n        if ( preg_match( '/^CLIENT -> SERVER:\\s*(\\S+)\\s*$/', $text, $m ) && preg_match( '/^[A-Za-z0-9+\\/=]{8,}$/', $m[1] ) && ! preg_match( '/^(EHLO|HELO|QUIT|DATA|RSET|NOOP|STARTTLS)$/i', $m[1] ) ) {\n            $text = 'CLIENT -> SERVER: [redacted]';\n        }\n\n        error_log( 'SMTP: ' . $text );\n    };\n}, 1000 );",
            "requires": [],
            "notes": "Only runs while WPIDE_SMTP_DEBUG is defined as true in wp-config.php; turn it off when you are done because the log grows quickly."
        },
        {
            "id": "email-smtp-connection-check",
            "version": 1,
            "title": "Check the SMTP Connection Without Sending",
            "description": "Adds Tools > SMTP Check, which connects and logs in to your configured SMTP server and reports success or the exact error, without sending an email to anyone.",
            "category": "email",
            "tags": [
                "email",
                "smtp",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'SMTP Check', 'SMTP Check', 'manage_options', 'wpide-smtp-check', function () {\n        echo '<div class=\"wrap\"><h1>SMTP Check</h1>';\n\n        if ( isset( $_POST['wpide_smtp_check'] ) && check_admin_referer( 'wpide_smtp_check' ) && current_user_can( 'manage_options' ) ) {\n            require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php';\n            require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php';\n            require_once ABSPATH . WPINC . '/PHPMailer/Exception.php';\n\n            $mailer = new PHPMailer\\PHPMailer\\PHPMailer( true );\n            $mailer->Timeout = 10;\n            do_action_ref_array( 'phpmailer_init', array( &$mailer ) );\n\n            if ( 'smtp' !== $mailer->Mailer ) {\n                echo '<div class=\"notice notice-warning\"><p>WordPress is not configured to use SMTP. Emails are sent with PHP mail().</p></div>';\n            } else {\n                try {\n                    $ok = $mailer->smtpConnect();\n                    $mailer->smtpClose();\n                    printf( '<div class=\"notice notice-%1$s\"><p>%2$s</p></div>', $ok ? 'success' : 'error', esc_html( $ok ? sprintf( 'Connected and authenticated with %s:%d.', $mailer->Host, $mailer->Port ) : 'Could not connect: ' . $mailer->ErrorInfo ) );\n                } catch ( Exception $e ) {\n                    printf( '<div class=\"notice notice-error\"><p>%s</p></div>', esc_html( 'Could not connect: ' . $e->getMessage() ) );\n                }\n            }\n        }\n\n        echo '<p>Connects to the SMTP server with your current settings and logs in, without sending anything.</p><form method=\"post\">';\n        wp_nonce_field( 'wpide_smtp_check' );\n        echo '<button class=\"button button-primary\" name=\"wpide_smtp_check\" value=\"1\">Run the check</button></form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Uses whatever SMTP settings your other snippets or mail plugin apply through phpmailer_init; if nothing switches WordPress to SMTP, it tells you so."
        },
        {
            "id": "email-wp-cli-test-command",
            "version": 1,
            "title": "WP-CLI Command to Send a Test Email",
            "description": "Adds \"wp wpide-mail test you@example.com\" to WP-CLI, which sends a test email and prints the exact error if it fails, handy on servers without admin access.",
            "category": "email",
            "tags": [
                "email",
                "wp-cli",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_CLI' ) ) {\n    WP_CLI::add_command( 'wpide-mail test', function ( $args, $assoc ) {\n        $to = isset( $args[0] ) ? sanitize_email( $args[0] ) : '';\n\n        if ( ! is_email( $to ) ) {\n            WP_CLI::error( 'Please pass a valid email address, e.g. wp wpide-mail test you@example.com' );\n        }\n\n        $error = '';\n        $catch = function ( $wp_error ) use ( &$error ) {\n            $error = $wp_error->get_error_message();\n        };\n        add_action( 'wp_mail_failed', $catch );\n\n        $subject = isset( $assoc['subject'] ) ? sanitize_text_field( $assoc['subject'] ) : 'Test email from ' . wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n        $sent    = wp_mail( $to, $subject, 'This is a test email sent with WP-CLI from ' . home_url() . ' at ' . wp_date( 'Y-m-d H:i:s' ) . '.' );\n\n        remove_action( 'wp_mail_failed', $catch );\n\n        if ( $sent ) {\n            WP_CLI::success( \"Email handed to the mail server for $to.\" );\n        } else {\n            WP_CLI::error( 'Sending failed: ' . ( $error ? $error : 'unknown error' ) );\n        }\n    } );\n}",
            "requires": [],
            "notes": "Add --subject=\"...\" to change the subject; the command only exists when WP-CLI is running."
        },
        {
            "id": "email-reply-to-commenter-moderation",
            "version": 1,
            "title": "Reply to Commenters From Moderation Emails",
            "description": "Adds the commenter's email as the Reply-To of comment moderation emails, so you can answer or ask a question just by pressing Reply in your inbox.",
            "category": "email",
            "tags": [
                "email",
                "comments",
                "reply-to"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_moderation_headers', function ( $headers, $comment_id ) {\n    $comment = get_comment( $comment_id );\n\n    if ( ! $comment || ! is_email( $comment->comment_author_email ) || false !== stripos( (string) $headers, 'Reply-To:' ) ) {\n        return $headers;\n    }\n\n    $name = trim( str_replace( array( '\"', \"\\r\", \"\\n\", '<', '>' ), '', $comment->comment_author ) );\n\n    return rtrim( (string) $headers, \"\\n\" ) . \"\\nReply-To: \" . ( $name ? '\"' . $name . '\" ' : '' ) . '<' . $comment->comment_author_email . \">\\n\";\n}, 10, 2 );",
            "requires": [],
            "notes": "WordPress already does this for post author notifications; this adds it to the \"please moderate\" emails."
        },
        {
            "id": "email-reply-to-rules",
            "version": 1,
            "title": "Reply-To Address Rules by Email Subject",
            "description": "Sets a different Reply-To address depending on the email's subject, so replies to order, support or form emails reach the right team automatically.",
            "category": "email",
            "tags": [
                "email",
                "reply-to",
                "routing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Text found in the subject (case-insensitive) => Reply-To address.\n$wpide_reply_rules = array(\n    // 'order'   => 'orders@yourdomain.com',\n    // 'support' => 'support@yourdomain.com',\n);\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_reply_rules ) {\n    $headers = is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'];\n\n    if ( false !== stripos( $headers, 'Reply-To:' ) ) {\n        return $atts;\n    }\n\n    foreach ( $wpide_reply_rules as $needle => $address ) {\n        if ( false !== stripos( (string) $atts['subject'], $needle ) && is_email( $address ) ) {\n            if ( is_array( $atts['headers'] ) ) {\n                $atts['headers'][] = 'Reply-To: ' . $address;\n            } else {\n                $atts['headers'] = trim( $headers . \"\\nReply-To: \" . $address );\n            }\n            break;\n        }\n    }\n\n    return $atts;\n} );",
            "requires": [],
            "notes": "Add your subject-to-address rules at the top (the first match wins); emails that already have a Reply-To are left alone."
        },
        {
            "id": "email-theme-template-file",
            "version": 1,
            "title": "Wrap Emails in a Template File From Your Theme",
            "description": "Wraps every plain-text WordPress email in an HTML template file stored in your theme, so designers can edit the email layout like any other template.",
            "category": "email",
            "tags": [
                "email",
                "html",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $template = locate_template( 'emails/wrapper.php' );\n    $headers  = is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'];\n\n    if ( ! $template || false !== stripos( $headers, 'text/html' ) || preg_match( '/<(html|body|p|div|table)\\b/i', (string) $atts['message'] ) ) {\n        return $atts;\n    }\n\n    $subject = wp_strip_all_tags( (string) $atts['subject'] );\n    $content = make_clickable( nl2br( esc_html( (string) $atts['message'] ) ) );\n\n    ob_start();\n    include $template;\n    $html = trim( (string) ob_get_clean() );\n\n    if ( '' === $html ) {\n        return $atts;\n    }\n\n    $atts['message'] = $html;\n    $type            = 'Content-Type: text/html; charset=' . get_bloginfo( 'charset' );\n\n    if ( is_array( $atts['headers'] ) ) {\n        $atts['headers'][] = $type;\n    } else {\n        $atts['headers'] = trim( $headers . \"\\n\" . $type );\n    }\n\n    return $atts;\n}, 20 );",
            "requires": [],
            "notes": "Create emails/wrapper.php in your (child) theme using $content (safe HTML) and $subject; without the file emails are unchanged, and do not combine it with another HTML wrapper snippet."
        },
        {
            "id": "email-signature-footer",
            "version": 1,
            "title": "Add a Signature to Every WordPress Email",
            "description": "Appends a short signature, such as your company name, address and support link, to the bottom of every email the site sends, in both plain-text and HTML emails.",
            "category": "email",
            "tags": [
                "email",
                "signature",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_signature = array(\n    'Example Ltd · 1 Main Street, Springfield',\n    'Need help? support@example.com',\n);\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_signature ) {\n    $message = (string) $atts['message'];\n    $headers = is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'];\n    $is_html = false !== stripos( $headers, 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n\n    if ( $is_html ) {\n        $block = '<p style=\"margin-top:24px;padding-top:12px;border-top:1px solid #ddd;color:#666;font-size:12px;\">' . implode( '<br>', array_map( 'esc_html', $wpide_signature ) ) . '</p>';\n        $atts['message'] = false !== stripos( $message, '</body>' ) ? preg_replace( '#</body>#i', $block . '</body>', $message, 1 ) : $message . $block;\n    } else {\n        $atts['message'] = rtrim( $message ) . \"\\n\\n-- \\n\" . implode( \"\\n\", $wpide_signature ) . \"\\n\";\n    }\n\n    return $atts;\n}, 30 );",
            "requires": [],
            "notes": "Edit the lines at the top; HTML emails get the signature before the closing body tag."
        },
        {
            "id": "email-suppression-list",
            "version": 1,
            "title": "Never Email Certain Addresses or Domains",
            "description": "Removes addresses and whole domains on your suppression list from every outgoing email, such as bouncing inboxes or people who asked not to be contacted, and skips emails left with no recipient.",
            "category": "email",
            "tags": [
                "email",
                "suppression",
                "deliverability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_suppressed = array( 'bounced@example.com', '@old-company.com' );\n\n$wpide_is_suppressed = function ( $recipient ) use ( $wpide_suppressed ) {\n    $email = strtolower( trim( preg_match( '/<([^>]+)>/', $recipient, $m ) ? $m[1] : $recipient ) );\n\n    foreach ( $wpide_suppressed as $entry ) {\n        $entry = strtolower( trim( $entry ) );\n        if ( $email === $entry || ( 0 === strpos( $entry, '@' ) && substr( $email, -strlen( $entry ) ) === $entry ) ) {\n            return true;\n        }\n    }\n\n    return false;\n};\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_is_suppressed ) {\n    $to = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n\n    $atts['to'] = array_values( array_filter( array_map( 'trim', $to ), function ( $recipient ) use ( $wpide_is_suppressed ) {\n        return '' !== $recipient && ! $wpide_is_suppressed( $recipient );\n    } ) );\n\n    return $atts;\n}, 5 );\n\nadd_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    return empty( $atts['to'] ) ? true : $return;\n}, 5, 2 );",
            "requires": [],
            "notes": "List full addresses or \"@domain.com\" entries; CC and BCC headers are not filtered."
        },
        {
            "id": "email-hourly-send-limit",
            "version": 1,
            "title": "Hourly Limit on Outgoing Emails",
            "description": "Caps how many emails the site can send per hour and stops the rest, which protects your mail account and domain reputation if a form or plugin is abused to send spam.",
            "category": "email",
            "tags": [
                "email",
                "security",
                "rate-limit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_mail_hourly_cap = 100;\n\nadd_filter( 'pre_wp_mail', function ( $return ) use ( $wpide_mail_hourly_cap ) {\n    if ( null !== $return ) {\n        return $return;\n    }\n\n    $key   = 'wpide_mail_count_' . gmdate( 'YmdH' );\n    $count = (int) get_transient( $key );\n\n    if ( $count >= $wpide_mail_hourly_cap ) {\n        if ( ! get_transient( $key . '_noted' ) ) {\n            error_log( sprintf( 'WPIDE: hourly email limit of %d reached; further emails this hour are blocked.', $wpide_mail_hourly_cap ) );\n            set_transient( $key . '_noted', 1, HOUR_IN_SECONDS );\n        }\n        return false;\n    }\n\n    set_transient( $key, $count + 1, HOUR_IN_SECONDS );\n\n    return $return;\n}, 1 );",
            "requires": [],
            "notes": "Set the limit above your busiest real hour; blocked emails are reported as failures and noted in the PHP error log once per hour."
        },
        {
            "id": "email-clean-recipient-list",
            "version": 1,
            "title": "Clean Up Email Recipient Lists Before Sending",
            "description": "Removes duplicate, malformed and placeholder addresses (such as example.com or .test domains) from email recipient lists, so one bad address does not make the whole email fail.",
            "category": "email",
            "tags": [
                "email",
                "validation",
                "deliverability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_reserved_domains = array( 'example.com', 'example.org', 'example.net', 'test', 'invalid', 'localhost', 'local' );\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_reserved_domains ) {\n    $list  = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $clean = array();\n\n    foreach ( $list as $recipient ) {\n        $recipient = trim( (string) $recipient );\n        $email     = strtolower( preg_match( '/<([^>]+)>/', $recipient, $m ) ? trim( $m[1] ) : $recipient );\n        $domain    = (string) substr( strrchr( $email, '@' ), 1 );\n        $tld       = substr( strrchr( '.' . $domain, '.' ), 1 );\n\n        if ( ! is_email( $email ) || isset( $clean[ $email ] ) || in_array( $domain, $wpide_reserved_domains, true ) || in_array( $tld, $wpide_reserved_domains, true ) ) {\n            continue;\n        }\n\n        $clean[ $email ] = $recipient;\n    }\n\n    $atts['to'] = array_values( $clean );\n\n    return $atts;\n}, 6 );",
            "requires": [],
            "notes": "Only the To list is cleaned; edit the reserved domains at the top if you really send to any of them."
        },
        {
            "id": "email-send-via-postmark",
            "version": 1,
            "title": "Send WordPress Email Through the Postmark API",
            "description": "Delivers all WordPress emails through Postmark's HTTP API instead of PHP mail or SMTP, for fast, reliable delivery on hosts that block outgoing mail ports.",
            "category": "email",
            "tags": [
                "email",
                "postmark",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_POSTMARK_TOKEN' ) || ! WPIDE_POSTMARK_TOKEN || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $from    = $h['from'] ? $h['from'] : sprintf( '%s <%s>', apply_filters( 'wp_mail_from_name', 'WordPress' ), apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ) );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n\n    $body = array(\n        'From'          => $from,\n        'To'            => implode( ',', array_map( 'trim', $to ) ),\n        'Subject'       => (string) $atts['subject'],\n        'MessageStream' => 'outbound',\n    );\n    $body[ $is_html ? 'HtmlBody' : 'TextBody' ] = (string) $atts['message'];\n    if ( $h['cc'] ) {\n        $body['Cc'] = implode( ',', $h['cc'] );\n    }\n    if ( $h['bcc'] ) {\n        $body['Bcc'] = implode( ',', $h['bcc'] );\n    }\n    if ( $h['reply-to'] ) {\n        $body['ReplyTo'] = implode( ',', $h['reply-to'] );\n    }\n\n    $response = wp_remote_post( 'https://api.postmarkapp.com/email', array(\n        'timeout' => 15,\n        'headers' => array( 'Accept' => 'application/json', 'Content-Type' => 'application/json', 'X-Postmark-Server-Token' => WPIDE_POSTMARK_TOKEN ),\n        'body'    => wp_json_encode( $body ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( 200 === $code ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'Postmark returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_POSTMARK_TOKEN (your server API token) in wp-config.php and send from a verified Postmark sender; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-send-via-mailgun",
            "version": 1,
            "title": "Send WordPress Email Through the Mailgun API",
            "description": "Delivers all WordPress emails through Mailgun's HTTP API, which avoids blocked SMTP ports and gives you Mailgun's delivery logs and tracking.",
            "category": "email",
            "tags": [
                "email",
                "mailgun",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_MAILGUN_KEY' ) || ! defined( 'WPIDE_MAILGUN_DOMAIN' ) || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n\n    $body = array(\n        'from'    => $h['from'] ? $h['from'] : sprintf( '%s <%s>', apply_filters( 'wp_mail_from_name', 'WordPress' ), apply_filters( 'wp_mail_from', 'wordpress@' . WPIDE_MAILGUN_DOMAIN ) ),\n        'to'      => implode( ',', array_map( 'trim', $to ) ),\n        'subject' => (string) $atts['subject'],\n    );\n    $body[ $is_html ? 'html' : 'text' ] = (string) $atts['message'];\n    if ( $h['cc'] ) {\n        $body['cc'] = implode( ',', $h['cc'] );\n    }\n    if ( $h['bcc'] ) {\n        $body['bcc'] = implode( ',', $h['bcc'] );\n    }\n    if ( $h['reply-to'] ) {\n        $body['h:Reply-To'] = implode( ',', $h['reply-to'] );\n    }\n\n    $host     = defined( 'WPIDE_MAILGUN_EU' ) && WPIDE_MAILGUN_EU ? 'api.eu.mailgun.net' : 'api.mailgun.net';\n    $response = wp_remote_post( 'https://' . $host . '/v3/' . rawurlencode( WPIDE_MAILGUN_DOMAIN ) . '/messages', array(\n        'timeout' => 15,\n        'headers' => array( 'Authorization' => 'Basic ' . base64_encode( 'api:' . WPIDE_MAILGUN_KEY ) ),\n        'body'    => $body,\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'Mailgun returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_MAILGUN_KEY and WPIDE_MAILGUN_DOMAIN (plus WPIDE_MAILGUN_EU as true for EU accounts) in wp-config.php; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-send-via-sendgrid",
            "version": 1,
            "title": "Send WordPress Email Through the SendGrid API",
            "description": "Delivers all WordPress emails through SendGrid's v3 Mail Send API instead of PHP mail or SMTP, for reliable delivery and SendGrid's activity feed.",
            "category": "email",
            "tags": [
                "email",
                "sendgrid",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_SENDGRID_KEY' ) || ! WPIDE_SENDGRID_KEY || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $address = function ( $value ) {\n        $value = trim( (string) $value );\n        if ( preg_match( '/^(.*)<([^>]+)>$/', $value, $m ) ) {\n            $name = trim( $m[1], \" \\\"'\" );\n            return $name ? array( 'email' => trim( $m[2] ), 'name' => $name ) : array( 'email' => trim( $m[2] ) );\n        }\n        return array( 'email' => $value );\n    };\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n    $from    = $h['from'] ? $address( $h['from'] ) : array( 'email' => apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ), 'name' => apply_filters( 'wp_mail_from_name', 'WordPress' ) );\n\n    $personalization = array( 'to' => array_map( $address, array_filter( array_map( 'trim', $to ) ) ) );\n    if ( $h['cc'] ) {\n        $personalization['cc'] = array_map( $address, $h['cc'] );\n    }\n    if ( $h['bcc'] ) {\n        $personalization['bcc'] = array_map( $address, $h['bcc'] );\n    }\n\n    $body = array(\n        'personalizations' => array( $personalization ),\n        'from'             => $from,\n        'subject'          => (string) $atts['subject'],\n        'content'          => array( array( 'type' => $is_html ? 'text/html' : 'text/plain', 'value' => (string) $atts['message'] ) ),\n    );\n    if ( $h['reply-to'] ) {\n        $body['reply_to'] = $address( $h['reply-to'][0] );\n    }\n\n    $response = wp_remote_post( 'https://api.sendgrid.com/v3/mail/send', array(\n        'timeout' => 15,\n        'headers' => array( 'Authorization' => 'Bearer ' . WPIDE_SENDGRID_KEY, 'Content-Type' => 'application/json' ),\n        'body'    => wp_json_encode( $body ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'SendGrid returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_SENDGRID_KEY in wp-config.php and verify your sender domain in SendGrid; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-send-via-brevo",
            "version": 1,
            "title": "Send WordPress Email Through the Brevo API",
            "description": "Delivers all WordPress emails through the Brevo (formerly Sendinblue) transactional email API, so mail goes out even where SMTP ports are blocked.",
            "category": "email",
            "tags": [
                "email",
                "brevo",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_BREVO_KEY' ) || ! WPIDE_BREVO_KEY || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $address = function ( $value ) {\n        $value = trim( (string) $value );\n        if ( preg_match( '/^(.*)<([^>]+)>$/', $value, $m ) ) {\n            $name = trim( $m[1], \" \\\"'\" );\n            return $name ? array( 'email' => trim( $m[2] ), 'name' => $name ) : array( 'email' => trim( $m[2] ) );\n        }\n        return array( 'email' => $value );\n    };\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n\n    $body = array(\n        'sender'  => $h['from'] ? $address( $h['from'] ) : array( 'email' => apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ), 'name' => apply_filters( 'wp_mail_from_name', 'WordPress' ) ),\n        'to'      => array_map( $address, array_filter( array_map( 'trim', $to ) ) ),\n        'subject' => (string) $atts['subject'],\n    );\n    $body[ $is_html ? 'htmlContent' : 'textContent' ] = (string) $atts['message'];\n    if ( $h['cc'] ) {\n        $body['cc'] = array_map( $address, $h['cc'] );\n    }\n    if ( $h['bcc'] ) {\n        $body['bcc'] = array_map( $address, $h['bcc'] );\n    }\n    if ( $h['reply-to'] ) {\n        $body['replyTo'] = $address( $h['reply-to'][0] );\n    }\n\n    $response = wp_remote_post( 'https://api.brevo.com/v3/smtp/email', array(\n        'timeout' => 15,\n        'headers' => array( 'api-key' => WPIDE_BREVO_KEY, 'Accept' => 'application/json', 'Content-Type' => 'application/json' ),\n        'body'    => wp_json_encode( $body ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'Brevo returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_BREVO_KEY (a v3 API key) in wp-config.php and use a sender verified in Brevo; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-send-via-resend",
            "version": 1,
            "title": "Use Resend to Deliver WordPress Email",
            "description": "Delivers all WordPress emails through the Resend email API with a single API key, a simple way to get reliable delivery without SMTP.",
            "category": "email",
            "tags": [
                "email",
                "resend",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_RESEND_KEY' ) || ! WPIDE_RESEND_KEY || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n\n    $body = array(\n        'from'    => $h['from'] ? $h['from'] : sprintf( '%s <%s>', apply_filters( 'wp_mail_from_name', 'WordPress' ), apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ) ),\n        'to'      => array_values( array_filter( array_map( 'trim', $to ) ) ),\n        'subject' => (string) $atts['subject'],\n    );\n    $body[ $is_html ? 'html' : 'text' ] = (string) $atts['message'];\n    if ( $h['cc'] ) {\n        $body['cc'] = $h['cc'];\n    }\n    if ( $h['bcc'] ) {\n        $body['bcc'] = $h['bcc'];\n    }\n    if ( $h['reply-to'] ) {\n        $body['reply_to'] = $h['reply-to'];\n    }\n\n    $response = wp_remote_post( 'https://api.resend.com/emails', array(\n        'timeout' => 15,\n        'headers' => array( 'Authorization' => 'Bearer ' . WPIDE_RESEND_KEY, 'Content-Type' => 'application/json' ),\n        'body'    => wp_json_encode( $body ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'Resend returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_RESEND_KEY in wp-config.php and send from a domain verified in Resend; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-recovery-mode-to-developers",
            "version": 1,
            "title": "Send Fatal Error Emails to Your Developers",
            "description": "Sends WordPress's \"your site is experiencing a technical issue\" recovery emails to your developers instead of (or as well as) the site owner, so the right people can fix it.",
            "category": "email",
            "tags": [
                "email",
                "errors",
                "developers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_recovery_devs       = array(); // e.g. array( 'dev@agency.com' ).\n$wpide_recovery_keep_admin = true;\n\nadd_filter( 'recovery_mode_email', function ( $email ) use ( $wpide_recovery_devs, $wpide_recovery_keep_admin ) {\n    $devs = array_filter( $wpide_recovery_devs, 'is_email' );\n\n    if ( $devs ) {\n        $current     = $wpide_recovery_keep_admin ? (array) $email['to'] : array();\n        $email['to'] = array_values( array_unique( array_merge( $current, $devs ) ) );\n    }\n\n    return $email;\n} );",
            "requires": [],
            "notes": "Add your developers' addresses at the top and set $wpide_recovery_keep_admin to false to stop the site owner receiving them; WordPress sends at most one such email per day."
        },
        {
            "id": "email-disable-privacy-confirmed-notice",
            "version": 1,
            "title": "Stop \"Data Request Confirmed\" Emails to the Admin",
            "description": "Stops WordPress emailing the site admin each time a user confirms a personal data export or erasure request; you can still see requests under Tools.",
            "category": "email",
            "tags": [
                "email",
                "privacy",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    remove_action( 'user_request_action_confirmed', '_wp_privacy_send_request_confirmation_notification', 12 );\n} );",
            "requires": [],
            "notes": "Users still receive their own confirmation and data emails; check Tools > Export/Erase Personal Data regularly instead."
        },
        {
            "id": "email-disable-update-debug-emails",
            "version": 1,
            "title": "Stop Automatic Update Debug Emails",
            "description": "Stops the long technical \"background update\" debug emails WordPress sends after automatic updates on development, beta and nightly versions.",
            "category": "email",
            "tags": [
                "email",
                "updates",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'automatic_updates_send_debug_email', '__return_false' );",
            "requires": [],
            "notes": "Success and failure emails for normal releases are not affected."
        },
        {
            "id": "email-update-emails-to-developer",
            "version": 1,
            "title": "Send Automatic Update Emails to Your Developer",
            "description": "Sends WordPress core, plugin and theme auto-update emails to your developer or agency instead of the site owner, who usually does not need them.",
            "category": "email",
            "tags": [
                "email",
                "updates",
                "developers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_update_recipients = array(); // e.g. array( 'dev@agency.com' ); empty = no change.\n\n$wpide_update_to = function ( $email ) use ( $wpide_update_recipients ) {\n    $to = array_values( array_filter( $wpide_update_recipients, 'is_email' ) );\n\n    if ( $to ) {\n        $email['to'] = $to;\n    }\n\n    return $email;\n};\n\nadd_filter( 'auto_core_update_email', $wpide_update_to );\nadd_filter( 'auto_plugin_theme_update_email', $wpide_update_to );\nadd_filter( 'automatic_updates_debug_email', $wpide_update_to );",
            "requires": [],
            "notes": "Add one or more addresses at the top; once set, the site owner stops receiving these emails."
        },
        {
            "id": "email-digest-pending-comments",
            "version": 1,
            "title": "Daily Digest of Comments Awaiting Moderation",
            "description": "Replaces the one-email-per-comment moderation notices with a single daily email listing every comment waiting for approval, with a link to the moderation screen.",
            "category": "email",
            "tags": [
                "email",
                "comments",
                "digest"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_digest_hour = 8; // Hour of the day (0-23, site time).\n\nadd_filter( 'notify_moderator', '__return_false' );\n\nadd_action( 'init', function () use ( $wpide_digest_hour ) {\n    if ( wp_next_scheduled( 'wpide_pending_comments_digest' ) ) {\n        return;\n    }\n    $next = new DateTime( 'today ' . $wpide_digest_hour . ':00', wp_timezone() );\n    if ( $next->getTimestamp() <= time() ) {\n        $next->modify( '+1 day' );\n    }\n    wp_schedule_event( $next->getTimestamp(), 'daily', 'wpide_pending_comments_digest' );\n} );\n\nadd_action( 'wpide_pending_comments_digest', function () {\n    $comments = get_comments( array( 'status' => 'hold', 'type' => 'comment', 'number' => 50, 'orderby' => 'comment_date_gmt', 'order' => 'DESC' ) );\n\n    if ( ! $comments ) {\n        return;\n    }\n\n    $total = (int) get_comments( array( 'status' => 'hold', 'type' => 'comment', 'count' => true ) );\n    $lines = array( sprintf( '%d comment(s) are waiting for your approval:', $total ), '' );\n\n    foreach ( $comments as $comment ) {\n        $lines[] = sprintf( '• %s on \"%s\": %s', $comment->comment_author, wp_strip_all_tags( get_the_title( $comment->comment_post_ID ) ), wp_html_excerpt( wp_strip_all_tags( $comment->comment_content ), 120, '…' ) );\n    }\n\n    $lines[] = '';\n    $lines[] = 'Moderate them: ' . admin_url( 'edit-comments.php?comment_status=moderated' );\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] %d comment(s) awaiting moderation', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $total ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "Sent at the hour set at the top (site time) only when comments are waiting; goes to the admin email address."
        },
        {
            "id": "email-weekly-site-summary",
            "version": 1,
            "title": "Weekly Site Activity Summary by Email",
            "description": "Emails the site admin every Monday with last week's numbers: new posts, comments, pending items and new users, so you keep an eye on the site without logging in.",
            "category": "email",
            "tags": [
                "email",
                "reports",
                "digest"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_summary_hour       = 9;\n$wpide_summary_recipients = array(); // Extra addresses; the admin email is always included.\n\nadd_action( 'init', function () use ( $wpide_summary_hour ) {\n    if ( wp_next_scheduled( 'wpide_weekly_summary' ) ) {\n        return;\n    }\n    $next = new DateTime( 'monday this week ' . $wpide_summary_hour . ':00', wp_timezone() );\n    if ( $next->getTimestamp() <= time() ) {\n        $next->modify( '+1 week' );\n    }\n    wp_schedule_event( $next->getTimestamp(), 'weekly', 'wpide_weekly_summary' );\n} );\n\nadd_action( 'wpide_weekly_summary', function () use ( $wpide_summary_recipients ) {\n    $since = gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS );\n\n    $posts = new WP_Query( array( 'post_type' => 'post', 'post_status' => 'publish', 'date_query' => array( array( 'after' => $since, 'column' => 'post_date_gmt' ) ), 'fields' => 'ids', 'posts_per_page' => 1 ) );\n    $users = new WP_User_Query( array( 'date_query' => array( array( 'after' => $since, 'column' => 'user_registered' ) ), 'fields' => 'ID', 'number' => 1, 'count_total' => true ) );\n\n    $stats = array(\n        'Posts published'             => (int) $posts->found_posts,\n        'Comments approved'           => (int) get_comments( array( 'status' => 'approve', 'count' => true, 'date_query' => array( array( 'after' => $since, 'column' => 'comment_date_gmt' ) ) ) ),\n        'Comments waiting'            => (int) wp_count_comments()->moderated,\n        'Posts waiting for review'    => (int) wp_count_posts( 'post' )->pending,\n        'New users'                   => (int) $users->get_total(),\n    );\n\n    $lines = array( 'Here is what happened on ' . home_url() . ' in the last 7 days:', '' );\n    foreach ( $stats as $label => $value ) {\n        $lines[] = sprintf( '%-28s %d', $label . ':', $value );\n    }\n    $lines[] = '';\n    $lines[] = 'Dashboard: ' . admin_url();\n\n    $to = array_merge( array( get_option( 'admin_email' ) ), array_filter( $wpide_summary_recipients, 'is_email' ) );\n    wp_mail( $to, sprintf( '[%s] Weekly summary', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "Sent on Monday at the hour set at the top (site time); add more recipients to the list."
        },
        {
            "id": "email-digest-new-users",
            "version": 1,
            "title": "Daily Digest of New User Registrations",
            "description": "Replaces the \"New user registration\" email WordPress sends for every sign-up with one daily email listing all new users, their role and email address.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "digest"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_users_digest_hour = 8;\n\nadd_filter( 'wp_send_new_user_notification_to_admin', '__return_false' );\n\nadd_action( 'init', function () use ( $wpide_users_digest_hour ) {\n    if ( wp_next_scheduled( 'wpide_new_users_digest' ) ) {\n        return;\n    }\n    $next = new DateTime( 'today ' . $wpide_users_digest_hour . ':00', wp_timezone() );\n    if ( $next->getTimestamp() <= time() ) {\n        $next->modify( '+1 day' );\n    }\n    wp_schedule_event( $next->getTimestamp(), 'daily', 'wpide_new_users_digest' );\n} );\n\nadd_action( 'wpide_new_users_digest', function () {\n    $users = get_users( array(\n        'date_query' => array( array( 'after' => gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ), 'column' => 'user_registered' ) ),\n        'number'     => 200,\n        'orderby'    => 'registered',\n        'order'      => 'DESC',\n    ) );\n\n    if ( ! $users ) {\n        return;\n    }\n\n    $lines = array( sprintf( '%d new user(s) registered in the last 24 hours:', count( $users ) ), '' );\n    foreach ( $users as $user ) {\n        $lines[] = sprintf( '• %s <%s> – %s', $user->user_login, $user->user_email, implode( ', ', (array) $user->roles ) );\n    }\n    $lines[] = '';\n    $lines[] = 'All users: ' . admin_url( 'users.php' );\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] %d new user(s)', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), count( $users ) ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.1 or later, users still get their own welcome email, and nothing is sent on days without new registrations."
        },
        {
            "id": "email-weekly-scheduled-posts",
            "version": 1,
            "title": "Weekly Email of Upcoming Scheduled Posts",
            "description": "Emails editors and the admin every Monday with the posts scheduled to publish in the coming week, so everyone knows what is going live and when.",
            "category": "email",
            "tags": [
                "email",
                "editorial",
                "digest"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( wp_next_scheduled( 'wpide_scheduled_posts_email' ) ) {\n        return;\n    }\n    $next = new DateTime( 'monday this week 08:00', wp_timezone() );\n    if ( $next->getTimestamp() <= time() ) {\n        $next->modify( '+1 week' );\n    }\n    wp_schedule_event( $next->getTimestamp(), 'weekly', 'wpide_scheduled_posts_email' );\n} );\n\nadd_action( 'wpide_scheduled_posts_email', function () {\n    $posts = get_posts( array(\n        'post_type'     => 'any',\n        'post_status'   => 'future',\n        'numberposts'   => 100,\n        'orderby'       => 'date',\n        'order'         => 'ASC',\n        'no_found_rows' => true,\n        'date_query'    => array( array( 'before' => gmdate( 'Y-m-d H:i:s', time() + WEEK_IN_SECONDS ), 'column' => 'post_date_gmt' ) ),\n    ) );\n\n    if ( ! $posts ) {\n        return;\n    }\n\n    $lines = array( 'Scheduled to publish in the next 7 days:', '' );\n    foreach ( $posts as $post ) {\n        $lines[] = sprintf( '• %s – %s (%s)', get_the_date( 'D j M, H:i', $post ), wp_strip_all_tags( get_the_title( $post ) ), get_the_author_meta( 'display_name', $post->post_author ) );\n    }\n\n    $to = array_merge( array( get_option( 'admin_email' ) ), wp_list_pluck( get_users( array( 'role' => 'editor', 'fields' => array( 'user_email' ) ) ), 'user_email' ) );\n    wp_mail( array_unique( $to ), sprintf( '[%s] %d post(s) scheduled this week', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), count( $posts ) ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "Goes to every user with the Editor role plus the admin email; nothing is sent when no posts are scheduled."
        },
        {
            "id": "email-stale-drafts-reminder",
            "version": 1,
            "title": "Remind Authors About Old Drafts",
            "description": "Once a week, emails each author a list of their drafts that have not been touched for a month, with edit links, so good ideas do not get forgotten.",
            "category": "email",
            "tags": [
                "email",
                "editorial",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_stale_days = 30;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_stale_drafts' ) ) {\n        wp_schedule_event( time() + DAY_IN_SECONDS, 'weekly', 'wpide_stale_drafts' );\n    }\n} );\n\nadd_action( 'wpide_stale_drafts', function () use ( $wpide_stale_days ) {\n    $drafts = get_posts( array(\n        'post_type'     => 'post',\n        'post_status'   => 'draft',\n        'numberposts'   => 300,\n        'no_found_rows' => true,\n        'date_query'    => array( array( 'before' => gmdate( 'Y-m-d H:i:s', time() - $wpide_stale_days * DAY_IN_SECONDS ), 'column' => 'post_modified_gmt' ) ),\n    ) );\n\n    $by_author = array();\n    foreach ( $drafts as $draft ) {\n        $by_author[ (int) $draft->post_author ][] = $draft;\n    }\n\n    foreach ( $by_author as $author_id => $posts ) {\n        $user = get_userdata( $author_id );\n        if ( ! $user || ! is_email( $user->user_email ) ) {\n            continue;\n        }\n\n        $lines = array( sprintf( 'Hi %s, these drafts have not been edited for over %d days:', $user->display_name, $wpide_stale_days ), '' );\n        foreach ( array_slice( $posts, 0, 20 ) as $post ) {\n            $lines[] = sprintf( '• %s – %s', $post->post_title ? $post->post_title : '(no title)', admin_url( 'post.php?post=' . $post->ID . '&action=edit' ) );\n        }\n        $lines[] = '';\n        $lines[] = 'Finish them, schedule them or move them to the trash.';\n\n        wp_mail( $user->user_email, 'Your drafts are waiting', implode( \"\\n\", $lines ) );\n    }\n} );",
            "requires": [],
            "notes": "Change the number of days at the top; each author gets at most one email per week listing up to 20 drafts."
        },
        {
            "id": "email-updates-available-alert",
            "version": 1,
            "title": "Email When Plugin or Theme Updates Are Available",
            "description": "Checks once a day for plugin, theme and WordPress updates and emails the admin a list when there is something new to install, so outdated software does not linger.",
            "category": "email",
            "tags": [
                "email",
                "updates",
                "maintenance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_updates_alert' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_updates_alert' );\n    }\n} );\n\nadd_action( 'wpide_updates_alert', function () {\n    $lines   = array();\n    $plugins = get_site_transient( 'update_plugins' );\n    $themes  = get_site_transient( 'update_themes' );\n    $core    = get_site_transient( 'update_core' );\n\n    if ( ! empty( $plugins->response ) ) {\n        if ( ! function_exists( 'get_plugins' ) ) {\n            require_once ABSPATH . 'wp-admin/includes/plugin.php';\n        }\n        $installed = get_plugins();\n        foreach ( $plugins->response as $file => $data ) {\n            $name    = isset( $installed[ $file ]['Name'] ) ? $installed[ $file ]['Name'] : $file;\n            $lines[] = sprintf( 'Plugin: %s → %s', $name, $data->new_version );\n        }\n    }\n    if ( ! empty( $themes->response ) ) {\n        foreach ( $themes->response as $slug => $data ) {\n            $lines[] = sprintf( 'Theme: %s → %s', $slug, $data['new_version'] );\n        }\n    }\n    if ( ! empty( $core->updates[0] ) && 'upgrade' === $core->updates[0]->response ) {\n        $lines[] = 'WordPress → ' . $core->updates[0]->current;\n    }\n\n    $hash = md5( implode( '|', $lines ) );\n    if ( ! $lines || get_option( 'wpide_updates_alert_hash' ) === $hash ) {\n        return;\n    }\n    update_option( 'wpide_updates_alert_hash', $hash, false );\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] %d update(s) available', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), count( $lines ) ), \"These updates are ready to install:\\n\\n\" . implode( \"\\n\", $lines ) . \"\\n\\nUpdate now: \" . admin_url( 'update-core.php' ) );\n} );",
            "requires": [],
            "notes": "Only emails when the list of available updates changes, so you are not reminded daily about the same ones."
        },
        {
            "id": "email-no-gmail-threading",
            "version": 1,
            "title": "Stop Gmail Grouping Your Site's Emails Into Threads",
            "description": "Adds a unique reference header to every email so Gmail shows each notification separately instead of bundling emails with the same subject into one long conversation.",
            "category": "email",
            "tags": [
                "email",
                "gmail",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    $phpmailer->addCustomHeader( 'X-Entity-Ref-ID', wp_generate_uuid4() );\n} );",
            "requires": [],
            "notes": "Invisible to recipients; Gmail uses the X-Entity-Ref-ID header to decide whether to thread messages."
        },
        {
            "id": "email-dark-mode-meta",
            "version": 1,
            "title": "Dark Mode Support for HTML Emails",
            "description": "Adds the color-scheme meta tags to HTML emails, so mail apps like Apple Mail and Outlook render them properly in dark mode instead of inverting your colors badly.",
            "category": "email",
            "tags": [
                "email",
                "html",
                "dark-mode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $message = (string) $atts['message'];\n\n    if ( false === stripos( $message, '<head' ) || false !== stripos( $message, 'color-scheme' ) ) {\n        return $atts;\n    }\n\n    $meta = '<meta name=\"color-scheme\" content=\"light dark\"><meta name=\"supported-color-schemes\" content=\"light dark\">';\n    $atts['message'] = preg_replace( '/<head([^>]*)>/i', '<head$1>' . $meta, $message, 1 );\n\n    return $atts;\n}, 50 );",
            "requires": [],
            "notes": "Only changes emails that already contain an HTML head; your template should also look fine on dark backgrounds."
        },
        {
            "id": "email-remove-subject-site-prefix",
            "version": 1,
            "title": "Remove the [Site Name] Prefix From Email Subjects",
            "description": "Strips the \"[Your Site Name]\" WordPress puts at the start of its email subjects, which looks dated and wastes space on phones.",
            "category": "email",
            "tags": [
                "email",
                "subject",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $prefix = '[' . wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ) . ']';\n\n    if ( 0 === strpos( (string) $atts['subject'], $prefix ) ) {\n        $atts['subject'] = ltrim( substr( $atts['subject'], strlen( $prefix ) ) );\n    }\n\n    return $atts;\n}, 1 );",
            "requires": [],
            "notes": "Only removes the prefix when it matches your site title exactly."
        },
        {
            "id": "email-align-from-domain",
            "version": 1,
            "title": "Keep the From Address on Your Own Domain",
            "description": "When a plugin sends email \"from\" a visitor's or a free webmail address, switches the From to your own domain and keeps the original address as Reply-To, so emails pass SPF and DMARC checks.",
            "category": "email",
            "tags": [
                "email",
                "deliverability",
                "dmarc"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_aligned_from = ''; // Address on your domain; empty = wordpress@yourdomain.\n\nadd_action( 'phpmailer_init', function ( $phpmailer ) use ( $wpide_aligned_from ) {\n    $site   = preg_replace( '/^www\\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n    $domain = strtolower( (string) substr( strrchr( (string) $phpmailer->From, '@' ), 1 ) );\n\n    if ( '' === $site || $domain === $site || ( strlen( $domain ) > strlen( $site ) && substr( $domain, -strlen( '.' . $site ) ) === '.' . $site ) ) {\n        return;\n    }\n\n    $original = $phpmailer->From;\n    $name     = $phpmailer->FromName;\n    $from     = is_email( $wpide_aligned_from ) ? $wpide_aligned_from : 'wordpress@' . $site;\n\n    if ( ! $phpmailer->getReplyToAddresses() && is_email( $original ) ) {\n        $phpmailer->addReplyTo( $original, $name );\n    }\n\n    $phpmailer->setFrom( $from, $name, false );\n}, 999 );",
            "requires": [],
            "notes": "Set the address to use at the top, or leave it empty to use wordpress@ your domain."
        },
        {
            "id": "email-remove-x-mailer",
            "version": 1,
            "title": "Remove the X-Mailer Header From Emails",
            "description": "Stops outgoing emails announcing \"PHPMailer\" and its version in the X-Mailer header, which reveals details about your server and can count against you in spam filters.",
            "category": "email",
            "tags": [
                "email",
                "headers",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'phpmailer_init', function ( $phpmailer ) {\n    $phpmailer->XMailer = ' ';\n} );",
            "requires": [],
            "notes": "PHPMailer omits the header entirely when it is set to a single space."
        },
        {
            "id": "email-site-health-check",
            "version": 1,
            "title": "Email Delivery Check in Site Health",
            "description": "Adds a test to Tools > Site Health that warns when WordPress sends mail with plain PHP mail() instead of SMTP or an API, and when an email has failed recently.",
            "category": "email",
            "tags": [
                "email",
                "site-health",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_mail_failed', function ( $error ) {\n    update_option( 'wpide_mail_last_failure', array( 'time' => time(), 'message' => $error->get_error_message() ), false );\n} );\n\nadd_filter( 'site_status_tests', function ( $tests ) {\n    $tests['direct']['wpide_mail'] = array(\n        'label' => 'Email delivery',\n        'test'  => function () {\n            require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php';\n            $mailer = new PHPMailer\\PHPMailer\\PHPMailer( true );\n            do_action_ref_array( 'phpmailer_init', array( &$mailer ) );\n\n            $api     = has_filter( 'pre_wp_mail' ) > 0;\n            $failure = get_option( 'wpide_mail_last_failure' );\n            $recent  = is_array( $failure ) && $failure['time'] > time() - WEEK_IN_SECONDS;\n\n            $result = array(\n                'label'       => 'Email is sent through SMTP or an email service',\n                'status'      => 'good',\n                'badge'       => array( 'label' => 'Email', 'color' => 'blue' ),\n                'description' => '<p>WordPress emails go through a dedicated mail server, which is the most reliable setup.</p>',\n                'actions'     => '',\n                'test'        => 'wpide_mail',\n            );\n\n            if ( 'smtp' !== $mailer->Mailer && ! $api ) {\n                $result['status']      = 'recommended';\n                $result['label']       = 'Email is sent with PHP mail()';\n                $result['description'] = '<p>Emails sent with PHP mail() often land in spam or are blocked. Configure SMTP or an email API for reliable delivery.</p>';\n            }\n\n            if ( $recent ) {\n                $result['status']       = 'critical';\n                $result['label']        = 'An email failed to send recently';\n                $result['description'] .= '<p>Last failure: ' . esc_html( wp_date( 'Y-m-d H:i', $failure['time'] ) . ' – ' . $failure['message'] ) . '</p>';\n            }\n\n            return $result;\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Failures are recorded from the moment the snippet is active; the warning clears seven days after the last failure."
        },
        {
            "id": "email-alert-content-trashed",
            "version": 1,
            "title": "Email Alert When Posts or Pages Are Trashed",
            "description": "Emails the site admin whenever a post or page is moved to the trash, with who did it and a link to restore it, so accidental or malicious deletions are spotted quickly.",
            "category": "email",
            "tags": [
                "email",
                "alerts",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_trash_types       = array( 'post', 'page' );\n$wpide_trash_skip_admins = false;\n\nadd_action( 'wp_trash_post', function ( $post_id ) use ( $wpide_trash_types, $wpide_trash_skip_admins ) {\n    $post = get_post( $post_id );\n\n    if ( ! $post || ! in_array( $post->post_type, $wpide_trash_types, true ) || 'auto-draft' === $post->post_status ) {\n        return;\n    }\n    if ( $wpide_trash_skip_admins && current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $user = wp_get_current_user();\n    $who  = $user->exists() ? $user->display_name . ' (' . $user->user_login . ')' : 'an automated process';\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] \"%s\" was moved to the trash', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), wp_strip_all_tags( get_the_title( $post ) ) ),\n        sprintf( \"The %s \\\"%s\\\" was trashed by %s on %s.\\n\\nRestore it from the trash: %s\", $post->post_type, wp_strip_all_tags( get_the_title( $post ) ), $who, wp_date( 'Y-m-d H:i' ), admin_url( 'edit.php?post_status=trash&post_type=' . $post->post_type ) )\n    );\n} );",
            "requires": [],
            "notes": "Limit the post types with the list at the top; trashing by administrators can be skipped with $wpide_trash_skip_admins."
        },
        {
            "id": "email-follow-up-after-signup",
            "version": 1,
            "title": "Follow-Up Email a Few Days After Sign-Up",
            "description": "Sends new users a second, friendly email a few days after they register, with tips or links to get started, scheduled automatically with WP-Cron.",
            "category": "email",
            "tags": [
                "email",
                "onboarding",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_followup = array(\n    'days'    => 3,\n    'subject' => 'Getting the most out of {site}',\n    'message' => \"Hi {name},\\n\\nThanks for joining {site} a few days ago! Here are a few things to try next:\\n\\n• Complete your profile\\n• Browse our latest articles\\n• Reply to this email if you have any questions\\n\\nSee you soon!\",\n);\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_followup ) {\n    wp_schedule_single_event( time() + $wpide_followup['days'] * DAY_IN_SECONDS, 'wpide_followup_email', array( (int) $user_id ) );\n} );\n\nadd_action( 'wpide_followup_email', function ( $user_id = 0 ) use ( $wpide_followup ) {\n    $user = get_userdata( (int) $user_id );\n\n    if ( ! $user || ! is_email( $user->user_email ) ) {\n        return;\n    }\n\n    $site    = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    $replace = array( '{name}' => $user->first_name ? $user->first_name : $user->display_name, '{site}' => $site );\n\n    wp_mail( $user->user_email, strtr( $wpide_followup['subject'], $replace ), strtr( $wpide_followup['message'], $replace ) );\n} );",
            "requires": [],
            "notes": "Edit the delay, subject and message at the top ({name} and {site} are replaced); the email is skipped if the account has been deleted meanwhile."
        },
        {
            "id": "email-daily-php-error-summary",
            "version": 1,
            "title": "Daily Email Summary of New PHP Errors",
            "description": "Reads new lines in debug.log once a day and emails a short summary of fatal errors, warnings and notices with how often each occurred, so problems do not go unnoticed.",
            "category": "email",
            "tags": [
                "email",
                "debugging",
                "errors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_errors_to = ''; // Recipient; empty = admin email.\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_php_error_summary' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_php_error_summary' );\n    }\n} );\n\nadd_action( 'wpide_php_error_summary', function () use ( $wpide_errors_to ) {\n    $file = ( defined( 'WP_DEBUG_LOG' ) && is_string( WP_DEBUG_LOG ) && '' !== WP_DEBUG_LOG ) ? WP_DEBUG_LOG : WP_CONTENT_DIR . '/debug.log';\n\n    if ( ! is_readable( $file ) ) {\n        return;\n    }\n\n    $size   = filesize( $file );\n    $offset = (int) get_option( 'wpide_php_error_offset', 0 );\n    if ( $offset > $size ) {\n        $offset = 0; // The log was rotated or cleared.\n    }\n\n    $handle = fopen( $file, 'rb' );\n    if ( ! $handle ) {\n        return;\n    }\n    fseek( $handle, $offset );\n    $chunk = (string) fread( $handle, MB_IN_BYTES );\n    update_option( 'wpide_php_error_offset', ftell( $handle ), false );\n    fclose( $handle );\n\n    $counts = array();\n    foreach ( preg_split( '/\\R/', $chunk ) as $line ) {\n        if ( preg_match( '/PHP (Fatal error|Parse error|Warning|Notice|Deprecated):\\s*(.+?)( in \\/.+? on line \\d+)?$/', $line, $m ) ) {\n            $key            = $m[1] . ': ' . wp_html_excerpt( $m[2] . ( isset( $m[3] ) ? $m[3] : '' ), 300, '…' );\n            $counts[ $key ] = isset( $counts[ $key ] ) ? $counts[ $key ] + 1 : 1;\n        }\n    }\n\n    if ( ! $counts ) {\n        return;\n    }\n\n    arsort( $counts );\n    $lines = array();\n    foreach ( array_slice( $counts, 0, 30, true ) as $message => $count ) {\n        $lines[] = sprintf( '%4dx  %s', $count, $message );\n    }\n\n    $to = is_email( $wpide_errors_to ) ? $wpide_errors_to : get_option( 'admin_email' );\n    wp_mail( $to, sprintf( '[%s] %d distinct PHP error(s) in the last day', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), count( $counts ) ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "Needs WP_DEBUG_LOG enabled, reads at most 1 MB of new log per day and lists the 30 most frequent messages; set the recipient at the top."
        },
        {
            "id": "email-copy-admin-emails-to-slack",
            "version": 1,
            "title": "Copy Admin Notification Emails to Slack",
            "description": "Posts the subject and the start of every email sent to the site admin address into a Slack channel, so the team sees new users, comments and alerts where they already work.",
            "category": "email",
            "tags": [
                "email",
                "slack",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $url   = defined( 'WPIDE_SLACK_WEBHOOK_URL' ) ? (string) WPIDE_SLACK_WEBHOOK_URL : '';\n    $admin = strtolower( (string) get_option( 'admin_email' ) );\n    $to    = strtolower( implode( ',', (array) $atts['to'] ) );\n\n    if ( 0 !== strpos( $url, 'https://hooks.slack.com/' ) || false === strpos( $to, $admin ) ) {\n        return $atts;\n    }\n\n    $escape = function ( $text ) {\n        return str_replace( array( '&', '<', '>' ), array( '&amp;', '&lt;', '&gt;' ), (string) $text );\n    };\n\n    $text = sprintf( \"*%s*\\n%s\", $escape( wp_strip_all_tags( (string) $atts['subject'] ) ), $escape( wp_html_excerpt( trim( wp_strip_all_tags( (string) $atts['message'] ) ), 500, '…' ) ) );\n\n    wp_safe_remote_post( $url, array(\n        'body'     => wp_json_encode( array( 'text' => $text ) ),\n        'headers'  => array( 'Content-Type' => 'application/json' ),\n        'timeout'  => 5,\n        'blocking' => false,\n    ) );\n\n    return $atts;\n}, 900 );",
            "requires": [],
            "notes": "Define WPIDE_SLACK_WEBHOOK_URL in wp-config.php; only emails addressed to the admin email are copied, and they are still sent as usual."
        },
        {
            "id": "email-alert-published-by-others",
            "version": 1,
            "title": "Email the Admin When Someone Else Publishes",
            "description": "Emails the site admin whenever a post or page is published by a user who is not an administrator, so you can review new content as soon as it goes live.",
            "category": "email",
            "tags": [
                "email",
                "alerts",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_publish_alert_types = array( 'post', 'page' );\n\nadd_action( 'transition_post_status', function ( $new, $old, $post ) use ( $wpide_publish_alert_types ) {\n    if ( 'publish' !== $new || 'publish' === $old || ! in_array( $post->post_type, $wpide_publish_alert_types, true ) || wp_is_post_revision( $post ) ) {\n        return;\n    }\n\n    $author = get_userdata( (int) $post->post_author );\n    if ( ! $author || user_can( $author, 'manage_options' ) ) {\n        return;\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] New %s by %s: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $post->post_type, $author->display_name, wp_strip_all_tags( get_the_title( $post ) ) ),\n        sprintf( \"%s just published \\\"%s\\\".\\n\\nView: %s\\nEdit: %s\", $author->display_name, wp_strip_all_tags( get_the_title( $post ) ), get_permalink( $post ), admin_url( 'post.php?post=' . $post->ID . '&action=edit' ) )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Covers posts and pages by default; scheduled posts trigger the email when they actually go live."
        },
        {
            "id": "email-alert-user-deleted",
            "version": 1,
            "title": "Email Alert When a User Account Is Deleted",
            "description": "Emails the site admin when any user account is deleted, including who deleted it and whether their content was reassigned, so removals never go unnoticed.",
            "category": "email",
            "tags": [
                "email",
                "alerts",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'delete_user', function ( $user_id, $reassign = null ) {\n    $user  = get_userdata( $user_id );\n    $actor = wp_get_current_user();\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $reassigned = $reassign ? get_userdata( (int) $reassign ) : false;\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] User deleted: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $user->user_login ),\n        sprintf(\n            \"The account %s <%s> (%s) was deleted by %s on %s.\\nContent: %s\",\n            $user->user_login,\n            $user->user_email,\n            implode( ', ', (array) $user->roles ),\n            $actor->exists() ? $actor->user_login : 'an automated process',\n            wp_date( 'Y-m-d H:i' ),\n            $reassigned ? 'reassigned to ' . $reassigned->user_login : 'deleted with the account'\n        )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "Works for single sites; on multisite network user deletions use a different hook."
        },
        {
            "id": "email-richer-new-user-admin-email",
            "version": 1,
            "title": "More Details in the New User Admin Email",
            "description": "Adds the new user's role, the page they signed up from, their IP address and a link to their profile to the \"New user registration\" email the admin receives.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "registration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_new_user_notification_email_admin', function ( $email, $user ) {\n    $referer = isset( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : '';\n    $ip      = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    $extra = array(\n        'Role: ' . implode( ', ', (array) $user->roles ),\n        'Signed up from: ' . ( $referer ? $referer : 'unknown' ),\n        'IP address: ' . ( $ip ? $ip : 'unknown' ),\n        'Profile: ' . admin_url( 'user-edit.php?user_id=' . $user->ID ),\n    );\n\n    $email['message'] = rtrim( (string) $email['message'] ) . \"\\n\\n\" . implode( \"\\n\", $extra ) . \"\\n\";\n\n    return $email;\n}, 10, 2 );",
            "requires": [],
            "notes": "Remove the IP line if your privacy policy does not cover it."
        },
        {
            "id": "email-retry-failed",
            "version": 1,
            "title": "Retry Emails That Failed to Send",
            "description": "Keeps emails that fail because of a temporary mail server problem and tries to send them again up to three times over the following half hour.",
            "category": "email",
            "tags": [
                "email",
                "reliability",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_retry_state = new stdClass();\n$wpide_retry_state->running = false;\n\nadd_action( 'wp_mail_failed', function ( $error ) use ( $wpide_retry_state ) {\n    $data = (array) $error->get_error_data();\n\n    if ( $wpide_retry_state->running || empty( $data['to'] ) || ! empty( $data['attachments'] ) ) {\n        return;\n    }\n\n    $queue   = (array) get_option( 'wpide_mail_retry', array() );\n    $queue[] = array(\n        'to'       => $data['to'],\n        'subject'  => isset( $data['subject'] ) ? $data['subject'] : '',\n        'message'  => isset( $data['message'] ) ? $data['message'] : '',\n        'headers'  => isset( $data['headers'] ) ? $data['headers'] : array(),\n        'attempts' => 0,\n    );\n    update_option( 'wpide_mail_retry', array_slice( $queue, -50 ), false );\n\n    if ( ! wp_next_scheduled( 'wpide_mail_retry' ) ) {\n        wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, 'wpide_mail_retry' );\n    }\n} );\n\nadd_action( 'wpide_mail_retry', function () use ( $wpide_retry_state ) {\n    $queue = (array) get_option( 'wpide_mail_retry', array() );\n    $left  = array();\n\n    $wpide_retry_state->running = true;\n    foreach ( $queue as $item ) {\n        if ( ! wp_mail( $item['to'], $item['subject'], $item['message'], $item['headers'] ) && ++$item['attempts'] < 3 ) {\n            $left[] = $item;\n        }\n    }\n    $wpide_retry_state->running = false;\n\n    update_option( 'wpide_mail_retry', $left, false );\n\n    if ( $left ) {\n        wp_schedule_single_event( time() + 10 * MINUTE_IN_SECONDS, 'wpide_mail_retry' );\n    }\n} );",
            "requires": [],
            "notes": "Emails with attachments are not retried because the attached files may be gone; the queue holds at most 50 emails."
        },
        {
            "id": "email-send-via-sparkpost",
            "version": 1,
            "title": "Deliver Site Emails With SparkPost",
            "description": "Delivers all WordPress emails through SparkPost's Transmissions API instead of PHP mail or SMTP, for dependable delivery and SparkPost's event reporting.",
            "category": "email",
            "tags": [
                "email",
                "sparkpost",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_SPARKPOST_KEY' ) || ! WPIDE_SPARKPOST_KEY || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $split = function ( $value ) {\n        $value = trim( (string) $value );\n        return preg_match( '/^(.*)<([^>]+)>$/', $value, $m ) ? array( 'name' => trim( $m[1], \" \\\"'\" ), 'email' => trim( $m[2] ) ) : array( 'name' => '', 'email' => $value );\n    };\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n    $from    = $h['from'] ? $split( $h['from'] ) : array( 'name' => apply_filters( 'wp_mail_from_name', 'WordPress' ), 'email' => apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ) );\n\n    $recipients = array();\n    foreach ( array_merge( array_filter( array_map( 'trim', $to ) ), $h['cc'], $h['bcc'] ) as $recipient ) {\n        $recipients[] = array( 'address' => array( 'email' => $split( $recipient )['email'] ) );\n    }\n\n    $content = array( 'from' => array( 'email' => $from['email'], 'name' => $from['name'] ), 'subject' => (string) $atts['subject'] );\n    $content[ $is_html ? 'html' : 'text' ] = (string) $atts['message'];\n    if ( $h['reply-to'] ) {\n        $content['reply_to'] = $h['reply-to'][0];\n    }\n    if ( $h['cc'] ) {\n        $content['headers'] = array( 'CC' => implode( ',', $h['cc'] ) );\n    }\n\n    $host     = defined( 'WPIDE_SPARKPOST_EU' ) && WPIDE_SPARKPOST_EU ? 'api.eu.sparkpost.com' : 'api.sparkpost.com';\n    $response = wp_remote_post( 'https://' . $host . '/api/v1/transmissions', array(\n        'timeout' => 15,\n        'headers' => array( 'Authorization' => WPIDE_SPARKPOST_KEY, 'Content-Type' => 'application/json' ),\n        'body'    => wp_json_encode( array( 'recipients' => $recipients, 'content' => $content ) ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $message = is_wp_error( $response ) ? $response->get_error_message() : 'SparkPost returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $message, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_SPARKPOST_KEY (plus WPIDE_SPARKPOST_EU as true for EU accounts) in wp-config.php and verify the sending domain; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-send-via-mailjet",
            "version": 1,
            "title": "Mailjet Delivery for All WordPress Email",
            "description": "Delivers all WordPress emails through Mailjet's Send API v3.1, a reliable alternative to SMTP that also gives you Mailjet's delivery statistics.",
            "category": "email",
            "tags": [
                "email",
                "mailjet",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! defined( 'WPIDE_MAILJET_KEY' ) || ! defined( 'WPIDE_MAILJET_SECRET' ) || ! empty( $atts['attachments'] ) ) {\n        return $return;\n    }\n\n    $address = function ( $value ) {\n        $value = trim( (string) $value );\n        if ( preg_match( '/^(.*)<([^>]+)>$/', $value, $m ) ) {\n            $name = trim( $m[1], \" \\\"'\" );\n            return $name ? array( 'Email' => trim( $m[2] ), 'Name' => $name ) : array( 'Email' => trim( $m[2] ) );\n        }\n        return array( 'Email' => $value );\n    };\n\n    $lines = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['headers'] ) );\n    $h     = array( 'from' => '', 'reply-to' => array(), 'cc' => array(), 'bcc' => array(), 'content-type' => '' );\n    foreach ( $lines as $line ) {\n        if ( false === strpos( (string) $line, ':' ) ) {\n            continue;\n        }\n        list( $name, $value ) = array_map( 'trim', explode( ':', $line, 2 ) );\n        $name = strtolower( $name );\n        if ( in_array( $name, array( 'reply-to', 'cc', 'bcc' ), true ) ) {\n            $h[ $name ] = array_merge( $h[ $name ], array_filter( array_map( 'trim', explode( ',', $value ) ) ) );\n        } elseif ( isset( $h[ $name ] ) ) {\n            $h[ $name ] = $value;\n        }\n    }\n\n    $to      = is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] );\n    $is_html = false !== stripos( $h['content-type'], 'text/html' ) || 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' );\n\n    $message = array(\n        'From'    => $h['from'] ? $address( $h['from'] ) : array( 'Email' => apply_filters( 'wp_mail_from', get_option( 'admin_email' ) ), 'Name' => apply_filters( 'wp_mail_from_name', 'WordPress' ) ),\n        'To'      => array_map( $address, array_filter( array_map( 'trim', $to ) ) ),\n        'Subject' => (string) $atts['subject'],\n    );\n    $message[ $is_html ? 'HTMLPart' : 'TextPart' ] = (string) $atts['message'];\n    if ( $h['cc'] ) {\n        $message['Cc'] = array_map( $address, $h['cc'] );\n    }\n    if ( $h['bcc'] ) {\n        $message['Bcc'] = array_map( $address, $h['bcc'] );\n    }\n    if ( $h['reply-to'] ) {\n        $message['ReplyTo'] = $address( $h['reply-to'][0] );\n    }\n\n    $response = wp_remote_post( 'https://api.mailjet.com/v3.1/send', array(\n        'timeout' => 15,\n        'headers' => array( 'Authorization' => 'Basic ' . base64_encode( WPIDE_MAILJET_KEY . ':' . WPIDE_MAILJET_SECRET ), 'Content-Type' => 'application/json' ),\n        'body'    => wp_json_encode( array( 'Messages' => array( $message ) ) ),\n    ) );\n\n    $code = (int) wp_remote_retrieve_response_code( $response );\n    if ( $code >= 200 && $code < 300 ) {\n        do_action( 'wp_mail_succeeded', $atts );\n        return true;\n    }\n\n    $error = is_wp_error( $response ) ? $response->get_error_message() : 'Mailjet returned HTTP ' . $code . ': ' . wp_remote_retrieve_body( $response );\n    do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $error, $atts ) );\n\n    return false;\n}, 10, 2 );",
            "requires": [],
            "notes": "Define WPIDE_MAILJET_KEY and WPIDE_MAILJET_SECRET in wp-config.php and send from a sender validated in Mailjet; emails with attachments fall back to the normal mailer."
        },
        {
            "id": "email-alert-admin-password-reset",
            "version": 1,
            "title": "Alert When an Administrator Password Reset Is Requested",
            "description": "Emails the site admin whenever someone requests a password reset for an administrator account, an early warning sign of an attempt to take over the site.",
            "category": "email",
            "tags": [
                "email",
                "security",
                "alerts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'retrieve_password', function ( $user_login ) {\n    $user = get_user_by( 'login', $user_login );\n\n    if ( ! $user || ! user_can( $user, 'manage_options' ) || get_transient( 'wpide_reset_alert_' . $user->ID ) ) {\n        return;\n    }\n\n    set_transient( 'wpide_reset_alert_' . $user->ID, 1, HOUR_IN_SECONDS );\n\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Password reset requested for administrator %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $user->user_login ),\n        sprintf( \"A password reset was requested for the administrator account \\\"%s\\\" on %s from IP %s.\\n\\nIf this was not expected, make sure the account's email inbox is secure; the password has not been changed.\", $user->user_login, wp_date( 'Y-m-d H:i' ), $ip )\n    );\n} );",
            "requires": [],
            "notes": "Sends at most one alert per account per hour; the requester's IP address is included."
        },
        {
            "id": "email-attachment-size-guard",
            "version": 1,
            "title": "Skip Oversized Email Attachments",
            "description": "Removes attachments that would make an email larger than your mail server accepts and notes it in the message, so the email still arrives instead of bouncing.",
            "category": "email",
            "tags": [
                "email",
                "attachments",
                "deliverability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_attachment_limit_mb = 15;\n\nadd_filter( 'wp_mail', function ( $atts ) use ( $wpide_attachment_limit_mb ) {\n    if ( empty( $atts['attachments'] ) ) {\n        return $atts;\n    }\n\n    $files   = is_array( $atts['attachments'] ) ? $atts['attachments'] : explode( \"\\n\", str_replace( \"\\r\\n\", \"\\n\", (string) $atts['attachments'] ) );\n    $budget  = $wpide_attachment_limit_mb * MB_IN_BYTES;\n    $kept    = array();\n    $dropped = array();\n    $used    = 0;\n\n    foreach ( $files as $key => $file ) {\n        $size = is_readable( $file ) ? (int) ceil( filesize( $file ) * 1.37 ) : 0;\n        if ( $used + $size > $budget ) {\n            $dropped[] = basename( $file );\n            continue;\n        }\n        $used        += $size;\n        $kept[ $key ] = $file;\n    }\n\n    if ( $dropped ) {\n        $atts['attachments'] = $kept;\n        $atts['message']    .= \"\\n\\n(\" . count( $dropped ) . ' attachment(s) were too large to send: ' . implode( ', ', $dropped ) . ')';\n    }\n\n    return $atts;\n}, 40 );",
            "requires": [],
            "notes": "Set the limit a little below your provider's maximum (often 10 to 25 MB); base64 encoding makes attachments about a third larger, which the snippet accounts for."
        },
        {
            "id": "email-fix-plain-emails-as-html",
            "version": 1,
            "title": "Fix Plain Emails Sent as HTML",
            "description": "When a plugin switches all emails to HTML, WordPress's own plain emails arrive as one squashed paragraph with broken links; this adds line breaks and clickable links back.",
            "category": "email",
            "tags": [
                "email",
                "html",
                "fixes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $headers = is_array( $atts['headers'] ) ? implode( \"\\n\", $atts['headers'] ) : (string) $atts['headers'];\n    $is_html = false !== stripos( $headers, 'text/html' ) || ( false === stripos( $headers, 'content-type' ) && 'text/html' === apply_filters( 'wp_mail_content_type', 'text/plain' ) );\n    $message = (string) $atts['message'];\n    $probe   = preg_replace( '/<https?:[^>]+>/', '', $message );\n\n    if ( ! $is_html || $probe !== strip_tags( $probe ) ) {\n        return $atts;\n    }\n\n    $message         = preg_replace( '/<(https?:[^>]+)>/', '$1', $message );\n    $atts['message'] = make_clickable( nl2br( esc_html( $message ) ) );\n\n    return $atts;\n}, 15 );",
            "requires": [],
            "notes": "Only touches emails sent as HTML whose body contains no HTML tags, such as the password reset and new user emails."
        },
        {
            "id": "email-recipient-cap",
            "version": 1,
            "title": "Limit the Number of Recipients per Email",
            "description": "Refuses to send any single email to more than a set number of recipients across To, CC and BCC, which stops a compromised form or plugin from blasting your contacts.",
            "category": "email",
            "tags": [
                "email",
                "security",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_max_recipients = 20;\n\nadd_filter( 'pre_wp_mail', function ( $return, $atts ) use ( $wpide_max_recipients ) {\n    if ( null !== $return ) {\n        return $return;\n    }\n\n    $count   = count( array_filter( is_array( $atts['to'] ) ? $atts['to'] : explode( ',', (string) $atts['to'] ) ) );\n    $headers = is_array( $atts['headers'] ) ? $atts['headers'] : explode( \"\\n\", (string) $atts['headers'] );\n\n    foreach ( $headers as $line ) {\n        if ( preg_match( '/^\\s*(cc|bcc)\\s*:(.*)$/i', (string) $line, $m ) ) {\n            $count += count( array_filter( array_map( 'trim', explode( ',', $m[2] ) ) ) );\n        }\n    }\n\n    if ( $count > $wpide_max_recipients ) {\n        do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', sprintf( 'Blocked: %d recipients exceeds the limit of %d.', $count, $wpide_max_recipients ), $atts ) );\n        return false;\n    }\n\n    return $return;\n}, 2, 2 );",
            "requires": [],
            "notes": "Raise the cap if you legitimately email larger groups; blocked emails are reported as failures."
        },
        {
            "id": "email-alert-theme-switch",
            "version": 1,
            "title": "Email Alert When the Theme Is Changed",
            "description": "Emails the site admin whenever someone switches the active theme, with who did it and the old and new theme, so unexpected design changes are caught immediately.",
            "category": "email",
            "tags": [
                "email",
                "alerts",
                "themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'switch_theme', function ( $new_name, $new_theme, $old_theme ) {\n    $user = wp_get_current_user();\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Theme changed to %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $new_name ),\n        sprintf(\n            \"The active theme was changed from \\\"%s\\\" to \\\"%s\\\" by %s on %s.\\n\\nThemes: %s\",\n            $old_theme instanceof WP_Theme ? $old_theme->get( 'Name' ) : 'unknown',\n            $new_name,\n            $user->exists() ? $user->user_login : 'an automated process',\n            wp_date( 'Y-m-d H:i' ),\n            admin_url( 'themes.php' )\n        )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Theme updates do not trigger it, only switching to a different theme."
        },
        {
            "id": "email-alert-key-settings-changed",
            "version": 1,
            "title": "Email Alert When Key Site Settings Change",
            "description": "Emails the site admin when important settings change, such as the site address, admin email, open registration or the default role for new users, a common sign of a hacked site.",
            "category": "email",
            "tags": [
                "email",
                "security",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Option name => label.\n$wpide_watched_options = array(\n    'siteurl'           => 'WordPress address',\n    'home'              => 'Site address',\n    'admin_email'       => 'Admin email',\n    'users_can_register' => 'Anyone can register',\n    'default_role'      => 'New user default role',\n    'blog_public'       => 'Search engine visibility',\n);\n\nadd_action( 'updated_option', function ( $option, $old, $new ) use ( $wpide_watched_options ) {\n    if ( ! isset( $wpide_watched_options[ $option ] ) || $old === $new ) {\n        return;\n    }\n\n    $user = wp_get_current_user();\n    $to   = 'admin_email' === $option ? array_filter( array( (string) $old, (string) $new ), 'is_email' ) : get_option( 'admin_email' );\n\n    wp_mail(\n        $to,\n        sprintf( '[%s] Setting changed: %s', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), $wpide_watched_options[ $option ] ),\n        sprintf(\n            \"\\\"%s\\\" was changed by %s on %s.\\n\\nOld value: %s\\nNew value: %s\",\n            $wpide_watched_options[ $option ],\n            $user->exists() ? $user->user_login : 'an automated process or WP-CLI',\n            wp_date( 'Y-m-d H:i' ),\n            is_scalar( $old ) ? (string) $old : wp_json_encode( $old ),\n            is_scalar( $new ) ? (string) $new : wp_json_encode( $new )\n        )\n    );\n}, 10, 3 );",
            "requires": [],
            "notes": "Edit the watched options at the top; each change is reported with the old and new value and the user who made it."
        },
        {
            "id": "email-new-post-to-subscribers",
            "version": 1,
            "title": "Email Subscribers When a New Post Is Published",
            "description": "Emails all users with the Subscriber role a short announcement with the title, excerpt and link whenever a new blog post is published, sent in small batches to protect your mail server.",
            "category": "email",
            "tags": [
                "email",
                "newsletter",
                "subscribers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_announce = array(\n    'role'       => 'subscriber',\n    'post_type'  => 'post',\n    'batch_size' => 50,\n);\n\nadd_action( 'transition_post_status', function ( $new, $old, $post ) use ( $wpide_announce ) {\n    if ( 'publish' !== $new || 'publish' === $old || $wpide_announce['post_type'] !== $post->post_type || get_post_meta( $post->ID, '_wpide_announced', true ) ) {\n        return;\n    }\n\n    update_post_meta( $post->ID, '_wpide_announced', time() );\n    wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'wpide_announce_batch', array( (int) $post->ID, 0 ) );\n}, 10, 3 );\n\nadd_action( 'wpide_announce_batch', function ( $post_id = 0, $offset = 0 ) use ( $wpide_announce ) {\n    $post = get_post( (int) $post_id );\n    if ( ! $post || 'publish' !== $post->post_status ) {\n        return;\n    }\n\n    $emails = get_users( array( 'role' => $wpide_announce['role'], 'fields' => 'user_email', 'number' => $wpide_announce['batch_size'], 'offset' => (int) $offset, 'orderby' => 'ID' ) );\n    if ( ! $emails ) {\n        return;\n    }\n\n    $site    = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    $excerpt = wp_trim_words( wp_strip_all_tags( $post->post_excerpt ? $post->post_excerpt : strip_shortcodes( $post->post_content ) ), 50 );\n    $headers = array( 'Bcc: ' . implode( ',', array_filter( $emails, 'is_email' ) ) );\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] New post: %s', $site, wp_strip_all_tags( get_the_title( $post ) ) ), $excerpt . \"\\n\\nRead more: \" . get_permalink( $post ) . \"\\n\\nYou receive this because you have an account on $site. Update your preferences: \" . admin_url( 'profile.php' ), $headers );\n\n    if ( count( $emails ) === $wpide_announce['batch_size'] ) {\n        wp_schedule_single_event( time() + 2 * MINUTE_IN_SECONDS, 'wpide_announce_batch', array( (int) $post_id, (int) $offset + $wpide_announce['batch_size'] ) );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Sends 50 BCC recipients per batch every two minutes (change the role, batch size or post type at the top); use a proper newsletter service for large lists."
        },
        {
            "id": "email-bulk-email-by-role",
            "version": 1,
            "title": "Email All Users of a Role From the Admin",
            "description": "Adds Users > Email Users, where administrators can write a plain-text message and send it to every user with a chosen role, delivered in batches in the background.",
            "category": "email",
            "tags": [
                "email",
                "users",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_users_page( 'Email Users', 'Email Users', 'manage_options', 'wpide-email-users', function () {\n        $sent = false;\n\n        if ( isset( $_POST['wpide_bulk_subject'] ) && check_admin_referer( 'wpide_bulk_email' ) && current_user_can( 'manage_options' ) ) {\n            $role    = sanitize_key( wp_unslash( isset( $_POST['wpide_bulk_role'] ) ? $_POST['wpide_bulk_role'] : '' ) );\n            $subject = sanitize_text_field( wp_unslash( $_POST['wpide_bulk_subject'] ) );\n            $message = sanitize_textarea_field( wp_unslash( isset( $_POST['wpide_bulk_message'] ) ? $_POST['wpide_bulk_message'] : '' ) );\n\n            if ( get_role( $role ) && $subject && $message ) {\n                $job = 'wpide_bulk_' . wp_generate_password( 8, false );\n                set_transient( $job, array( 'role' => $role, 'subject' => $subject, 'message' => $message ), DAY_IN_SECONDS );\n                wp_schedule_single_event( time(), 'wpide_bulk_email_batch', array( $job, 0 ) );\n                $sent = true;\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Email Users</h1>';\n        if ( $sent ) {\n            echo '<div class=\"notice notice-success\"><p>Your message is being sent in the background.</p></div>';\n        }\n        echo '<form method=\"post\">';\n        wp_nonce_field( 'wpide_bulk_email' );\n        echo '<table class=\"form-table\"><tr><th><label for=\"wpide_bulk_role\">Send to</label></th><td><select name=\"wpide_bulk_role\" id=\"wpide_bulk_role\">';\n        wp_dropdown_roles( 'subscriber' );\n        echo '</select></td></tr><tr><th><label for=\"wpide_bulk_subject\">Subject</label></th><td><input name=\"wpide_bulk_subject\" id=\"wpide_bulk_subject\" class=\"regular-text\" required></td></tr>';\n        echo '<tr><th><label for=\"wpide_bulk_message\">Message</label></th><td><textarea name=\"wpide_bulk_message\" id=\"wpide_bulk_message\" rows=\"10\" class=\"large-text\" required></textarea></td></tr></table>';\n        submit_button( 'Send' );\n        echo '</form></div>';\n    } );\n} );\n\nadd_action( 'wpide_bulk_email_batch', function ( $job = '', $offset = 0 ) {\n    $data = get_transient( (string) $job );\n    if ( ! is_array( $data ) ) {\n        return;\n    }\n\n    $emails = get_users( array( 'role' => $data['role'], 'fields' => 'user_email', 'number' => 40, 'offset' => (int) $offset, 'orderby' => 'ID' ) );\n    if ( ! $emails ) {\n        delete_transient( (string) $job );\n        return;\n    }\n\n    wp_mail( get_option( 'admin_email' ), $data['subject'], $data['message'], array( 'Bcc: ' . implode( ',', array_filter( $emails, 'is_email' ) ) ) );\n    wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'wpide_bulk_email_batch', array( (string) $job, (int) $offset + 40 ) );\n}, 10, 2 );",
            "requires": [],
            "notes": "Messages go out as BCC batches of 40 every minute through WP-Cron; recipients' addresses are never shown to each other."
        },
        {
            "id": "email-account-email-subjects",
            "version": 1,
            "title": "Friendlier Subjects for Account Emails",
            "description": "Gives WordPress's new account, password changed and email address changed emails friendlier subjects of your own, without touching the rest of the message.",
            "category": "email",
            "tags": [
                "email",
                "subject",
                "copy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_core_subjects = array(\n    'new_user'         => 'Welcome to %s – set up your account',\n    'password_changed' => 'Your %s password was changed',\n    'email_changed'    => 'Your %s email address was changed',\n);\n\n$wpide_site_name = function () {\n    return wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES );\n};\n\nadd_filter( 'wp_new_user_notification_email', function ( $email ) use ( $wpide_core_subjects, $wpide_site_name ) {\n    if ( isset( $wpide_core_subjects['new_user'] ) ) {\n        $email['subject'] = sprintf( $wpide_core_subjects['new_user'], $wpide_site_name() );\n    }\n    return $email;\n} );\n\nadd_filter( 'password_change_email', function ( $email ) use ( $wpide_core_subjects, $wpide_site_name ) {\n    if ( isset( $wpide_core_subjects['password_changed'] ) ) {\n        $email['subject'] = sprintf( $wpide_core_subjects['password_changed'], $wpide_site_name() );\n    }\n    return $email;\n} );\n\nadd_filter( 'email_change_email', function ( $email ) use ( $wpide_core_subjects, $wpide_site_name ) {\n    if ( isset( $wpide_core_subjects['email_changed'] ) ) {\n        $email['subject'] = sprintf( $wpide_core_subjects['email_changed'], $wpide_site_name() );\n    }\n    return $email;\n} );",
            "requires": [],
            "notes": "Use %s for the site name; remove a line to keep WordPress's default subject for that email."
        },
        {
            "id": "email-low-disk-space-alert",
            "version": 1,
            "title": "Email Alert When Disk Space Runs Low",
            "description": "Checks free disk space on the server twice a day and emails the admin when it drops below your threshold, before uploads, backups and the database start failing.",
            "category": "email",
            "tags": [
                "email",
                "monitoring",
                "server"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_min_free_gb = 2;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_disk_space_check' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'wpide_disk_space_check' );\n    }\n} );\n\nadd_action( 'wpide_disk_space_check', function () use ( $wpide_min_free_gb ) {\n    if ( ! function_exists( 'disk_free_space' ) || get_transient( 'wpide_disk_alert_sent' ) ) {\n        return;\n    }\n\n    $free = @disk_free_space( ABSPATH ); // phpcs:ignore WordPress.PHP.NoSilencedErrors\n    if ( false === $free || $free >= $wpide_min_free_gb * GB_IN_BYTES ) {\n        return;\n    }\n\n    set_transient( 'wpide_disk_alert_sent', 1, DAY_IN_SECONDS );\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Low disk space: %s free', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), size_format( $free ) ),\n        sprintf( \"The server hosting %s has only %s of free disk space left (alert threshold: %d GB).\\n\\nClear old backups, logs or unused media, or ask your host for more space.\", home_url(), size_format( $free ), $wpide_min_free_gb )\n    );\n} );",
            "requires": [],
            "notes": "Does nothing on hosts that disable disk_free_space(), and repeats the alert at most once a day while space stays low."
        },
        {
            "id": "email-alert-cron-stalled",
            "version": 1,
            "title": "Email Alert When WP-Cron Stops Running",
            "description": "Warns the admin by email when scheduled tasks are more than an hour overdue, so you notice before scheduled posts, digests and backups silently stop.",
            "category": "email",
            "tags": [
                "email",
                "cron",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cron_late_minutes = 60;\n\nadd_action( 'admin_init', function () use ( $wpide_cron_late_minutes ) {\n    if ( wp_doing_ajax() || ! current_user_can( 'manage_options' ) || get_transient( 'wpide_cron_checked' ) ) {\n        return;\n    }\n    set_transient( 'wpide_cron_checked', 1, HOUR_IN_SECONDS );\n\n    $crons = _get_cron_array();\n    if ( ! is_array( $crons ) || ! $crons ) {\n        return;\n    }\n\n    $oldest = (int) min( array_keys( $crons ) );\n    $late   = time() - $oldest;\n\n    if ( $late < $wpide_cron_late_minutes * MINUTE_IN_SECONDS || get_transient( 'wpide_cron_alert_sent' ) ) {\n        return;\n    }\n\n    set_transient( 'wpide_cron_alert_sent', 1, DAY_IN_SECONDS );\n    $hooks = array_keys( (array) $crons[ $oldest ] );\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Scheduled tasks are not running', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf( \"The oldest scheduled task (%s) is %s overdue, which means WP-Cron is not running.\\n\\nCheck that DISABLE_WP_CRON is not set without a real server cron job, and that your site can make requests to itself.\", implode( ', ', $hooks ), human_time_diff( $oldest ) )\n    );\n} );",
            "requires": [],
            "notes": "The check runs when an administrator loads the admin, so it works even when cron itself is broken; at most one alert per day."
        },
        {
            "id": "form-shortcode-styles",
            "version": 1,
            "title": "Styles for the WPIDE Form Shortcodes",
            "description": "Gives the contact, quote, newsletter and other WPIDE form shortcodes a clean, consistent look with full-width fields, clear notices and a solid button that follows your theme colors.",
            "category": "forms",
            "tags": [
                "forms",
                "styling",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".wpide-form {\n    --wpide-form-accent: currentColor;\n    --wpide-form-border: rgba(127, 127, 127, 0.45);\n    --wpide-form-radius: 8px;\n    max-width: 640px;\n}\n\n.wpide-form label {\n    font-weight: 600;\n}\n\n.wpide-form input:not([type=\"checkbox\"]):not([type=\"radio\"]):not([type=\"hidden\"]),\n.wpide-form select,\n.wpide-form textarea {\n    width: 100%;\n    box-sizing: border-box;\n    margin-top: 0.3em;\n    padding: 0.7em 0.9em;\n    border: 1px solid var(--wpide-form-border);\n    border-radius: var(--wpide-form-radius);\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    font-size: max(16px, 1em);\n}\n\n.wpide-form input:focus-visible,\n.wpide-form select:focus-visible,\n.wpide-form textarea:focus-visible {\n    outline: 2px solid var(--wpide-form-accent);\n    outline-offset: 2px;\n}\n\n.wpide-form button[type=\"submit\"] {\n    padding: 0.75em 1.6em;\n    border: 2px solid var(--wpide-form-accent);\n    border-radius: var(--wpide-form-radius);\n    background: transparent;\n    color: inherit;\n    font: inherit;\n    font-weight: 600;\n    cursor: pointer;\n}\n\n.wpide-form button[type=\"submit\"]:hover {\n    background: rgba(127, 127, 127, 0.12);\n}\n\n.wpide-form .wpide-form-notice {\n    padding: 0.8em 1em;\n    border-left: 4px solid #2e7d32;\n    border-radius: 4px;\n    background: rgba(46, 125, 50, 0.08);\n}\n\n.wpide-form .wpide-form-error {\n    border-left-color: #c62828;\n    background: rgba(198, 40, 40, 0.08);\n}\n\n.wpide-newsletter form {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5em;\n}\n\n.wpide-newsletter input[type=\"email\"] {\n    flex: 1 1 220px;\n    margin-top: 0;\n}",
            "requires": [],
            "notes": "Targets the .wpide-form wrapper used by the library's form shortcodes; change the custom properties at the top."
        },
        {
            "id": "form-registration-screen-messages",
            "version": 1,
            "title": "Friendlier Messages on the Registration Screens",
            "description": "Explains what happens next above the WordPress registration form and replaces the terse \"Registration complete\" notice with clearer instructions about the confirmation email.",
            "category": "forms",
            "tags": [
                "registration",
                "login",
                "messages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_register_intro = 'Create your free account. We will email you a link to set your password, so use an address you can access right now.';\n$wpide_register_done  = 'Almost there! Check your inbox for an email with a link to set your password. It can take a few minutes; look in your spam folder too.';\n\nadd_filter( 'login_message', function ( $message ) use ( $wpide_register_intro ) {\n    $action = isset( $_REQUEST['action'] ) ? sanitize_key( $_REQUEST['action'] ) : '';\n\n    return 'register' === $action ? '<p class=\"message register\">' . esc_html( $wpide_register_intro ) . '</p>' : $message;\n} );\n\nadd_filter( 'wp_login_errors', function ( $errors ) use ( $wpide_register_done ) {\n    if ( $errors instanceof WP_Error && isset( $_GET['checkemail'] ) && 'registered' === $_GET['checkemail'] && $errors->get_error_code() ) {\n        $errors->remove( 'registered' );\n        $errors->add( 'registered', esc_html( $wpide_register_done ), 'message' );\n    }\n\n    return $errors;\n} );",
            "requires": [],
            "notes": "Edit both texts at the top; they appear on wp-login.php?action=register and right after registering."
        },
        {
            "id": "form-comment-title-field",
            "version": 1,
            "title": "Optional Title Field for Comments",
            "description": "Adds an optional \"Title\" field to the comment form and shows it as a heading above the comment, handy for reviews and longer discussions.",
            "category": "forms",
            "tags": [
                "comments",
                "fields",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_field_comment', function ( $field ) {\n    return '<p class=\"comment-form-title\"><label for=\"wpide-comment-title\">Title (optional)</label><input type=\"text\" id=\"wpide-comment-title\" name=\"wpide_comment_title\" maxlength=\"100\"></p>' . $field;\n} );\n\nadd_action( 'comment_post', function ( $comment_id ) {\n    $title = isset( $_POST['wpide_comment_title'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_comment_title'] ) ) : '';\n\n    if ( '' !== $title ) {\n        add_comment_meta( $comment_id, 'wpide_comment_title', mb_substr( $title, 0, 100 ), true );\n    }\n} );\n\nadd_filter( 'comment_text', function ( $text, $comment = null ) {\n    if ( ! $comment || is_admin() ) {\n        return $text;\n    }\n\n    $title = (string) get_comment_meta( $comment->comment_ID, 'wpide_comment_title', true );\n\n    return '' !== $title ? '<p class=\"comment-title\"><strong>' . esc_html( $title ) . '</strong></p>' . $text : $text;\n}, 9, 2 );",
            "requires": [],
            "notes": "Titles are limited to 100 characters and stored as comment meta named wpide_comment_title."
        },
        {
            "id": "cf7-sender-on-own-domain",
            "version": 1,
            "title": "Send Contact Form 7 Emails From Your Own Domain",
            "description": "When a Contact Form 7 form is set to send \"from\" the visitor's address, switches the sender to your own domain and moves the visitor's address to Reply-To, so emails stop landing in spam.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "deliverability",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_sender = ''; // e.g. 'Website <hello@yourdomain.com>'; empty = wordpress@yourdomain.\n\nadd_filter( 'wpcf7_mail_components', function ( $components ) use ( $wpide_cf7_sender ) {\n    $site = preg_replace( '/^www\\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n\n    if ( ! preg_match( '/([^<\\s]+@[^>\\s]+)/', (string) $components['sender'], $m ) ) {\n        return $components;\n    }\n\n    $address = strtolower( $m[1] );\n    $domain  = (string) substr( strrchr( $address, '@' ), 1 );\n\n    if ( '' === $site || $domain === $site || substr( $domain, -strlen( '.' . $site ) ) === '.' . $site ) {\n        return $components;\n    }\n\n    if ( false === stripos( (string) $components['additional_headers'], 'Reply-To:' ) && is_email( $address ) ) {\n        $components['additional_headers'] = trim( $components['additional_headers'] . \"\\nReply-To: \" . $address );\n    }\n\n    $components['sender'] = $wpide_cf7_sender ? $wpide_cf7_sender : wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) . ' <wordpress@' . $site . '>';\n\n    return $components;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Set the sender address at the top or leave it empty to use wordpress@ your domain; replying still goes to the visitor."
        },
        {
            "id": "gf-from-on-own-domain",
            "version": 1,
            "title": "Send Gravity Forms Notifications From Your Own Domain",
            "description": "When a Gravity Forms notification uses the visitor's email or another outside domain as the From address, switches it to your domain and keeps that address as Reply-To.",
            "category": "forms",
            "tags": [
                "gravity-forms",
                "deliverability",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_gf_from = ''; // e.g. 'hello@yourdomain.com'; empty = wordpress@yourdomain.\n\nadd_filter( 'gform_notification', function ( $notification, $form, $entry ) use ( $wpide_gf_from ) {\n    $site = preg_replace( '/^www\\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) );\n    $from = trim( GFCommon::replace_variables( (string) rgar( $notification, 'from' ), $form, $entry, false, false, false, 'text' ) );\n\n    if ( '' === $site || ! is_email( $from ) ) {\n        return $notification;\n    }\n\n    $domain = strtolower( (string) substr( strrchr( $from, '@' ), 1 ) );\n    if ( $domain === $site || substr( $domain, -strlen( '.' . $site ) ) === '.' . $site ) {\n        return $notification;\n    }\n\n    if ( '' === trim( (string) rgar( $notification, 'replyTo' ) ) ) {\n        $notification['replyTo'] = $from;\n    }\n    $notification['from'] = is_email( $wpide_gf_from ) ? $wpide_gf_from : 'wordpress@' . $site;\n\n    return $notification;\n}, 20, 3 );",
            "requires": [
                "gravityforms"
            ],
            "notes": "Merge tags in the From field are resolved first; set your own sender at the top or leave it empty for wordpress@ your domain."
        },
        {
            "id": "cf7-one-submission-per-user",
            "version": 1,
            "title": "One Contact Form 7 Submission per Logged-In User",
            "description": "Lets each logged-in member send the Contact Form 7 forms you choose only once, for surveys, applications or votes, and shows a message instead of the form afterwards.",
            "category": "forms",
            "tags": [
                "contact-form-7",
                "members",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cf7_once_forms   = array(); // Form IDs, e.g. array( 123 ).\n$wpide_cf7_once_message = 'You have already submitted this form. Thank you!';\n\nadd_action( 'wpcf7_mail_sent', function ( $contact_form ) use ( $wpide_cf7_once_forms ) {\n    if ( is_user_logged_in() && in_array( (int) $contact_form->id(), array_map( 'intval', $wpide_cf7_once_forms ), true ) ) {\n        update_user_meta( get_current_user_id(), '_wpide_cf7_done_' . (int) $contact_form->id(), time() );\n    }\n} );\n\nadd_filter( 'wpcf7_spam', function ( $spam ) use ( $wpide_cf7_once_forms ) {\n    $form = function_exists( 'wpcf7_get_current_contact_form' ) ? wpcf7_get_current_contact_form() : null;\n\n    if ( $form && is_user_logged_in() && in_array( (int) $form->id(), array_map( 'intval', $wpide_cf7_once_forms ), true ) && get_user_meta( get_current_user_id(), '_wpide_cf7_done_' . (int) $form->id(), true ) ) {\n        return true;\n    }\n\n    return $spam;\n}, 40 );\n\nadd_filter( 'do_shortcode_tag', function ( $output, $tag, $attr ) use ( $wpide_cf7_once_forms, $wpide_cf7_once_message ) {\n    if ( 'contact-form-7' !== $tag || empty( $attr['id'] ) || ! is_user_logged_in() || ! function_exists( 'wpcf7_contact_form' ) ) {\n        return $output;\n    }\n\n    $form = wpcf7_contact_form( $attr['id'] );\n    $id   = $form ? (int) $form->id() : 0;\n\n    if ( in_array( $id, array_map( 'intval', $wpide_cf7_once_forms ), true ) && get_user_meta( get_current_user_id(), '_wpide_cf7_done_' . $id, true ) ) {\n        return '<p class=\"wpide-form-closed\" role=\"status\">' . esc_html( $wpide_cf7_once_message ) . '</p>';\n    }\n\n    return $output;\n}, 10, 3 );",
            "requires": [
                "cf7"
            ],
            "notes": "List the form IDs at the top and combine with CF7's \"subscribers_only: true\" additional setting so only logged-in users can use the form."
        },
        {
            "id": "fluentforms-one-submission-per-user",
            "version": 1,
            "title": "One Fluent Forms Submission per Logged-In User",
            "description": "Lets each logged-in member submit the Fluent Forms forms you choose only once, which suits surveys, votes and applications, with a clear message on a second attempt.",
            "category": "forms",
            "tags": [
                "fluent-forms",
                "members",
                "limits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_ff_once_forms   = array(); // Form IDs, e.g. array( 1 ).\n$wpide_ff_once_message = 'You have already submitted this form. Thank you!';\n\nadd_filter( 'fluentform/validation_errors', function ( $errors, $data, $form ) use ( $wpide_ff_once_forms, $wpide_ff_once_message ) {\n    if ( is_user_logged_in() && in_array( (int) $form->id, array_map( 'intval', $wpide_ff_once_forms ), true ) && get_user_meta( get_current_user_id(), '_wpide_ff_done_' . (int) $form->id, true ) ) {\n        $errors['_wpide_once'] = array( $wpide_ff_once_message );\n    }\n\n    return $errors;\n}, 10, 3 );\n\nadd_action( 'fluentform/submission_inserted', function ( $entry_id, $data, $form ) use ( $wpide_ff_once_forms ) {\n    if ( is_user_logged_in() && in_array( (int) $form->id, array_map( 'intval', $wpide_ff_once_forms ), true ) ) {\n        update_user_meta( get_current_user_id(), '_wpide_ff_done_' . (int) $form->id, time() );\n    }\n}, 10, 3 );",
            "requires": [
                "fluentforms"
            ],
            "notes": "List the form IDs at the top; submissions are remembered in user meta and logged-out visitors are not limited."
        },
        {
            "id": "lost-password-honeypot",
            "version": 1,
            "title": "Honeypot Trap on the Lost Password Form",
            "description": "Adds a hidden field to the lost password form that only bots fill in and silently ignores those requests, so bots cannot flood your users with reset emails.",
            "category": "security",
            "tags": [
                "login",
                "password",
                "honeypot"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'lostpassword_form', function () {\n    echo '<p style=\"position:absolute;left:-9999px;\" aria-hidden=\"true\"><label>Company website <input type=\"text\" name=\"wpide_lp_trap\" value=\"\" tabindex=\"-1\" autocomplete=\"off\"></label></p>';\n} );\n\nadd_action( 'lostpassword_post', function ( $errors ) {\n    if ( empty( $_POST['wpide_lp_trap'] ) ) {\n        return;\n    }\n\n    wp_safe_redirect( add_query_arg( 'checkemail', 'confirm', wp_login_url() ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Real users never see the field; bot requests get the normal \"check your email\" screen but no email is sent."
        },
        {
            "id": "email-html-preheader",
            "version": 1,
            "title": "Inbox Preview Text for HTML Emails",
            "description": "Adds hidden preview text to HTML emails, the short line mail apps show next to the subject, taken from the first sentence of the message so inboxes no longer show stray template text.",
            "category": "email",
            "tags": [
                "email",
                "html",
                "inbox"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_mail', function ( $atts ) {\n    $message = (string) $atts['message'];\n\n    if ( ! preg_match( '/<body[^>]*>/i', $message, $body, PREG_OFFSET_CAPTURE ) || false !== stripos( $message, 'preheader' ) ) {\n        return $atts;\n    }\n\n    $text = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( substr( $message, $body[0][1] ) ) ) );\n    if ( '' === $text ) {\n        return $atts;\n    }\n\n    $first = preg_split( '/(?<=[.!?])\\s/', $text, 2 );\n    $hint  = wp_html_excerpt( $first[0], 110, '…' );\n    $span  = '<div class=\"preheader\" style=\"display:none!important;visibility:hidden;opacity:0;color:transparent;height:0;width:0;max-height:0;max-width:0;overflow:hidden;mso-hide:all;\">' . esc_html( $hint ) . str_repeat( '&#847;&zwnj;&nbsp;', 30 ) . '</div>';\n\n    $insert          = $body[0][1] + strlen( $body[0][0] );\n    $atts['message'] = substr( $message, 0, $insert ) . $span . substr( $message, $insert );\n\n    return $atts;\n}, 60 );",
            "requires": [],
            "notes": "Only HTML emails with a body tag are changed, and only when they do not already contain preview text."
        },
        {
            "id": "form-comment-newsletter-optin",
            "version": 1,
            "title": "Newsletter Opt-In Checkbox on the Comment Form",
            "description": "Adds an unticked \"Send me the newsletter\" checkbox to the comment form and lets you download the email addresses of commenters who opted in as a CSV from Tools.",
            "category": "forms",
            "tags": [
                "comments",
                "newsletter",
                "export"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'comment_form_submit_field', function ( $field ) {\n    return '<p class=\"comment-form-newsletter\"><input type=\"checkbox\" id=\"wpide-comment-newsletter\" name=\"wpide_comment_newsletter\" value=\"1\"> <label for=\"wpide-comment-newsletter\">Send me the newsletter</label></p>' . $field;\n}, 5 );\n\nadd_action( 'comment_post', function ( $comment_id ) {\n    if ( ! empty( $_POST['wpide_comment_newsletter'] ) ) {\n        add_comment_meta( $comment_id, 'wpide_newsletter_optin', gmdate( 'Y-m-d H:i:s' ), true );\n    }\n} );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Comment Opt-Ins', 'Comment Opt-Ins', 'manage_options', 'wpide-comment-optins', function () {\n        global $wpdb;\n        $count = (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(DISTINCT c.comment_author_email) FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} m ON m.comment_id = c.comment_ID WHERE m.meta_key = %s AND c.comment_approved = '1'\", 'wpide_newsletter_optin' ) );\n\n        echo '<div class=\"wrap\"><h1>Comment Opt-Ins</h1>';\n        printf( '<p>%d commenter(s) asked to receive the newsletter.</p>', (int) $count );\n        printf( '<p><a class=\"button button-primary\" href=\"%s\">Download CSV</a></p></div>', esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_comment_optins' ), 'wpide_comment_optins' ) ) );\n    } );\n} );\n\nadd_action( 'admin_post_wpide_comment_optins', function () {\n    global $wpdb;\n\n    if ( ! current_user_can( 'manage_options' ) || ! check_admin_referer( 'wpide_comment_optins' ) ) {\n        wp_die( 'Not allowed.' );\n    }\n\n    $rows = $wpdb->get_results( $wpdb->prepare( \"SELECT c.comment_author_email AS email, MAX(c.comment_author) AS name, MAX(m.meta_value) AS opted FROM {$wpdb->comments} c INNER JOIN {$wpdb->commentmeta} m ON m.comment_id = c.comment_ID WHERE m.meta_key = %s AND c.comment_approved = '1' GROUP BY c.comment_author_email\", 'wpide_newsletter_optin' ) );\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=comment-optins-' . gmdate( 'Y-m-d' ) . '.csv' );\n\n    $out = fopen( 'php://output', 'w' );\n    fputcsv( $out, array( 'email', 'name', 'opted_in' ) );\n    foreach ( $rows as $row ) {\n        fputcsv( $out, array( ltrim( $row->email, '=+-@' ), preg_replace( '/^[=+\\-@]+/', '', (string) $row->name ), $row->opted ) );\n    }\n    fclose( $out );\n    exit;\n} );",
            "requires": [],
            "notes": "Only commenters who tick the box are exported (from Tools > Comment Opt-Ins); import the CSV into your mailing tool, which should handle confirmation."
        },
        {
            "id": "a11y-skip-links-menu",
            "version": 1,
            "title": "Skip Links to Navigation, Search and Footer",
            "description": "Adds a small set of keyboard skip links (main content, main menu, search and footer) that appear on the first Tab press. Links whose target is missing on a page are removed automatically.",
            "category": "accessibility",
            "tags": [
                "skip-links",
                "keyboard",
                "navigation"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "site_wide_body",
            "insert_number": 1,
            "code": "<nav class=\"wpide-skiplinks\" aria-label=\"<?php echo esc_attr__( 'Skip links' ); ?>\">\n    <a href=\"#\" data-target=\"main, #main, .site-main, #content\">Skip to main content</a>\n    <a href=\"#\" data-target=\"#site-navigation, nav.main-navigation, header nav, .wp-block-navigation\">Skip to main menu</a>\n    <a href=\"#\" data-target=\"form[role=search] input[type=search], .search-field, .wp-block-search__input\">Skip to search</a>\n    <a href=\"#\" data-target=\"footer, #colophon, .site-footer\">Skip to footer</a>\n</nav>\n<style>\n.wpide-skiplinks a{position:absolute;left:-9999px;top:8px;z-index:100002;padding:10px 16px;background:#111;color:#fff;font-size:16px;text-decoration:underline;border-radius:4px;}\n.wpide-skiplinks a:focus{left:8px;outline:3px solid #fff;outline-offset:-6px;}\n</style>\n<script>\n(function () {\n    document.querySelectorAll('.wpide-skiplinks a').forEach(function (link, i) {\n        var target = document.querySelector(link.getAttribute('data-target'));\n        if (!target) { link.remove(); return; }\n        if (!target.id) { target.id = 'wpide-skip-target-' + i; }\n        if (!/^(A|BUTTON|INPUT|SELECT|TEXTAREA)$/.test(target.tagName) && !target.hasAttribute('tabindex')) {\n            target.setAttribute('tabindex', '-1');\n        }\n        link.setAttribute('href', '#' + target.id);\n        link.addEventListener('click', function () { setTimeout(function () { target.focus(); }, 0); });\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Adjust the CSS selectors in the data-target attributes to match your theme's header, menu, search and footer."
        },
        {
            "id": "a11y-reduced-motion-everywhere",
            "version": 1,
            "title": "Respect \"Reduce Motion\" Across the Whole Site",
            "description": "Turns off CSS animations, transitions, parallax backgrounds and smooth scrolling for visitors whose device is set to reduce motion. Visitors without that setting see no change.",
            "category": "accessibility",
            "tags": [
                "reduced-motion",
                "animation",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: reduce) {\n    *,\n    *::before,\n    *::after {\n        animation-duration: 0.01ms !important;\n        animation-iteration-count: 1 !important;\n        transition-duration: 0.01ms !important;\n        scroll-behavior: auto !important;\n    }\n\n    html {\n        scroll-behavior: auto !important;\n    }\n\n    [style*=\"background-attachment: fixed\"],\n    .has-parallax,\n    .wp-block-cover.has-parallax {\n        background-attachment: scroll !important;\n    }\n}",
            "requires": [],
            "notes": "Add selectors for slider or animation libraries your theme uses if they animate with JavaScript instead of CSS."
        },
        {
            "id": "a11y-pause-autoplay-media",
            "version": 1,
            "title": "Stop Autoplaying Videos for Reduced-Motion Users",
            "description": "Pauses autoplaying and looping background videos and adds visible controls when the visitor's device asks for reduced motion, so moving content never starts on its own.",
            "category": "accessibility",
            "tags": [
                "reduced-motion",
                "video",
                "autoplay"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    var query = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;\n    if (!query || !query.matches) { return; }\n    document.querySelectorAll('video[autoplay], video[loop]').forEach(function (video) {\n        video.removeAttribute('autoplay');\n        video.pause();\n        video.setAttribute('controls', '');\n    });\n    document.querySelectorAll('img[src$=\".gif\"]').forEach(function (img) {\n        img.setAttribute('loading', 'lazy');\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Remove the line that adds controls if your design uses a separate custom play button."
        },
        {
            "id": "a11y-pause-animations-button",
            "version": 1,
            "title": "\"Pause Animations\" Button for Visitors",
            "description": "Adds a floating button that lets any visitor stop animations, transitions and autoplaying videos on the site. The choice is remembered on their device.",
            "category": "accessibility",
            "tags": [
                "animation",
                "wcag",
                "button"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<button type=\"button\" class=\"wpide-motion-toggle\" aria-pressed=\"false\">Pause animations</button>\n<style>\n.wpide-motion-toggle{position:fixed;left:12px;bottom:12px;z-index:99990;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#1d2327;color:#fff;font-size:14px;cursor:pointer;}\n.wpide-motion-toggle:focus-visible{outline:3px solid #72aee6;outline-offset:2px;}\nhtml.wpide-no-motion *,html.wpide-no-motion *::before,html.wpide-no-motion *::after{animation:none!important;transition:none!important;scroll-behavior:auto!important;}\n</style>\n<script>\n(function () {\n    var key = 'wpideNoMotion';\n    var btn = document.querySelector('.wpide-motion-toggle');\n    if (!btn) { return; }\n    function apply(on) {\n        document.documentElement.classList.toggle('wpide-no-motion', on);\n        btn.setAttribute('aria-pressed', on ? 'true' : 'false');\n        btn.textContent = on ? 'Play animations' : 'Pause animations';\n        document.querySelectorAll('video').forEach(function (v) { if (on) { v.pause(); } });\n    }\n    var stored = false;\n    try { stored = localStorage.getItem(key) === '1'; } catch (e) {}\n    apply(stored);\n    btn.addEventListener('click', function () {\n        var on = btn.getAttribute('aria-pressed') !== 'true';\n        apply(on);\n        try { localStorage.setItem(key, on ? '1' : '0'); } catch (e) {}\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Change the bottom/left values in the CSS to move the button away from other floating elements such as chat widgets."
        },
        {
            "id": "a11y-submenu-toggle-buttons",
            "version": 1,
            "title": "Accessible Toggle Buttons for Sub-Menus",
            "description": "Adds a real button with aria-expanded next to every menu item that has a sub-menu in classic menus, so keyboard and screen reader users can open and close sub-menus.",
            "category": "accessibility",
            "tags": [
                "menus",
                "aria",
                "keyboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'walker_nav_menu_start_el', function ( $output, $item, $depth, $args ) {\n    $classes = is_array( $item->classes ) ? $item->classes : array();\n\n    if ( ! in_array( 'menu-item-has-children', $classes, true ) ) {\n        return $output;\n    }\n\n    $label = sprintf( 'Show sub-menu for %s', wp_strip_all_tags( $item->title ) );\n\n    return $output . sprintf(\n        '<button type=\"button\" class=\"wpide-submenu-toggle\" aria-expanded=\"false\" aria-label=\"%s\"><span aria-hidden=\"true\">&#9662;</span></button>',\n        esc_attr( $label )\n    );\n}, 10, 4 );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<style>\n.wpide-submenu-toggle{background:none;border:0;padding:4px 8px;cursor:pointer;color:inherit;font:inherit;}\n.wpide-submenu-toggle:focus-visible{outline:2px solid currentColor;outline-offset:2px;}\n.menu-item-has-children.is-open > .sub-menu{display:block;visibility:visible;opacity:1;left:auto;}\n</style>\n<script>\ndocument.addEventListener('click', function (event) {\n    var button = event.target.closest('.wpide-submenu-toggle');\n    if (!button) { return; }\n    var item = button.parentElement;\n    var open = button.getAttribute('aria-expanded') !== 'true';\n    button.setAttribute('aria-expanded', open ? 'true' : 'false');\n    item.classList.toggle('is-open', open);\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "Style .wpide-submenu-toggle to match your theme; the script opens sub-menus by adding the is-open class."
        },
        {
            "id": "a11y-escape-closes-menus",
            "version": 1,
            "title": "Close Open Menus With the Escape Key",
            "description": "Lets keyboard users press Escape to close an open dropdown or mobile menu and returns focus to the button or link that opened it.",
            "category": "accessibility",
            "tags": [
                "menus",
                "keyboard",
                "escape"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\ndocument.addEventListener('keydown', function (event) {\n    if (event.key !== 'Escape') { return; }\n    var open = document.querySelectorAll('[aria-expanded=\"true\"]');\n    if (!open.length) { return; }\n    var last = open[open.length - 1];\n    last.click();\n    if (last.getAttribute('aria-expanded') === 'true') {\n        last.setAttribute('aria-expanded', 'false');\n        var item = last.closest('.is-open, .focus, .toggled-on');\n        if (item) { item.classList.remove('is-open', 'focus', 'toggled-on'); }\n    }\n    last.focus();\n});\n</script>",
            "requires": [],
            "notes": "Works with menus that mark the open state with aria-expanded=\"true\" on the toggle, which most modern themes and the Navigation block do."
        },
        {
            "id": "a11y-label-menu-landmarks",
            "version": 1,
            "title": "Name Each Menu Landmark for Screen Readers",
            "description": "Gives every classic menu location its own accessible name (for example \"Primary menu\" or \"Footer menu\"), so screen reader users can tell navigation regions apart.",
            "category": "accessibility",
            "tags": [
                "menus",
                "landmarks",
                "aria"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Theme location => accessible name for its <nav> container.\n$wpide_menu_labels = array(\n    'primary' => 'Primary menu',\n    'menu-1'  => 'Primary menu',\n    'footer'  => 'Footer menu',\n    'social'  => 'Social links',\n);\n\nadd_filter( 'wp_nav_menu_args', function ( $args ) use ( $wpide_menu_labels ) {\n    $location = isset( $args['theme_location'] ) ? $args['theme_location'] : '';\n\n    if ( ! $location || empty( $wpide_menu_labels[ $location ] ) ) {\n        return $args;\n    }\n\n    if ( empty( $args['container'] ) || 'div' === $args['container'] ) {\n        $args['container'] = 'nav';\n    }\n\n    if ( empty( $args['container_aria_label'] ) ) {\n        $args['container_aria_label'] = $wpide_menu_labels[ $location ];\n    }\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Menu locations without a label in the list keep the theme's default; find your location names under Appearance > Menus > Manage Locations."
        },
        {
            "id": "a11y-new-window-link-warning",
            "version": 1,
            "title": "Announce Links That Open in a New Tab",
            "description": "Adds hidden \"(opens in a new tab)\" text and a small icon to links in post content that open a new window, so screen reader and keyboard users are not surprised.",
            "category": "accessibility",
            "tags": [
                "links",
                "new-tab",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '_blank' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    // Mark links first, then add the hidden text right before each closing </a>.\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'a' ) ) {\n        if ( '_blank' === $tags->get_attribute( 'target' ) ) {\n            $tags->add_class( 'wpide-new-tab' );\n        }\n    }\n\n    $content = $tags->get_updated_html();\n\n    return preg_replace_callback( '#(<a\\b[^>]*class=\"[^\"]*\\bwpide-new-tab\\b[^\"]*\"[^>]*>)(.*?)(</a>)#is', function ( $m ) {\n        if ( false !== stripos( $m[2], 'opens in a new' ) ) {\n            return $m[0];\n        }\n\n        return $m[1] . $m[2] . '<span class=\"screen-reader-text\"> (opens in a new tab)</span>' . $m[3];\n    }, $content );\n}, 20 );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.wpide-new-tab::after{content:\"\\2197\";display:inline-block;margin-left:.2em;font-size:.8em;text-decoration:none;}</style>' . \"\\n\";\n} );",
            "requires": [],
            "notes": "The hidden text uses the screen-reader-text class most themes provide; the icon can be removed by deleting the ::after rule."
        },
        {
            "id": "a11y-remove-redundant-link-titles",
            "version": 1,
            "title": "Remove Redundant Title Attributes From Menu Links",
            "description": "Strips title attributes from menu links when they only repeat the visible link text, which makes screen readers announce the same words twice.",
            "category": "accessibility",
            "tags": [
                "menus",
                "title-attribute",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'nav_menu_link_attributes', function ( $atts, $item ) {\n    if ( empty( $atts['title'] ) ) {\n        return $atts;\n    }\n\n    $text = strtolower( trim( wp_strip_all_tags( $item->title ) ) );\n\n    if ( strtolower( trim( $atts['title'] ) ) === $text ) {\n        unset( $atts['title'] );\n    }\n\n    return $atts;\n}, 10, 2 );",
            "requires": [],
            "notes": "Titles that add new information are kept; only exact repeats of the link text are removed."
        },
        {
            "id": "a11y-descriptive-read-more",
            "version": 1,
            "title": "Descriptive \"Read More\" Links With the Post Title",
            "description": "Adds the post title as hidden text to \"Read more\" and \"Continue reading\" links, so screen reader users hear where each link goes instead of a list of identical links.",
            "category": "accessibility",
            "tags": [
                "links",
                "excerpts",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Visible link text; the post title is appended as hidden text.\n$wpide_more_text = 'Read more';\n\n$wpide_more_link = function () use ( $wpide_more_text ) {\n    return sprintf(\n        ' <a class=\"more-link\" href=\"%1$s\">%2$s<span class=\"screen-reader-text\"> about %3$s</span></a>',\n        esc_url( get_permalink() ),\n        esc_html( $wpide_more_text ),\n        esc_html( get_the_title() )\n    );\n};\n\nadd_filter( 'excerpt_more', function () use ( $wpide_more_link ) {\n    return '&hellip;' . $wpide_more_link();\n}, 20 );\n\nadd_filter( 'the_content_more_link', function () use ( $wpide_more_link ) {\n    return $wpide_more_link();\n}, 20 );",
            "requires": [],
            "notes": "Change $wpide_more_text to the visible wording you want; the post title is added for screen readers only."
        },
        {
            "id": "a11y-prepublish-missing-alt",
            "version": 1,
            "title": "Pre-Publish Warning for Images Without Alt Text",
            "description": "Shows a panel in the block editor's pre-publish check listing every image block that has no alternative text, with a button to jump to each one.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "block-editor",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-alt-check', '', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-data', 'wp-components', 'wp-block-editor' ), null, true );\n    wp_enqueue_script( 'wpide-alt-check' );\n    wp_add_inline_script( 'wpide-alt-check', <<<'JS'\n(function (wp) {\n    var el = wp.element.createElement;\n    var Panel = (wp.editor && wp.editor.PluginPrePublishPanel) || (wp.editPost && wp.editPost.PluginPrePublishPanel);\n    if (!Panel) { return; }\n    function collect(blocks, out) {\n        blocks.forEach(function (b) {\n            if ((b.name === 'core/image' || b.name === 'core/media-text' || b.name === 'core/cover') && !(b.attributes.alt || '').trim() && (b.attributes.url || b.attributes.mediaUrl)) {\n                out.push(b);\n            }\n            if (b.innerBlocks && b.innerBlocks.length) { collect(b.innerBlocks, out); }\n        });\n        return out;\n    }\n    function AltPanel() {\n        var missing = wp.data.useSelect(function (select) { return collect(select('core/block-editor').getBlocks(), []); }, []);\n        var select = wp.data.useDispatch('core/block-editor').selectBlock;\n        return el(Panel, { title: 'Image alt text', initialOpen: missing.length > 0 },\n            missing.length === 0 ? el('p', null, 'Every image has alternative text.') :\n            el('div', null,\n                el('p', null, missing.length + ' image(s) have no alternative text:'),\n                missing.map(function (b, i) {\n                    return el(wp.components.Button, { key: b.clientId, variant: 'link', onClick: function () { select(b.clientId); } }, 'Image ' + (i + 1));\n                })\n            )\n        );\n    }\n    wp.plugins.registerPlugin('wpide-alt-check', { render: AltPanel });\n})(window.wp);\nJS\n    );\n} );",
            "requires": [],
            "notes": "Images that are purely decorative can stay without alt text; the panel is a reminder and does not block publishing."
        },
        {
            "id": "a11y-lock-publish-without-alt",
            "version": 1,
            "title": "Block Publishing Until Every Image Has Alt Text",
            "description": "Disables the Publish and Update buttons in the block editor while any image block is missing alternative text, and shows a notice explaining why.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "block-editor",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-alt-lock', '', array( 'wp-data', 'wp-editor', 'wp-notices' ), null, true );\n    wp_enqueue_script( 'wpide-alt-lock' );\n    wp_add_inline_script( 'wpide-alt-lock', <<<'JS'\n(function (wp) {\n    var locked = false;\n    function missingAlt(blocks) {\n        return blocks.some(function (b) {\n            var noAlt = b.name === 'core/image' && b.attributes.url && !(b.attributes.alt || '').trim();\n            return noAlt || (b.innerBlocks && missingAlt(b.innerBlocks));\n        });\n    }\n    wp.data.subscribe(function () {\n        var blocks = wp.data.select('core/block-editor').getBlocks();\n        var missing = missingAlt(blocks);\n        if (missing === locked) { return; }\n        locked = missing;\n        var editor = wp.data.dispatch('core/editor');\n        var notices = wp.data.dispatch('core/notices');\n        if (missing) {\n            editor.lockPostSaving('wpide-alt');\n            notices.createNotice('warning', 'Add alternative text to every image before publishing.', { id: 'wpide-alt', isDismissible: false });\n        } else {\n            editor.unlockPostSaving('wpide-alt');\n            notices.removeNotice('wpide-alt');\n        }\n    });\n})(window.wp);\nJS\n    );\n} );",
            "requires": [],
            "notes": "Strict by design; for decorative images type a short description anyway or use the pre-publish warning snippet instead."
        },
        {
            "id": "a11y-heading-outline-panel",
            "version": 1,
            "title": "Heading Outline Panel With Skipped-Level Warnings",
            "description": "Adds a \"Heading outline\" panel to the block editor sidebar that lists the post's headings as an indented outline and flags skipped levels (such as an H2 followed by an H4) and empty headings.",
            "category": "accessibility",
            "tags": [
                "headings",
                "block-editor",
                "structure"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-heading-outline', '', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-data' ), null, true );\n    wp_enqueue_script( 'wpide-heading-outline' );\n    wp_add_inline_script( 'wpide-heading-outline', <<<'JS'\n(function (wp) {\n    var el = wp.element.createElement;\n    var Panel = (wp.editor && wp.editor.PluginDocumentSettingPanel) || (wp.editPost && wp.editPost.PluginDocumentSettingPanel);\n    if (!Panel) { return; }\n    function headings(blocks, out) {\n        blocks.forEach(function (b) {\n            if (b.name === 'core/heading') { out.push(b); }\n            if (b.innerBlocks && b.innerBlocks.length) { headings(b.innerBlocks, out); }\n        });\n        return out;\n    }\n    function Outline() {\n        var list = wp.data.useSelect(function (select) { return headings(select('core/block-editor').getBlocks(), []); }, []);\n        var prev = 1;\n        var items = list.map(function (b) {\n            var level = b.attributes.level || 2;\n            var text = String(b.attributes.content || '').replace(/<[^>]+>/g, '').trim();\n            var problems = [];\n            if (level > prev + 1) { problems.push('skips from H' + prev + ' to H' + level); }\n            if (level === 1) { problems.push('extra H1'); }\n            if (!text) { problems.push('empty'); }\n            prev = level;\n            return el('li', { key: b.clientId, style: { marginLeft: (level - 2) * 12 + 'px', color: problems.length ? '#b32d2e' : 'inherit' } },\n                'H' + level + ' ' + (text || '(empty)') + (problems.length ? ' - ' + problems.join(', ') : ''));\n        });\n        return el(Panel, { name: 'wpide-heading-outline', title: 'Heading outline' },\n            list.length ? el('ul', { style: { margin: 0 } }, items) : el('p', null, 'No headings yet.'));\n    }\n    wp.plugins.registerPlugin('wpide-heading-outline', { render: Outline });\n})(window.wp);\nJS\n    );\n} );",
            "requires": [],
            "notes": "The post title is the page's H1, so the outline expects content headings to start at H2."
        },
        {
            "id": "a11y-prepublish-vague-links",
            "version": 1,
            "title": "Pre-Publish Check for Vague Link Text",
            "description": "Lists links whose text is \"click here\", \"read more\", \"here\" or a bare URL in the block editor's pre-publish panel, so authors can rewrite them to describe where they lead.",
            "category": "accessibility",
            "tags": [
                "links",
                "block-editor",
                "wcag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-vague-links', '', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-data' ), null, true );\n    wp_enqueue_script( 'wpide-vague-links' );\n    wp_add_inline_script( 'wpide-vague-links', <<<'JS'\n(function (wp) {\n    var el = wp.element.createElement;\n    var Panel = (wp.editor && wp.editor.PluginPrePublishPanel) || (wp.editPost && wp.editPost.PluginPrePublishPanel);\n    if (!Panel) { return; }\n    var vague = ['click here', 'here', 'read more', 'more', 'link', 'this', 'learn more', 'this page', 'continue'];\n    function Check() {\n        var html = wp.data.useSelect(function (select) { return select('core/editor').getEditedPostContent(); }, []);\n        var doc = new DOMParser().parseFromString(html || '', 'text/html');\n        var found = [];\n        doc.querySelectorAll('a[href]').forEach(function (a) {\n            var text = (a.textContent || '').trim().toLowerCase().replace(/[.!:]+$/, '');\n            if (vague.indexOf(text) !== -1 || /^https?:\\/\\//.test(text) || (!text && !a.querySelector('img[alt]:not([alt=\"\"])'))) {\n                found.push((a.textContent || '').trim() || '(no text)');\n            }\n        });\n        return el(Panel, { title: 'Link text', initialOpen: found.length > 0 },\n            found.length ? el('div', null, el('p', null, 'These links do not say where they lead:'), el('ul', null, found.map(function (t, i) { return el('li', { key: i }, t); })))\n                         : el('p', null, 'All links have descriptive text.'));\n    }\n    wp.plugins.registerPlugin('wpide-vague-links', { render: Check });\n})(window.wp);\nJS\n    );\n} );",
            "requires": [],
            "notes": "Add phrases in your language to the vague list in the script."
        },
        {
            "id": "a11y-save-check-notice",
            "version": 1,
            "title": "Accessibility Check When a Post Is Saved",
            "description": "After you save a post or page, shows a notice if the content has images without alt text, empty links, empty headings or skipped heading levels. Works with the block editor and the classic editor.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "headings",
                "admin-notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'save_post', function ( $post_id, $post ) {\n    if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || ! current_user_can( 'edit_post', $post_id ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return;\n    }\n\n    if ( ! is_post_type_viewable( $post->post_type ) || '' === trim( (string) $post->post_content ) ) {\n        return;\n    }\n\n    $issues = array();\n    $tags   = new WP_HTML_Tag_Processor( $post->post_content );\n    $level  = 1;\n\n    while ( $tags->next_tag() ) {\n        $name = $tags->get_tag();\n\n        if ( 'IMG' === $name && null === $tags->get_attribute( 'alt' ) ) {\n            $issues['img'] = 'an image has no alt attribute';\n        } elseif ( 'IMG' === $name && '' === trim( (string) $tags->get_attribute( 'alt' ) ) ) {\n            $issues['img-empty'] = 'an image has empty alt text (fine only if it is decorative)';\n        } elseif ( preg_match( '/^H([1-6])$/', (string) $name, $m ) ) {\n            if ( (int) $m[1] > $level + 1 ) {\n                $issues[ 'h' . $m[1] ] = sprintf( 'a heading jumps from H%d to H%d', $level, (int) $m[1] );\n            }\n            $level = (int) $m[1];\n        }\n    }\n\n    if ( preg_match( '#<a\\b[^>]*>\\s*</a>#i', $post->post_content ) ) {\n        $issues['link'] = 'a link has no text';\n    }\n\n    if ( preg_match( '#<h[1-6]\\b[^>]*>\\s*</h[1-6]>#i', $post->post_content ) ) {\n        $issues['heading'] = 'a heading is empty';\n    }\n\n    if ( $issues ) {\n        set_transient( 'wpide_a11y_notice_' . get_current_user_id(), array_values( $issues ), MINUTE_IN_SECONDS );\n    }\n}, 20, 2 );\n\nadd_action( 'admin_notices', function () {\n    $key    = 'wpide_a11y_notice_' . get_current_user_id();\n    $issues = get_transient( $key );\n\n    if ( ! $issues ) {\n        return;\n    }\n\n    delete_transient( $key );\n    echo '<div class=\"notice notice-warning is-dismissible\"><p><strong>Accessibility check:</strong> ' . esc_html( implode( '; ', $issues ) ) . '.</p></div>';\n} );",
            "requires": [],
            "notes": "Checks published content as stored in the database; results are kept for one minute per user, so the notice shows once after each save."
        },
        {
            "id": "a11y-featured-image-alt-fallback",
            "version": 1,
            "title": "Use the Post Title as Alt Text for Featured Images Without One",
            "description": "When a featured image has no alternative text, outputs the post title as its alt attribute in themes and blocks, instead of leaving it empty.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "featured-image",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'post_thumbnail_html', function ( $html, $post_id ) {\n    if ( ! $html || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $html;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $html );\n\n    if ( $tags->next_tag( 'img' ) && '' === trim( (string) $tags->get_attribute( 'alt' ) ) ) {\n        $tags->set_attribute( 'alt', wp_strip_all_tags( get_the_title( $post_id ) ) );\n        return $tags->get_updated_html();\n    }\n\n    return $html;\n}, 10, 2 );",
            "requires": [],
            "notes": "Only affects images shown as a post's featured image; images that already have alt text keep it."
        },
        {
            "id": "a11y-media-filter-missing-alt",
            "version": 1,
            "title": "\"Missing Alt Text\" Filter in the Media Library",
            "description": "Adds a dropdown to the Media Library list view that shows only images without alternative text, so you can fix them one by one.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "media-library",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'restrict_manage_posts', function ( $post_type ) {\n    if ( 'attachment' !== $post_type ) {\n        return;\n    }\n\n    $current = isset( $_GET['wpide_alt'] ) ? sanitize_key( wp_unslash( $_GET['wpide_alt'] ) ) : '';\n\n    echo '<label class=\"screen-reader-text\" for=\"wpide-alt-filter\">Filter by alt text</label>';\n    echo '<select name=\"wpide_alt\" id=\"wpide-alt-filter\">';\n    echo '<option value=\"\">All alt text states</option>';\n    echo '<option value=\"missing\"' . selected( $current, 'missing', false ) . '>Missing alt text</option>';\n    echo '</select>';\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || empty( $_GET['wpide_alt'] ) || 'missing' !== $_GET['wpide_alt'] ) {\n        return;\n    }\n\n    if ( 'attachment' !== $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $query->set( 'post_mime_type', 'image' );\n    $query->set( 'meta_query', array(\n        'relation' => 'OR',\n        array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),\n        array( 'key' => '_wp_attachment_image_alt', 'value' => '' ),\n    ) );\n} );",
            "requires": [],
            "notes": "Works in the list view of the Media Library (upload.php?mode=list)."
        },
        {
            "id": "a11y-missing-alt-dashboard-widget",
            "version": 1,
            "title": "Images Missing Alt Text Dashboard Widget",
            "description": "Adds a dashboard widget showing how many images in the Media Library have no alternative text, with a link to the most recent ones to fix.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "dashboard",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_alt_count = function () {\n    global $wpdb;\n\n    $count = get_transient( 'wpide_missing_alt_count' );\n\n    if ( false === $count ) {\n        $count = (int) $wpdb->get_var(\n            \"SELECT COUNT(p.ID) FROM {$wpdb->posts} p\n             LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt'\n             WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%' AND ( m.meta_value IS NULL OR m.meta_value = '' )\"\n        );\n        set_transient( 'wpide_missing_alt_count', $count, 12 * HOUR_IN_SECONDS );\n    }\n\n    return (int) $count;\n};\n\n$wpide_alt_flush = function ( $meta_id, $object_id, $meta_key ) {\n    if ( '_wp_attachment_image_alt' === $meta_key ) {\n        delete_transient( 'wpide_missing_alt_count' );\n    }\n};\nadd_action( 'added_post_meta', $wpide_alt_flush, 10, 3 );\nadd_action( 'updated_post_meta', $wpide_alt_flush, 10, 3 );\nadd_action( 'add_attachment', function () {\n    delete_transient( 'wpide_missing_alt_count' );\n} );\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_alt_count ) {\n    if ( ! current_user_can( 'upload_files' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_missing_alt', 'Images Missing Alt Text', function () use ( $wpide_alt_count ) {\n        $count = $wpide_alt_count();\n\n        if ( ! $count ) {\n            echo '<p>Every image has alternative text. Nice work.</p>';\n            return;\n        }\n\n        $recent = get_posts( array(\n            'post_type'      => 'attachment',\n            'post_mime_type' => 'image',\n            'post_status'    => 'inherit',\n            'posts_per_page' => 5,\n            'no_found_rows'  => true,\n            'meta_query'     => array(\n                'relation' => 'OR',\n                array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),\n                array( 'key' => '_wp_attachment_image_alt', 'value' => '' ),\n            ),\n        ) );\n\n        printf( '<p><strong>%s</strong> image(s) have no alt text. Most recent:</p><ul>', esc_html( number_format_i18n( $count ) ) );\n        foreach ( $recent as $image ) {\n            printf( '<li><a href=\"%s\">%s</a></li>', esc_url( get_edit_post_link( $image->ID ) ), esc_html( $image->post_title ? $image->post_title : '#' . $image->ID ) );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "The count is cached for 12 hours and refreshed whenever an image's alt text changes."
        },
        {
            "id": "a11y-placeholder-to-label",
            "version": 1,
            "title": "Add Missing Labels to Form Fields From Their Placeholder",
            "description": "Finds form fields on the page that have a placeholder but no label or accessible name and gives them one from the placeholder text, a common problem with theme and plugin forms.",
            "category": "accessibility",
            "tags": [
                "forms",
                "labels",
                "aria"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function fix(root) {\n        root.querySelectorAll('input[placeholder], textarea[placeholder], select[data-placeholder]').forEach(function (field) {\n            if (field.type === 'hidden' || field.getAttribute('aria-label') || field.getAttribute('aria-labelledby')) { return; }\n            if (field.id && document.querySelector('label[for=\"' + CSS.escape(field.id) + '\"]')) { return; }\n            if (field.closest('label')) { return; }\n            field.setAttribute('aria-label', field.getAttribute('placeholder') || field.getAttribute('data-placeholder'));\n        });\n    }\n    fix(document);\n    if ('MutationObserver' in window) {\n        new MutationObserver(function (records) {\n            records.forEach(function (r) { r.addedNodes.forEach(function (n) { if (n.nodeType === 1) { fix(n); } }); });\n        }).observe(document.body, { childList: true, subtree: true });\n    }\n})();\n</script>",
            "requires": [],
            "notes": "A visible label is always better; use this as a safety net while you fix the forms themselves."
        },
        {
            "id": "a11y-contrast-checker-tool",
            "version": 1,
            "title": "Color Contrast Checker Under Tools",
            "description": "Adds a Tools > Contrast Checker page where you pick a text and background color and instantly see the WCAG contrast ratio and whether it passes AA and AAA for normal and large text.",
            "category": "accessibility",
            "tags": [
                "contrast",
                "colors",
                "wcag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Contrast Checker', 'Contrast Checker', 'edit_posts', 'wpide-contrast', function () {\n        ?>\n<div class=\"wrap\">\n    <h1>Contrast Checker</h1>\n    <p>\n        <label for=\"wpide-fg\">Text color</label>\n        <input type=\"color\" id=\"wpide-fg\" value=\"#1d2327\">\n        <label for=\"wpide-bg\" style=\"margin-left:16px\">Background color</label>\n        <input type=\"color\" id=\"wpide-bg\" value=\"#ffffff\">\n    </p>\n    <div id=\"wpide-sample\" style=\"padding:24px;border:1px solid #c3c4c7;max-width:520px;\">\n        <p style=\"font-size:16px;margin:0 0 8px\">Normal text sample (16px)</p>\n        <p style=\"font-size:24px;font-weight:700;margin:0\">Large text sample (24px bold)</p>\n    </div>\n    <p id=\"wpide-ratio\" aria-live=\"polite\" style=\"font-size:18px\"></p>\n</div>\n<script>\n(function () {\n    function lum(hex) {\n        var rgb = [1, 3, 5].map(function (i) {\n            var c = parseInt(hex.substr(i, 2), 16) / 255;\n            return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);\n        });\n        return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];\n    }\n    var fg = document.getElementById('wpide-fg'), bg = document.getElementById('wpide-bg');\n    function update() {\n        var a = lum(fg.value), b = lum(bg.value);\n        var ratio = (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);\n        var sample = document.getElementById('wpide-sample');\n        sample.style.color = fg.value; sample.style.background = bg.value;\n        function v(ok) { return ok ? 'pass' : 'fail'; }\n        document.getElementById('wpide-ratio').textContent = 'Ratio ' + ratio.toFixed(2) + ':1. AA normal ' + v(ratio >= 4.5) + ', AA large ' + v(ratio >= 3) + ', AAA normal ' + v(ratio >= 7) + ', AAA large ' + v(ratio >= 4.5) + '.';\n    }\n    fg.addEventListener('input', update); bg.addEventListener('input', update); update();\n})();\n</script>\n        <?php\n    } );\n} );",
            "requires": [],
            "notes": "Runs entirely in the browser; nothing is saved."
        },
        {
            "id": "a11y-font-size-controls",
            "version": 1,
            "title": "Text Size Buttons Shortcode",
            "description": "Adds a [text_size_controls] shortcode with \"A-\", \"A\" and \"A+\" buttons that make the whole site's text smaller or larger for that visitor, remembered on their device.",
            "category": "accessibility",
            "tags": [
                "font-size",
                "shortcode",
                "readability"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Scaling steps in percent of the browser's default font size.\n$wpide_text_steps = array( 90, 100, 112, 125, 140 );\n\nadd_shortcode( 'text_size_controls', function () use ( $wpide_text_steps ) {\n    static $printed = false;\n\n    $html = '<div class=\"wpide-textsize\" role=\"group\" aria-label=\"Text size\">'\n        . '<button type=\"button\" data-step=\"-1\" aria-label=\"Smaller text\">A&minus;</button>'\n        . '<button type=\"button\" data-step=\"0\" aria-label=\"Default text size\">A</button>'\n        . '<button type=\"button\" data-step=\"1\" aria-label=\"Larger text\">A+</button>'\n        . '</div>';\n\n    if ( ! $printed ) {\n        $printed = true;\n        $html   .= '<style>.wpide-textsize{display:inline-flex;gap:4px}.wpide-textsize button{min-width:44px;min-height:44px;font-weight:700;cursor:pointer}</style>';\n        $html   .= '<script>(function(){var steps=' . wp_json_encode( array_values( $wpide_text_steps ) ) . ',key=\"wpideTextStep\",def=steps.indexOf(100),i=def;try{var s=parseInt(localStorage.getItem(key),10);if(!isNaN(s)&&steps[s]){i=s;}}catch(e){}function apply(){document.documentElement.style.fontSize=steps[i]===100?\"\":steps[i]+\"%\";try{localStorage.setItem(key,i);}catch(e){}}apply();document.addEventListener(\"click\",function(e){var b=e.target.closest(\".wpide-textsize button\");if(!b){return;}var d=parseInt(b.getAttribute(\"data-step\"),10);i=d===0?def:Math.max(0,Math.min(steps.length-1,i+d));apply();});})();</script>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Works best with themes that size text in rem or em; pixel-based themes only scale partly."
        },
        {
            "id": "a11y-text-spacing-toggle",
            "version": 1,
            "title": "Text Spacing Toggle for Easier Reading",
            "description": "Adds a [text_spacing_toggle] shortcode button that increases line height, letter and word spacing to the levels recommended by WCAG, which helps readers with dyslexia or low vision.",
            "category": "accessibility",
            "tags": [
                "readability",
                "dyslexia",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'text_spacing_toggle', function () {\n    static $printed = false;\n\n    $html = '<button type=\"button\" class=\"wpide-spacing-toggle\" aria-pressed=\"false\">Increase text spacing</button>';\n\n    if ( ! $printed ) {\n        $printed = true;\n        $html   .= '<style>html.wpide-spacing body *{line-height:1.6!important;letter-spacing:.12em!important;word-spacing:.16em!important}html.wpide-spacing p{margin-bottom:2em!important}.wpide-spacing-toggle{min-height:44px;cursor:pointer}</style>';\n        $html   .= '<script>(function(){var k=\"wpideSpacing\",on=false;try{on=localStorage.getItem(k)===\"1\";}catch(e){}function set(v){on=v;document.documentElement.classList.toggle(\"wpide-spacing\",v);document.querySelectorAll(\".wpide-spacing-toggle\").forEach(function(b){b.setAttribute(\"aria-pressed\",v?\"true\":\"false\");});try{localStorage.setItem(k,v?\"1\":\"0\");}catch(e){}}document.addEventListener(\"DOMContentLoaded\",function(){set(on);});document.addEventListener(\"click\",function(e){if(e.target.closest(\".wpide-spacing-toggle\")){set(!on);}});})();</script>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Place the shortcode in a header or footer widget, or in a Shortcode block on a page."
        },
        {
            "id": "a11y-underline-content-links",
            "version": 1,
            "title": "Always Underline Links in Post Content",
            "description": "Makes links inside posts and pages underlined, so they can be recognised without relying on color alone, as WCAG requires for links in text.",
            "category": "accessibility",
            "tags": [
                "links",
                "css",
                "wcag"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".entry-content a:not(.wp-block-button__link):not(.wp-element-button),\n.wp-block-post-content a:not(.wp-block-button__link):not(.wp-element-button),\n.comment-content a {\n    text-decoration: underline !important;\n    text-decoration-thickness: max(1px, 0.0625em);\n    text-underline-offset: 0.15em;\n}\n\n.entry-content a:hover,\n.wp-block-post-content a:hover,\n.comment-content a:hover {\n    text-decoration-thickness: max(2px, 0.125em);\n}",
            "requires": [],
            "notes": "Buttons styled as links (wp-block-button) are left alone."
        },
        {
            "id": "a11y-minimum-target-size",
            "version": 1,
            "title": "Bigger Tap Targets for Small Links and Buttons",
            "description": "Gives menu links, pagination links, icon buttons and form controls a minimum 24 by 24 pixel tap area, the WCAG 2.2 target size minimum, without changing how they look.",
            "category": "accessibility",
            "tags": [
                "wcag",
                "mobile",
                "touch"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".menu a,\n.page-numbers,\n.wp-block-social-link a,\n.wp-block-navigation-item__content,\n.tagcloud a,\n.wp-block-tag-cloud a,\nbutton,\ninput[type=\"checkbox\"] + label,\ninput[type=\"radio\"] + label {\n    min-height: 24px;\n    min-width: 24px;\n    display: inline-flex;\n    align-items: center;\n}\n\ninput[type=\"checkbox\"],\ninput[type=\"radio\"] {\n    min-width: 18px;\n    min-height: 18px;\n}",
            "requires": [],
            "notes": "Raise the values to 44px for the stricter AAA level if your layout allows it."
        },
        {
            "id": "a11y-screen-reader-text-class",
            "version": 1,
            "title": "Screen Reader Text Class for Any Theme",
            "description": "Adds the standard .screen-reader-text styles that hide text visually but keep it readable by screen readers, for themes that do not include them.",
            "category": "accessibility",
            "tags": [
                "screen-reader",
                "css",
                "theme"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".screen-reader-text {\n    border: 0;\n    clip: rect(1px, 1px, 1px, 1px);\n    clip-path: inset(50%);\n    height: 1px;\n    margin: -1px;\n    overflow: hidden;\n    padding: 0;\n    position: absolute !important;\n    width: 1px;\n    word-wrap: normal !important;\n}\n\n.screen-reader-text:focus {\n    background-color: #f1f1f1;\n    clip: auto !important;\n    clip-path: none;\n    color: #21759b;\n    display: block;\n    font-size: 0.875rem;\n    font-weight: 700;\n    height: auto;\n    left: 5px;\n    line-height: normal;\n    padding: 15px 23px 14px;\n    text-decoration: none;\n    top: 5px;\n    width: auto;\n    z-index: 100000;\n}",
            "requires": [],
            "notes": "Many snippets and plugins rely on this class; add it once if hidden helper text shows up visibly on your site."
        },
        {
            "id": "a11y-pagination-labels",
            "version": 1,
            "title": "Accessible Pagination Labels",
            "description": "Gives numbered pagination links clear labels such as \"Page 3\" and names the pagination landmark \"Posts pages\", so screen reader users know what each link does.",
            "category": "accessibility",
            "tags": [
                "pagination",
                "aria",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'paginate_links_output', function ( $output ) {\n    if ( ! $output || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $output;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $output );\n\n    while ( $tags->next_tag() ) {\n        if ( ! in_array( $tags->get_tag(), array( 'A', 'SPAN' ), true ) || ! $tags->has_class( 'page-numbers' ) || $tags->get_attribute( 'aria-label' ) ) {\n            continue;\n        }\n\n        if ( $tags->has_class( 'prev' ) ) {\n            $tags->set_attribute( 'aria-label', 'Previous page' );\n        } elseif ( $tags->has_class( 'next' ) ) {\n            $tags->set_attribute( 'aria-label', 'Next page' );\n        } elseif ( $tags->has_class( 'current' ) ) {\n            $tags->set_attribute( 'aria-label', 'Current page' );\n        } elseif ( 'A' === $tags->get_tag() ) {\n            $tags->set_attribute( 'class', trim( $tags->get_attribute( 'class' ) . ' wpide-page-link' ) );\n        }\n    }\n\n    // Numbered links get \"Page N\" from their text.\n    return preg_replace_callback( '#<a([^>]*\\bwpide-page-link\\b[^>]*)>([^<]*\\d+[^<]*)</a>#', function ( $m ) {\n        $number = (int) preg_replace( '/\\D+/', '', $m[2] );\n\n        return '<a' . $m[1] . ' aria-label=\"' . esc_attr( 'Page ' . $number ) . '\">' . $m[2] . '</a>';\n    }, $tags->get_updated_html() );\n} );\n\nadd_filter( 'navigation_markup_template', function ( $template, $class ) {\n    if ( 'pagination' === $class ) {\n        $template = str_replace( 'aria-label=\"%4$s\"', 'aria-label=\"Posts pages\"', $template );\n    }\n\n    return $template;\n}, 10, 2 );",
            "requires": [],
            "notes": "Applies to pagination built with the core paginate_links() and the_posts_pagination() functions."
        },
        {
            "id": "a11y-adjacent-post-link-labels",
            "version": 1,
            "title": "Descriptive Labels for Previous and Next Post Links",
            "description": "Adds aria-labels like \"Previous post: Title\" to the previous and next post links in classic themes, so screen readers announce where each link leads.",
            "category": "accessibility",
            "tags": [
                "links",
                "navigation",
                "aria"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_adjacent_label = function ( $output, $format, $link, $post, $adjacent ) {\n    if ( ! $output || ! $post || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $output;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $output );\n\n    if ( $tags->next_tag( 'a' ) && ! $tags->get_attribute( 'aria-label' ) ) {\n        $prefix = 'previous' === $adjacent ? 'Previous post: ' : 'Next post: ';\n        $tags->set_attribute( 'aria-label', $prefix . wp_strip_all_tags( get_the_title( $post ) ) );\n    }\n\n    return $tags->get_updated_html();\n};\n\nadd_filter( 'previous_post_link', $wpide_adjacent_label, 10, 5 );\nadd_filter( 'next_post_link', $wpide_adjacent_label, 10, 5 );",
            "requires": [],
            "notes": "Block themes using the Post Navigation Link block already include the title in the link text."
        },
        {
            "id": "a11y-iframe-titles",
            "version": 1,
            "title": "Add Missing Titles to Embedded Iframes",
            "description": "Gives iframes in post content a title attribute when they lack one (for example \"YouTube video\" or \"Embedded map\"), so screen reader users know what each frame contains.",
            "category": "accessibility",
            "tags": [
                "iframes",
                "embeds",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Host fragment => title used when the iframe has none.\n$wpide_iframe_titles = array(\n    'youtube'     => 'YouTube video',\n    'vimeo'       => 'Vimeo video',\n    'google.com/maps' => 'Embedded map',\n    'spotify'     => 'Spotify player',\n    'soundcloud'  => 'SoundCloud player',\n    'calendar.google' => 'Embedded calendar',\n);\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_iframe_titles ) {\n    if ( false === stripos( $content, '<iframe' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'iframe' ) ) {\n        if ( '' !== trim( (string) $tags->get_attribute( 'title' ) ) ) {\n            continue;\n        }\n\n        $src   = (string) $tags->get_attribute( 'src' );\n        $title = 'Embedded content';\n\n        foreach ( $wpide_iframe_titles as $needle => $label ) {\n            if ( false !== stripos( $src, $needle ) ) {\n                $title = $label;\n                break;\n            }\n        }\n\n        $tags->set_attribute( 'title', $title );\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Add more hosts to the $wpide_iframe_titles list for other services you embed."
        },
        {
            "id": "a11y-video-caption-reminder",
            "version": 1,
            "title": "Remind Editors to Add Captions to Videos",
            "description": "Shows a warning after saving when a post contains a Video block without a captions or subtitles track, so videos are not published without captions by accident.",
            "category": "accessibility",
            "tags": [
                "video",
                "captions",
                "admin-notice"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'save_post', function ( $post_id, $post ) {\n    if ( wp_is_post_revision( $post_id ) || ! has_block( 'core/video', $post ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $missing = 0;\n\n    foreach ( parse_blocks( $post->post_content ) as $block ) {\n        if ( 'core/video' === $block['blockName'] && empty( $block['attrs']['tracks'] ) ) {\n            $missing++;\n        }\n    }\n\n    if ( $missing ) {\n        set_transient( 'wpide_caption_notice_' . get_current_user_id(), $missing, MINUTE_IN_SECONDS );\n    }\n}, 20, 2 );\n\nadd_action( 'admin_notices', function () {\n    $key     = 'wpide_caption_notice_' . get_current_user_id();\n    $missing = (int) get_transient( $key );\n\n    if ( ! $missing ) {\n        return;\n    }\n\n    delete_transient( $key );\n    printf( '<div class=\"notice notice-warning is-dismissible\"><p>%s</p></div>', esc_html( sprintf( '%d video(s) in this post have no captions track. Add one in the Video block settings under \"Text tracks\".', $missing ) ) );\n} );",
            "requires": [],
            "notes": "Add captions in the Video block settings under \"Text tracks\"; embedded YouTube and Vimeo videos manage captions on their own platform."
        },
        {
            "id": "a11y-table-header-scope",
            "version": 1,
            "title": "Add Scope to Table Header Cells",
            "description": "Adds scope=\"col\" or scope=\"row\" to table header cells in post content that lack it, so screen readers can tie each data cell to its header.",
            "category": "accessibility",
            "tags": [
                "tables",
                "screen-reader",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '<th' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags     = new WP_HTML_Tag_Processor( $content );\n    $row      = 0;\n    $in_thead = false;\n\n    while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) {\n        $tag = $tags->get_tag();\n\n        if ( 'THEAD' === $tag ) {\n            $in_thead = ! $tags->is_tag_closer();\n        } elseif ( 'TABLE' === $tag && ! $tags->is_tag_closer() ) {\n            $row = 0;\n        } elseif ( 'TR' === $tag && ! $tags->is_tag_closer() ) {\n            $row++;\n        } elseif ( 'TH' === $tag && ! $tags->is_tag_closer() && null === $tags->get_attribute( 'scope' ) ) {\n            $tags->set_attribute( 'scope', ( $in_thead || 1 === $row ) ? 'col' : 'row' );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Header cells in the first row get scope=\"col\"; header cells elsewhere get scope=\"row\"."
        },
        {
            "id": "a11y-remove-positive-tabindex",
            "version": 1,
            "title": "Remove Positive Tabindex Values From Content",
            "description": "Removes tabindex values greater than zero from links and fields in post content, which otherwise force a confusing keyboard order, while keeping tabindex 0 and -1.",
            "category": "accessibility",
            "tags": [
                "keyboard",
                "tabindex",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, 'tabindex' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag() ) {\n        $value = $tags->get_attribute( 'tabindex' );\n\n        if ( null !== $value && (int) $value > 0 ) {\n            $tags->set_attribute( 'tabindex', '0' );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Theme and plugin markup outside the post content is not changed."
        },
        {
            "id": "a11y-woo-announce-add-to-cart",
            "version": 1,
            "title": "Announce \"Added to Cart\" to Screen Readers",
            "description": "Makes screen readers announce when a product is added to the cart with AJAX on shop and category pages, which otherwise happens silently.",
            "category": "accessibility",
            "tags": [
                "woocommerce",
                "screen-reader",
                "cart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( ! function_exists( 'is_woocommerce' ) || ! wp_script_is( 'wc-add-to-cart', 'registered' ) ) {\n        return;\n    }\n\n    wp_enqueue_script( 'wp-a11y' );\n    wp_add_inline_script( 'wc-add-to-cart', <<<'JS'\njQuery(function ($) {\n    $(document.body).on('added_to_cart', function (event, fragments, hash, button) {\n        var name = button && button.length ? (button.attr('aria-label') || button.data('product_name') || '') : '';\n        name = String(name).replace(/^Add (to cart: )?/i, '').replace(/[“”\"]/g, '').replace(/ to your cart$/i, '');\n        if (window.wp && wp.a11y) { wp.a11y.speak((name ? name + ' ' : 'Product ') + 'added to your cart.', 'polite'); }\n    });\n});\nJS\n    );\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses the wp-a11y live region that WordPress provides; change the message text in the script if needed."
        },
        {
            "id": "a11y-admin-focus-outlines",
            "version": 1,
            "title": "Stronger Keyboard Focus Outlines in wp-admin",
            "description": "Makes the keyboard focus indicator in the WordPress dashboard thicker and higher-contrast on links, buttons, menus and form fields, so it is easy to see where you are.",
            "category": "accessibility",
            "tags": [
                "admin",
                "focus",
                "keyboard"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": "#wpwrap a:focus-visible,\n#wpwrap button:focus-visible,\n#wpwrap input:focus-visible,\n#wpwrap select:focus-visible,\n#wpwrap textarea:focus-visible,\n#adminmenu a:focus-visible,\n#wpadminbar a:focus-visible,\n.components-button:focus-visible {\n    outline: 3px solid #1d2327 !important;\n    outline-offset: 2px !important;\n    box-shadow: 0 0 0 5px #fff, 0 0 0 7px #2271b1 !important;\n}",
            "requires": [],
            "notes": "Uses :focus-visible, so mouse clicks keep the normal look."
        },
        {
            "id": "a11y-statement-shortcode",
            "version": 1,
            "title": "Accessibility Statement Shortcode",
            "description": "Adds an [accessibility_statement] shortcode that prints a ready-made accessibility statement with your site name, target standard, contact email and review date.",
            "category": "accessibility",
            "tags": [
                "statement",
                "shortcode",
                "compliance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Values used in the statement.\n$wpide_statement = array(\n    'standard' => 'WCAG 2.2 level AA',\n    'email'    => get_option( 'admin_email' ),\n    'reviewed' => '2026-01-15', // Date of your last review (YYYY-MM-DD).\n    'known'    => array( 'Some older PDF documents are not fully accessible.' ),\n);\n\nadd_shortcode( 'accessibility_statement', function () use ( $wpide_statement ) {\n    $site = get_bloginfo( 'name' );\n    $html = '<section class=\"wpide-a11y-statement\">';\n    $html .= '<p>' . esc_html( sprintf( '%s wants everyone to be able to use this website, including people with disabilities. We aim to meet %s.', $site, $wpide_statement['standard'] ) ) . '</p>';\n\n    if ( $wpide_statement['known'] ) {\n        $html .= '<h2>Known limitations</h2><ul>';\n        foreach ( $wpide_statement['known'] as $item ) {\n            $html .= '<li>' . esc_html( $item ) . '</li>';\n        }\n        $html .= '</ul>';\n    }\n\n    $html .= '<h2>Feedback</h2><p>' . esc_html( 'If you find a barrier on this site, please tell us and we will do our best to fix it:' ) . ' <a href=\"' . esc_url( 'mailto:' . antispambot( $wpide_statement['email'] ) ) . '\">' . esc_html( antispambot( $wpide_statement['email'] ) ) . '</a></p>';\n    $html .= '<p>' . esc_html( sprintf( 'This statement was last reviewed on %s.', date_i18n( get_option( 'date_format' ), strtotime( $wpide_statement['reviewed'] ) ) ) ) . '</p>';\n\n    return $html . '</section>';\n} );",
            "requires": [],
            "notes": "Edit the values at the top, then review the text with whoever is responsible for accessibility on your site; this is a starting point, not legal advice."
        },
        {
            "id": "a11y-report-barrier-link",
            "version": 1,
            "title": "\"Report an Accessibility Problem\" Footer Link",
            "description": "Adds a small link at the bottom of every page inviting visitors to report accessibility barriers by email, with the page address filled in for you.",
            "category": "accessibility",
            "tags": [
                "feedback",
                "footer",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Email address (or contact page URL) for accessibility reports.\n$wpide_a11y_contact = get_option( 'admin_email' );\n\nadd_action( 'wp_footer', function () use ( $wpide_a11y_contact ) {\n    $page = home_url( add_query_arg( array() ) );\n\n    if ( is_email( $wpide_a11y_contact ) ) {\n        $url = 'mailto:' . antispambot( $wpide_a11y_contact ) . '?subject=' . rawurlencode( 'Accessibility problem' ) . '&body=' . rawurlencode( \"Page: $page\\n\\nWhat happened:\\n\" );\n    } else {\n        $url = add_query_arg( 'page_url', rawurlencode( $page ), $wpide_a11y_contact );\n    }\n\n    printf(\n        '<p class=\"wpide-a11y-report\" style=\"text-align:center;font-size:14px;margin:1em 0;\"><a href=\"%s\">Report an accessibility problem on this page</a></p>',\n        esc_url( $url, array( 'mailto', 'http', 'https' ) )\n    );\n}, 50 );",
            "requires": [],
            "notes": "Change $wpide_a11y_contact to a dedicated inbox, or to the URL of a contact page."
        },
        {
            "id": "a11y-floating-toolbar",
            "version": 1,
            "title": "Accessibility Toolbar With Text Size, Contrast and Link Highlighting",
            "description": "Adds a small floating accessibility button that opens a panel with larger text, high contrast, highlighted links, a readable font and a reset option. Choices are remembered on the visitor's device.",
            "category": "accessibility",
            "tags": [
                "toolbar",
                "contrast",
                "font-size"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div class=\"wpide-a11y-bar\">\n    <button type=\"button\" class=\"wpide-a11y-open\" aria-expanded=\"false\" aria-controls=\"wpide-a11y-panel\">Accessibility options</button>\n    <div id=\"wpide-a11y-panel\" class=\"wpide-a11y-panel\" hidden>\n        <button type=\"button\" data-a11y=\"big\" aria-pressed=\"false\">Larger text</button>\n        <button type=\"button\" data-a11y=\"contrast\" aria-pressed=\"false\">High contrast</button>\n        <button type=\"button\" data-a11y=\"links\" aria-pressed=\"false\">Highlight links</button>\n        <button type=\"button\" data-a11y=\"font\" aria-pressed=\"false\">Readable font</button>\n        <button type=\"button\" data-a11y=\"reset\">Reset</button>\n    </div>\n</div>\n<style>\n.wpide-a11y-bar{position:fixed;right:12px;bottom:12px;z-index:99991;font:15px/1.4 system-ui,sans-serif;}\n.wpide-a11y-bar button{display:block;width:100%;margin:0 0 6px;padding:10px 14px;border:2px solid #fff;border-radius:6px;background:#1d2327;color:#fff;text-align:left;cursor:pointer;min-height:44px;}\n.wpide-a11y-bar button[aria-pressed=\"true\"]{background:#2271b1;}\n.wpide-a11y-bar button:focus-visible{outline:3px solid #f0b849;outline-offset:2px;}\n.wpide-a11y-panel{margin-bottom:6px;}\nhtml.wpide-a11y-big{font-size:125%!important;}\nhtml.wpide-a11y-contrast body,html.wpide-a11y-contrast body *:not(.wpide-a11y-bar *){background-color:#000!important;color:#fff!important;border-color:#fff!important;}\nhtml.wpide-a11y-contrast a:not(.wpide-a11y-bar *){color:#ff0!important;}\nhtml.wpide-a11y-links a:not(.wpide-a11y-bar *){text-decoration:underline!important;outline:2px dashed currentColor;outline-offset:2px;}\nhtml.wpide-a11y-font body *:not(.wpide-a11y-bar *){font-family:Verdana,Tahoma,sans-serif!important;letter-spacing:.03em;}\n</style>\n<script>\n(function () {\n    var root = document.documentElement, key = 'wpideA11y', state = {};\n    var open = document.querySelector('.wpide-a11y-open'), panel = document.getElementById('wpide-a11y-panel');\n    if (!open || !panel) { return; }\n    try { state = JSON.parse(localStorage.getItem(key) || '{}') || {}; } catch (e) { state = {}; }\n    function render() {\n        ['big', 'contrast', 'links', 'font'].forEach(function (k) {\n            root.classList.toggle('wpide-a11y-' + k, !!state[k]);\n            var b = panel.querySelector('[data-a11y=\"' + k + '\"]');\n            if (b) { b.setAttribute('aria-pressed', state[k] ? 'true' : 'false'); }\n        });\n        try { localStorage.setItem(key, JSON.stringify(state)); } catch (e) {}\n    }\n    open.addEventListener('click', function () {\n        var expanded = open.getAttribute('aria-expanded') === 'true';\n        open.setAttribute('aria-expanded', expanded ? 'false' : 'true');\n        panel.hidden = expanded;\n    });\n    panel.addEventListener('click', function (e) {\n        var b = e.target.closest('[data-a11y]');\n        if (!b) { return; }\n        var k = b.getAttribute('data-a11y');\n        if (k === 'reset') { state = {}; } else { state[k] = !state[k]; }\n        render();\n    });\n    document.addEventListener('keydown', function (e) {\n        if (e.key === 'Escape' && !panel.hidden) { panel.hidden = true; open.setAttribute('aria-expanded', 'false'); open.focus(); }\n    });\n    render();\n})();\n</script>",
            "requires": [],
            "notes": "A toolbar helps some visitors but does not replace fixing accessibility problems in the theme itself."
        },
        {
            "id": "a11y-admin-bar-quick-check",
            "version": 1,
            "title": "Accessibility Quick Check in the Admin Bar",
            "description": "Adds an \"A11y check\" item to the admin bar on the front of the site. Clicking it outlines images without alt text, empty links and buttons, unlabeled fields and skipped headings on the current page and shows a count.",
            "category": "accessibility",
            "tags": [
                "admin-bar",
                "audit",
                "wcag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-a11y-check',\n        'title' => 'A11y check',\n        'href'  => '#wpide-a11y-check',\n    ) );\n}, 90 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! is_admin_bar_showing() || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n    ?>\n<style>.wpide-a11y-flag{outline:3px solid #d63638!important;outline-offset:2px;}.wpide-a11y-flag-warn{outline:3px dashed #dba617!important;outline-offset:2px;}</style>\n<script>\n(function () {\n    var link = document.querySelector('#wp-admin-bar-wpide-a11y-check > a');\n    if (!link) { return; }\n    link.addEventListener('click', function (event) {\n        event.preventDefault();\n        var problems = [];\n        function flag(el, msg, warn) { el.classList.add(warn ? 'wpide-a11y-flag-warn' : 'wpide-a11y-flag'); el.title = msg; problems.push(msg); }\n        var scope = document.body;\n        scope.querySelectorAll('img:not([alt])').forEach(function (el) { flag(el, 'Image without alt attribute'); });\n        scope.querySelectorAll('a[href], button').forEach(function (el) {\n            if (el.closest('#wpadminbar')) { return; }\n            var name = (el.textContent || '').trim() || el.getAttribute('aria-label') || el.getAttribute('title') || (el.querySelector('img[alt]:not([alt=\"\"])') ? 'img' : '');\n            if (!name) { flag(el, 'Link or button without an accessible name'); }\n        });\n        scope.querySelectorAll('input:not([type=hidden]):not([type=submit]):not([type=button]), select, textarea').forEach(function (el) {\n            if (el.closest('#wpadminbar')) { return; }\n            var labelled = el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') || el.closest('label') || (el.id && document.querySelector('label[for=\"' + CSS.escape(el.id) + '\"]'));\n            if (!labelled) { flag(el, 'Form field without a label'); }\n        });\n        var last = 0;\n        scope.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (el) {\n            if (el.closest('#wpadminbar')) { return; }\n            var level = parseInt(el.tagName.substring(1), 10);\n            if (last && level > last + 1) { flag(el, 'Heading skips from H' + last + ' to H' + level, true); }\n            last = level;\n        });\n        if (document.querySelectorAll('h1').length !== 1) { problems.push('Page has ' + document.querySelectorAll('h1').length + ' H1 headings'); }\n        if (!document.documentElement.getAttribute('lang')) { problems.push('The html element has no lang attribute'); }\n        var unique = {};\n        problems.forEach(function (p) { unique[p] = (unique[p] || 0) + 1; });\n        var summary = Object.keys(unique).map(function (p) { return unique[p] + ' x ' + p; }).join('\\n');\n        window.alert(problems.length ? 'Found ' + problems.length + ' possible problem(s), outlined on the page:\\n\\n' + summary : 'No common problems found on this page.');\n    });\n})();\n</script>\n    <?php\n}, 99 );",
            "requires": [],
            "notes": "Only users who can edit posts see the button; it is a quick check, not a replacement for a full audit tool."
        },
        {
            "id": "a11y-name-icon-links",
            "version": 1,
            "title": "Name Icon-Only Links and Buttons From Their Title",
            "description": "Gives links and buttons that contain only an icon an accessible name taken from their title attribute or the icon's label, so screen readers do not announce them as just \"link\" or \"button\".",
            "category": "accessibility",
            "tags": [
                "icons",
                "aria",
                "screen-reader"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function fix(root) {\n        root.querySelectorAll('a, button, [role=\"button\"]').forEach(function (el) {\n            if ((el.textContent || '').trim() || el.getAttribute('aria-label') || el.getAttribute('aria-labelledby')) { return; }\n            var img = el.querySelector('img[alt]:not([alt=\"\"])');\n            if (img) { return; }\n            var svgTitle = el.querySelector('svg title');\n            var name = el.getAttribute('title') || (svgTitle && svgTitle.textContent) || el.getAttribute('data-tooltip') || '';\n            if (!name && el.tagName === 'A' && el.hostname) {\n                var map = { 'facebook.com': 'Facebook', 'x.com': 'X', 'twitter.com': 'X', 'instagram.com': 'Instagram', 'linkedin.com': 'LinkedIn', 'youtube.com': 'YouTube', 'tiktok.com': 'TikTok', 'pinterest.com': 'Pinterest', 'github.com': 'GitHub' };\n                var host = el.hostname.replace(/^www\\./, '');\n                name = map[host] || '';\n            }\n            if (name) {\n                el.setAttribute('aria-label', name.trim());\n                el.querySelectorAll('svg, i, .icon').forEach(function (icon) { icon.setAttribute('aria-hidden', 'true'); });\n            }\n        });\n    }\n    fix(document);\n})();\n</script>",
            "requires": [],
            "notes": "Elements with no title or labelled icon are left alone; add a title or aria-label to those in your theme or page builder."
        },
        {
            "id": "a11y-no-autoplay-embeds",
            "version": 1,
            "title": "Remove Autoplay From Embedded Videos",
            "description": "Strips autoplay settings from YouTube and Vimeo embeds in posts, so videos only start when the visitor presses play.",
            "category": "accessibility",
            "tags": [
                "video",
                "autoplay",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_strip_autoplay = function ( $html ) {\n    if ( false === stripos( $html, 'autoplay' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $html;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $html );\n\n    while ( $tags->next_tag( 'iframe' ) ) {\n        $src = (string) $tags->get_attribute( 'src' );\n\n        if ( preg_match( '#(youtube\\.com|youtube-nocookie\\.com|player\\.vimeo\\.com)#i', $src ) ) {\n            $tags->set_attribute( 'src', remove_query_arg( array( 'autoplay', 'mute', 'muted', 'background' ), $src ) );\n        }\n\n        $allow = (string) $tags->get_attribute( 'allow' );\n        if ( $allow ) {\n            $tags->set_attribute( 'allow', trim( preg_replace( '/\\s*autoplay;?/i', '', $allow ) ) );\n        }\n    }\n\n    return $tags->get_updated_html();\n};\n\nadd_filter( 'embed_oembed_html', $wpide_strip_autoplay, 20 );\nadd_filter( 'the_content', $wpide_strip_autoplay, 20 );",
            "requires": [],
            "notes": "Background videos added as Cover blocks are not embeds and are not changed by this snippet."
        },
        {
            "id": "a11y-allow-pinch-zoom",
            "version": 1,
            "title": "Allow Pinch-to-Zoom on Mobile",
            "description": "Removes maximum-scale and user-scalable=no from the page's viewport tag when a theme or plugin adds them, so visitors can zoom in on phones as WCAG requires.",
            "category": "accessibility",
            "tags": [
                "mobile",
                "zoom",
                "viewport"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    ?>\n<script>\n(function () {\n    function fix() {\n        document.querySelectorAll('meta[name=\"viewport\"]').forEach(function (meta) {\n            var content = meta.getAttribute('content') || '';\n            var cleaned = content.split(',').map(function (part) { return part.trim(); }).filter(function (part) {\n                return part && !/^(maximum-scale|user-scalable)\\s*=/i.test(part);\n            }).join(', ');\n            if (cleaned !== content) { meta.setAttribute('content', cleaned); }\n        });\n    }\n    fix();\n    document.addEventListener('DOMContentLoaded', fix);\n})();\n</script>\n    <?php\n}, 99 );",
            "requires": [],
            "notes": "Runs early in the page head and only touches the viewport meta tag."
        },
        {
            "id": "a11y-announce-search-results",
            "version": 1,
            "title": "Announce the Number of Search Results",
            "description": "On search result pages, tells screen reader users how many results were found for their search as soon as the page loads.",
            "category": "accessibility",
            "tags": [
                "search",
                "screen-reader",
                "aria-live"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! is_search() ) {\n        return;\n    }\n\n    global $wp_query;\n    $count   = (int) $wp_query->found_posts;\n    $message = $count\n        ? sprintf( _n( '%1$s result found for \"%2$s\".', '%1$s results found for \"%2$s\".', $count ), number_format_i18n( $count ), get_search_query( false ) )\n        : sprintf( 'No results found for \"%s\".', get_search_query( false ) );\n\n    printf(\n        '<div id=\"wpide-search-status\" class=\"screen-reader-text\" role=\"status\" aria-live=\"polite\" style=\"position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);\"></div><script>setTimeout(function(){var s=document.getElementById(\"wpide-search-status\");if(s){s.textContent=%s;}},400);</script>',\n        wp_json_encode( $message )\n    );\n} );",
            "requires": [],
            "notes": "The message is read from a visually hidden live region; sighted visitors see no change."
        },
        {
            "id": "a11y-document-link-info",
            "version": 1,
            "title": "Show File Type and Size on Document Links",
            "description": "Adds the file type and size, such as \"(PDF, 2.4 MB)\", after links to PDF, Word, Excel and PowerPoint files in your content, so visitors know what they are about to download.",
            "category": "accessibility",
            "tags": [
                "links",
                "pdf",
                "downloads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Extensions to label, with the name shown to visitors.\n$wpide_doc_types = array(\n    'pdf'  => 'PDF',\n    'doc'  => 'Word',\n    'docx' => 'Word',\n    'xls'  => 'Excel',\n    'xlsx' => 'Excel',\n    'ppt'  => 'PowerPoint',\n    'pptx' => 'PowerPoint',\n    'zip'  => 'ZIP',\n);\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_doc_types ) {\n    $pattern = '#<a\\b([^>]*\\bhref=[\"\\']([^\"\\']+\\.(' . implode( '|', array_keys( $wpide_doc_types ) ) . '))(?:\\?[^\"\\']*)?[\"\\'][^>]*)>(.*?)</a>#is';\n\n    if ( ! preg_match( '#\\.(' . implode( '|', array_keys( $wpide_doc_types ) ) . ')\\b#i', $content ) ) {\n        return $content;\n    }\n\n    $uploads = wp_get_upload_dir();\n\n    return preg_replace_callback( $pattern, function ( $m ) use ( $wpide_doc_types, $uploads ) {\n        if ( false !== strpos( $m[4], 'wpide-file-info' ) ) {\n            return $m[0];\n        }\n\n        $type = $wpide_doc_types[ strtolower( $m[3] ) ];\n        $info = $type;\n        $url  = set_url_scheme( $m[2], 'https' );\n        $base = set_url_scheme( $uploads['baseurl'], 'https' );\n\n        if ( 0 === strpos( $url, $base ) ) {\n            $path = $uploads['basedir'] . substr( $url, strlen( $base ) );\n            if ( false === strpos( $path, '..' ) && is_readable( $path ) ) {\n                $info .= ', ' . size_format( filesize( $path ), 1 );\n            }\n        }\n\n        return '<a' . $m[1] . '>' . $m[4] . ' <span class=\"wpide-file-info\">(' . esc_html( $info ) . ')</span></a>';\n    }, $content );\n}, 20 );",
            "requires": [],
            "notes": "The size is only shown for files stored in your uploads folder; links to other sites get the file type only."
        },
        {
            "id": "a11y-focus-first-invalid-field",
            "version": 1,
            "title": "Move Focus to the First Invalid Form Field",
            "description": "When a visitor submits a form with missing or invalid fields, moves keyboard focus to the first problem field and announces the browser's error message to screen readers.",
            "category": "accessibility",
            "tags": [
                "forms",
                "validation",
                "focus"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<div id=\"wpide-form-errors\" role=\"alert\" style=\"position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);\"></div>\n<script>\n(function () {\n    var region = document.getElementById('wpide-form-errors');\n    var handled = false;\n    document.addEventListener('invalid', function (event) {\n        var field = event.target;\n        if (handled) { return; }\n        handled = true;\n        setTimeout(function () { handled = false; }, 100);\n        var label = '';\n        if (field.labels && field.labels[0]) { label = field.labels[0].textContent.trim(); }\n        label = label || field.getAttribute('aria-label') || field.name || 'A field';\n        if (region) { region.textContent = label + ': ' + field.validationMessage; }\n        field.setAttribute('aria-invalid', 'true');\n        field.focus();\n        field.addEventListener('input', function () { if (field.checkValidity()) { field.removeAttribute('aria-invalid'); } }, { once: true });\n    }, true);\n})();\n</script>",
            "requires": [],
            "notes": "Uses the browser's built-in form validation, so it works with any form that uses required, type=\"email\" and similar attributes."
        },
        {
            "id": "a11y-media-modal-alt-highlight",
            "version": 1,
            "title": "Highlight Empty Alt Text Fields in the Media Modal",
            "description": "Outlines the Alternative Text field in red when it is empty in the media library and media modal, so authors notice it before inserting an image.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "media-library",
                "admin"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "admin_head",
            "insert_number": 1,
            "code": ".attachment-details .setting.alt-text textarea:placeholder-shown,\n.attachment-details [data-setting=\"alt\"] textarea:placeholder-shown,\n#attachment_alt:placeholder-shown {\n    border: 2px solid #d63638 !important;\n    background: #fcf0f1 !important;\n}\n\n.attachment-details [data-setting=\"alt\"] .name::after,\n.setting.alt-text .name::after {\n    content: \" (needed)\";\n    color: #d63638;\n    font-weight: 600;\n}",
            "requires": [],
            "notes": "Pure CSS; it reminds but does not block inserting images."
        },
        {
            "id": "a11y-youtube-captions-on",
            "version": 1,
            "title": "Turn On Captions by Default in YouTube Embeds",
            "description": "Adds YouTube's captions setting to embedded videos, so subtitles are shown automatically whenever the video has them.",
            "category": "accessibility",
            "tags": [
                "youtube",
                "captions",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Preferred caption language ('' lets YouTube decide).\n$wpide_caption_lang = '';\n\nadd_filter( 'embed_oembed_html', function ( $html, $url ) use ( $wpide_caption_lang ) {\n    if ( ! preg_match( '#youtu\\.?be#i', $url ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $html;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $html );\n\n    if ( $tags->next_tag( 'iframe' ) ) {\n        $args = array( 'cc_load_policy' => 1 );\n        if ( $wpide_caption_lang ) {\n            $args['cc_lang_pref'] = $wpide_caption_lang;\n        }\n        $tags->set_attribute( 'src', add_query_arg( $args, (string) $tags->get_attribute( 'src' ) ) );\n    }\n\n    return $tags->get_updated_html();\n}, 10, 2 );",
            "requires": [],
            "notes": "Change $wpide_caption_lang to a language code such as en or fr to prefer that caption language."
        },
        {
            "id": "a11y-widget-titles-as-headings",
            "version": 1,
            "title": "Use Real Headings for Widget Titles",
            "description": "Makes classic sidebar widget titles proper H2 headings, so screen reader users can jump between widgets with heading navigation.",
            "category": "accessibility",
            "tags": [
                "widgets",
                "headings",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Heading level used for widget titles.\n$wpide_widget_heading = 'h2';\n\nadd_filter( 'dynamic_sidebar_params', function ( $params ) use ( $wpide_widget_heading ) {\n    if ( empty( $params[0] ) || ! is_array( $params[0] ) ) {\n        return $params;\n    }\n\n    $tag = tag_escape( $wpide_widget_heading );\n\n    if ( isset( $params[0]['before_title'] ) && ! preg_match( '#^\\s*<h[1-6]#i', $params[0]['before_title'] ) ) {\n        $params[0]['before_title'] = '<' . $tag . ' class=\"widget-title widgettitle\">';\n        $params[0]['after_title']  = '</' . $tag . '>';\n    }\n\n    return $params;\n} );",
            "requires": [],
            "notes": "Change $wpide_widget_heading to h3 if your theme already uses H2 for the main sections around the sidebar."
        },
        {
            "id": "a11y-main-landmark-fallback",
            "version": 1,
            "title": "Add a Main Landmark When the Theme Has None",
            "description": "Marks the theme's main content area with role=\"main\" when the page has no main element, so screen reader users can jump straight to the content.",
            "category": "accessibility",
            "tags": [
                "landmarks",
                "screen-reader",
                "theme"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    if (document.querySelector('main, [role=\"main\"]')) { return; }\n    var area = document.querySelector('#primary, #content, .site-content, .content-area, #main, .entry-content');\n    if (area) {\n        area.setAttribute('role', 'main');\n        if (!area.id) { area.id = 'wpide-main'; }\n    }\n})();\n</script>",
            "requires": [],
            "notes": "Add your theme's content wrapper to the selector list if it uses a different class or id."
        },
        {
            "id": "a11y-readable-tag-cloud",
            "version": 1,
            "title": "Readable Tag Cloud Sizes",
            "description": "Keeps the smallest tags in tag cloud widgets and blocks large enough to read and tap, and sizes them in relative units so they grow with the visitor's text size.",
            "category": "accessibility",
            "tags": [
                "tag-cloud",
                "font-size",
                "widgets"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_tag_cloud_args', function ( $args ) {\n    $args['smallest'] = 0.9;\n    $args['largest']  = 1.5;\n    $args['unit']     = 'em';\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Change the smallest and largest values (in em) to fit your design."
        },
        {
            "id": "a11y-hide-decorative-svg",
            "version": 1,
            "title": "Hide Decorative SVG Icons From Screen Readers",
            "description": "Adds aria-hidden to inline SVG icons that sit next to visible text inside links and buttons, so screen readers stop announcing meaningless graphic names.",
            "category": "accessibility",
            "tags": [
                "svg",
                "icons",
                "screen-reader"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    document.querySelectorAll('a svg, button svg').forEach(function (svg) {\n        if (svg.hasAttribute('aria-hidden') || svg.getAttribute('role') === 'img' || svg.hasAttribute('aria-label')) { return; }\n        var parent = svg.closest('a, button');\n        var text = parent ? parent.textContent.replace(svg.textContent || '', '').trim() : '';\n        if (text || (parent && parent.getAttribute('aria-label'))) {\n            svg.setAttribute('aria-hidden', 'true');\n            svg.setAttribute('focusable', 'false');\n        }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Icons that are the only content of a link are left alone so the link keeps a name."
        },
        {
            "id": "a11y-aria-current-links",
            "version": 1,
            "title": "Mark the Current Page in Menus Built With Blocks and Widgets",
            "description": "Adds aria-current=\"page\" to any link on the page that points to the page being viewed, for menus, widget link lists and footer links that do not mark it themselves.",
            "category": "accessibility",
            "tags": [
                "navigation",
                "aria",
                "links"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    var here = location.origin + location.pathname.replace(/\\/$/, '');\n    document.querySelectorAll('nav a[href], .widget a[href], footer a[href]').forEach(function (a) {\n        if (a.hasAttribute('aria-current') || !a.href) { return; }\n        var url;\n        try { url = new URL(a.href); } catch (e) { return; }\n        if (url.hash && url.pathname === location.pathname) { return; }\n        if (url.origin + url.pathname.replace(/\\/$/, '') === here) { a.setAttribute('aria-current', 'page'); }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Links that already carry aria-current are not changed."
        },
        {
            "id": "a11y-dialog-focus-return",
            "version": 1,
            "title": "Return Focus After Closing Popups",
            "description": "Remembers which element opened a popup or modal dialog and moves keyboard focus back to it when the dialog closes, a common gap in theme and plugin popups.",
            "category": "accessibility",
            "tags": [
                "modals",
                "focus",
                "keyboard"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    var opener = null;\n    document.addEventListener('click', function (event) {\n        var el = event.target.closest('a, button, [tabindex]');\n        if (el && !el.closest('dialog, [role=\"dialog\"], [aria-modal=\"true\"]')) { opener = el; }\n    }, true);\n    function visible(d) {\n        return d.isConnected && d.getAttribute('aria-hidden') !== 'true' && !d.hidden && (d.tagName !== 'DIALOG' || d.open) && d.getClientRects().length > 0;\n    }\n    var open = [];\n    var pending = false;\n    function check() {\n        pending = false;\n        var now = Array.prototype.filter.call(document.querySelectorAll('dialog, [role=\"dialog\"], [aria-modal=\"true\"]'), visible);\n        var closed = open.filter(function (d) { return now.indexOf(d) === -1; });\n        if (closed.length && opener && document.contains(opener)) {\n            var active = document.activeElement;\n            if (!active || active === document.body || !document.contains(active) || closed.some(function (d) { return d.contains(active); })) { opener.focus(); }\n        }\n        open = now;\n    }\n    new MutationObserver(function () {\n        if (!pending) { pending = true; requestAnimationFrame(check); }\n    }).observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['open', 'hidden', 'aria-hidden', 'class', 'style'] });\n})();\n</script>",
            "requires": [],
            "notes": "Works with dialogs that use the dialog element or role=\"dialog\" and are hidden by removing them, hiding them or toggling aria-hidden."
        },
        {
            "id": "a11y-caption-as-alt-fallback",
            "version": 1,
            "title": "Use Image Captions as Alt Text When Alt Is Empty",
            "description": "For image blocks that have a caption but no alternative text, uses the caption text as the alt attribute, so the image is not announced as unlabeled.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "captions",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'render_block_core/image', function ( $html ) {\n    if ( false === stripos( $html, '<figcaption' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $html;\n    }\n\n    if ( ! preg_match( '#<figcaption[^>]*>(.*?)</figcaption>#is', $html, $m ) ) {\n        return $html;\n    }\n\n    $caption = trim( wp_strip_all_tags( $m[1] ) );\n    $tags    = new WP_HTML_Tag_Processor( $html );\n\n    if ( $caption && $tags->next_tag( 'img' ) && '' === trim( (string) $tags->get_attribute( 'alt' ) ) ) {\n        $tags->set_attribute( 'alt', wp_html_excerpt( $caption, 150, '' ) );\n        return $tags->get_updated_html();\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "A caption and alt text ideally say different things; treat this as a fallback and write real alt text where you can."
        },
        {
            "id": "a11y-remove-duplicate-image-titles",
            "version": 1,
            "title": "Remove Image Tooltips That Repeat the Alt Text",
            "description": "Removes the title attribute from images in your content when it is the same as the alt text, so screen readers do not read the same description twice.",
            "category": "accessibility",
            "tags": [
                "images",
                "title-attribute",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, 'title=' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'img' ) ) {\n        $title = trim( (string) $tags->get_attribute( 'title' ) );\n\n        if ( '' !== $title && strtolower( $title ) === strtolower( trim( (string) $tags->get_attribute( 'alt' ) ) ) ) {\n            $tags->remove_attribute( 'title' );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Titles that add different information are kept."
        },
        {
            "id": "a11y-search-form-label",
            "version": 1,
            "title": "Give Theme Search Forms an Accessible Label",
            "description": "Adds an accessible name to the search field of theme search forms that have no label, so screen reader users hear \"Search\" instead of an unlabeled text field.",
            "category": "accessibility",
            "tags": [
                "search",
                "forms",
                "labels"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Accessible name for the search field.\n$wpide_search_label = 'Search this site';\n\nadd_filter( 'get_search_form', function ( $form ) use ( $wpide_search_label ) {\n    if ( false !== stripos( $form, '<label' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $form;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $form );\n\n    while ( $tags->next_tag( 'input' ) ) {\n        if ( ( 's' === $tags->get_attribute( 'name' ) || 'search' === $tags->get_attribute( 'type' ) ) && ! $tags->get_attribute( 'aria-label' ) ) {\n            $tags->set_attribute( 'aria-label', $wpide_search_label );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Change $wpide_search_label to translate the label."
        },
        {
            "id": "a11y-woo-product-image-alt",
            "version": 1,
            "title": "Product Name as Alt Text for WooCommerce Images Without One",
            "description": "When a product image in the shop, on category pages or on the product page has no alternative text, uses the product name instead of leaving it empty.",
            "category": "accessibility",
            "tags": [
                "woocommerce",
                "alt-text",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_get_attachment_image_attributes', function ( $attr, $attachment ) {\n    if ( '' !== trim( (string) ( isset( $attr['alt'] ) ? $attr['alt'] : '' ) ) || ! function_exists( 'wc_get_product' ) ) {\n        return $attr;\n    }\n\n    $post_id = get_the_ID();\n\n    if ( $post_id && 'product' === get_post_type( $post_id ) ) {\n        $attr['alt'] = wp_strip_all_tags( get_the_title( $post_id ) );\n    } elseif ( $attachment && $attachment->post_parent && 'product' === get_post_type( $attachment->post_parent ) ) {\n        $attr['alt'] = wp_strip_all_tags( get_the_title( $attachment->post_parent ) );\n    }\n\n    return $attr;\n}, 20, 2 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Images with their own alt text in the media library are not changed."
        },
        {
            "id": "a11y-editor-larger-text-option",
            "version": 1,
            "title": "\"Larger Text in the Editor\" Profile Option",
            "description": "Adds a checkbox to each user's profile that makes the text in the block editor larger and more spaced out, for authors who find the default editor text hard to read.",
            "category": "accessibility",
            "tags": [
                "block-editor",
                "font-size",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'personal_options', function ( $user ) {\n    printf(\n        '<tr><th scope=\"row\">Editor text</th><td><label><input type=\"checkbox\" name=\"wpide_editor_large\" value=\"1\" %s> Show larger text in the block editor</label></td></tr>',\n        checked( get_user_meta( $user->ID, 'wpide_editor_large', true ), '1', false )\n    );\n} );\n\n$wpide_save_editor_pref = function ( $user_id ) {\n    if ( current_user_can( 'edit_user', $user_id ) && isset( $_POST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_POST['_wpnonce'] ), 'update-user_' . $user_id ) ) {\n        update_user_meta( $user_id, 'wpide_editor_large', empty( $_POST['wpide_editor_large'] ) ? '0' : '1' );\n    }\n};\nadd_action( 'personal_options_update', $wpide_save_editor_pref );\nadd_action( 'edit_user_profile_update', $wpide_save_editor_pref );\n\nadd_filter( 'block_editor_settings_all', function ( $settings ) {\n    if ( '1' === get_user_meta( get_current_user_id(), 'wpide_editor_large', true ) ) {\n        $settings['styles'][] = array( 'css' => 'body{font-size:22px!important;line-height:1.7!important;}p,li{letter-spacing:.01em;}' );\n    }\n\n    return $settings;\n}, 20 );",
            "requires": [],
            "notes": "Only changes how content looks while editing; the published page is not affected."
        },
        {
            "id": "a11y-admin-high-contrast-mode",
            "version": 1,
            "title": "High-Contrast Mode for wp-admin",
            "description": "Adds a \"High contrast\" checkbox to each user's profile that switches the dashboard to dark backgrounds, white text, yellow links and strong borders for that user only.",
            "category": "accessibility",
            "tags": [
                "admin",
                "contrast",
                "low-vision"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'personal_options', function ( $user ) {\n    printf(\n        '<tr><th scope=\"row\">High contrast</th><td><label><input type=\"checkbox\" name=\"wpide_admin_contrast\" value=\"1\" %s> Use high-contrast colors in the dashboard</label></td></tr>',\n        checked( get_user_meta( $user->ID, 'wpide_admin_contrast', true ), '1', false )\n    );\n} );\n\n$wpide_save_contrast = function ( $user_id ) {\n    if ( current_user_can( 'edit_user', $user_id ) && isset( $_POST['_wpnonce'] ) && wp_verify_nonce( sanitize_key( $_POST['_wpnonce'] ), 'update-user_' . $user_id ) ) {\n        update_user_meta( $user_id, 'wpide_admin_contrast', empty( $_POST['wpide_admin_contrast'] ) ? '0' : '1' );\n    }\n};\nadd_action( 'personal_options_update', $wpide_save_contrast );\nadd_action( 'edit_user_profile_update', $wpide_save_contrast );\n\nadd_action( 'admin_head', function () {\n    if ( '1' !== get_user_meta( get_current_user_id(), 'wpide_admin_contrast', true ) ) {\n        return;\n    }\n    ?>\n<style>\n#wpcontent,#wpbody-content,.wrap,.postbox,.card,.widefat,.widefat td,.widefat th,.form-table th,.form-table td,.notice{background:#000!important;color:#fff!important;border-color:#fff!important;}\n#wpcontent a,.wrap a,.row-actions a{color:#ffeb3b!important;text-decoration:underline!important;}\n.wrap input,.wrap select,.wrap textarea{background:#000!important;color:#fff!important;border:2px solid #fff!important;}\n.button,.button-primary,.button-secondary{background:#000!important;color:#ffeb3b!important;border:2px solid #ffeb3b!important;}\n.widefat tr:nth-child(odd) td{background:#111!important;}\n#adminmenu,#adminmenuback,#adminmenuwrap{background:#000!important;}\n#adminmenu a{color:#fff!important;}\n#adminmenu .current a,#adminmenu a:focus,#adminmenu a:hover{color:#000!important;background:#ffeb3b!important;}\n:focus-visible{outline:3px solid #ffeb3b!important;outline-offset:2px!important;}\n</style>\n    <?php\n} );",
            "requires": [],
            "notes": "Plugin screens with their own styling may not follow every rule; the block editor canvas keeps the theme's colors."
        },
        {
            "id": "a11y-transcript-shortcode",
            "version": 1,
            "title": "Collapsible Transcript Shortcode for Audio and Video",
            "description": "Adds a [transcript] shortcode that wraps the text version of a podcast or video in an accessible show/hide section, so every recording can have a transcript without cluttering the page.",
            "category": "accessibility",
            "tags": [
                "transcripts",
                "shortcode",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'transcript', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'title' => 'Transcript', 'open' => 'no' ), $atts, 'transcript' );\n\n    if ( '' === trim( (string) $content ) ) {\n        return '';\n    }\n\n    return sprintf(\n        '<details class=\"wpide-transcript\"%1$s style=\"margin:1em 0;padding:.75em 1em;border:1px solid #c3c4c7;border-radius:6px;\"><summary style=\"cursor:pointer;font-weight:600;\">%2$s</summary><div class=\"wpide-transcript-text\" style=\"margin-top:.75em;\">%3$s</div></details>',\n        'yes' === $atts['open'] ? ' open' : '',\n        esc_html( $atts['title'] ),\n        wpautop( do_shortcode( wp_kses_post( $content ) ) )\n    );\n} );",
            "requires": [],
            "notes": "Use [transcript title=\"Read the interview transcript\"]...[/transcript]; the section uses the native details element, so it works with keyboards and screen readers."
        },
        {
            "id": "a11y-hide-empty-headings",
            "version": 1,
            "title": "Hide Empty Headings From Screen Readers",
            "description": "Finds heading tags on the page that contain no text (often left behind by page builders and themes) and hides them from screen readers, so they do not show up as blank entries in the heading list.",
            "category": "accessibility",
            "tags": [
                "headings",
                "screen-reader",
                "page-builders"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    document.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (h) {\n        var text = (h.textContent || '').replace(/ /g, ' ').trim();\n        var img = h.querySelector('img[alt]:not([alt=\"\"]), svg[aria-label], [aria-label]');\n        if (!text && !img) { h.setAttribute('aria-hidden', 'true'); }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Fix empty headings at the source when you can; this keeps them from confusing screen reader users meanwhile."
        },
        {
            "id": "a11y-unstick-header-when-zoomed",
            "version": 1,
            "title": "Unstick Fixed Headers When Pages Are Zoomed In",
            "description": "Turns sticky and fixed headers into normal headers when the visible screen area is very small, such as at 400% browser zoom or on landscape phones, so they no longer cover most of the content.",
            "category": "accessibility",
            "tags": [
                "zoom",
                "sticky-header",
                "reflow"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (max-height: 480px), (max-width: 320px) {\n    .site-header,\n    #masthead,\n    header.wp-block-template-part,\n    .sticky-header,\n    .is-position-sticky,\n    .elementor-sticky--active,\n    header[class*=\"sticky\"],\n    header[class*=\"fixed\"] {\n        position: static !important;\n        top: auto !important;\n    }\n\n    body {\n        padding-top: 0 !important;\n    }\n}",
            "requires": [],
            "notes": "Add your theme's header selector if it is not in the list."
        },
        {
            "id": "a11y-readable-line-length",
            "version": 1,
            "title": "Comfortable Line Length for Reading",
            "description": "Limits paragraphs, lists and quotes in posts to about 75 characters per line, the width recommended for readability, while wide blocks such as images and tables keep the full width.",
            "category": "accessibility",
            "tags": [
                "readability",
                "typography",
                "css"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".entry-content > p,\n.entry-content > ul,\n.entry-content > ol,\n.entry-content > blockquote,\n.wp-block-post-content > p,\n.wp-block-post-content > ul,\n.wp-block-post-content > ol,\n.wp-block-post-content > blockquote {\n    max-width: 75ch;\n    margin-left: auto;\n    margin-right: auto;\n}",
            "requires": [],
            "notes": "Change 75ch to taste; 60 to 80 characters is a good range."
        },
        {
            "id": "a11y-gif-click-to-play",
            "version": 1,
            "title": "Pause Animated GIFs Until Clicked",
            "description": "Shows the first frame of every animated GIF in your content with a play button, and only plays the animation when the visitor clicks, so moving images never start on their own.",
            "category": "accessibility",
            "tags": [
                "gif",
                "animation",
                "wcag"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-gif{position:relative;display:inline-block;max-width:100%;}\n.wpide-gif canvas,.wpide-gif img{max-width:100%;height:auto;display:block;}\n.wpide-gif button{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);padding:10px 16px;border:2px solid #fff;border-radius:6px;background:rgba(0,0,0,.75);color:#fff;font-size:15px;cursor:pointer;}\n.wpide-gif button:focus-visible{outline:3px solid #ffeb3b;outline-offset:2px;}\n</style>\n<script>\n(function () {\n    function freeze(img) {\n        if (img.dataset.wpideGif) { return; }\n        img.dataset.wpideGif = '1';\n        var canvas = document.createElement('canvas');\n        canvas.width = img.naturalWidth;\n        canvas.height = img.naturalHeight;\n        canvas.setAttribute('role', 'img');\n        canvas.setAttribute('aria-label', img.alt || 'Animated image');\n        try { canvas.getContext('2d').drawImage(img, 0, 0); } catch (e) { return; }\n        var box = document.createElement('span');\n        box.className = 'wpide-gif';\n        var btn = document.createElement('button');\n        btn.type = 'button';\n        btn.textContent = 'Play animation';\n        btn.setAttribute('aria-pressed', 'false');\n        img.parentNode.insertBefore(box, img);\n        box.appendChild(canvas);\n        box.appendChild(btn);\n        img.style.display = 'none';\n        box.appendChild(img);\n        btn.addEventListener('click', function () {\n            var playing = btn.getAttribute('aria-pressed') === 'true';\n            btn.setAttribute('aria-pressed', playing ? 'false' : 'true');\n            btn.textContent = playing ? 'Play animation' : 'Pause animation';\n            canvas.style.display = playing ? 'block' : 'none';\n            img.style.display = playing ? 'none' : 'block';\n            if (!playing) { var src = img.src; img.src = ''; img.src = src; }\n        });\n    }\n    document.querySelectorAll('.entry-content img[src*=\".gif\"], .wp-block-post-content img[src*=\".gif\"]').forEach(function (img) {\n        if (img.complete && img.naturalWidth) { freeze(img); } else { img.addEventListener('load', function () { freeze(img); }, { once: true }); }\n    });\n})();\n</script>",
            "requires": [],
            "notes": "GIFs loaded from other domains still work; clicking again pauses the animation."
        },
        {
            "id": "a11y-wp-cli-content-scan",
            "version": 1,
            "title": "WP-CLI Command to Scan Content for Accessibility Problems",
            "description": "Adds a \"wp a11y-scan\" command that checks published posts and pages for images without alt text, empty links, iframes without titles, positive tabindex and skipped heading levels, and prints a table you can export.",
            "category": "accessibility",
            "tags": [
                "wp-cli",
                "audit",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( defined( 'WP_CLI' ) && WP_CLI && class_exists( 'WP_HTML_Tag_Processor' ) ) {\n    WP_CLI::add_command( 'a11y-scan', function ( $args, $assoc ) {\n        $type   = isset( $assoc['post_type'] ) ? sanitize_key( $assoc['post_type'] ) : 'post,page';\n        $format = isset( $assoc['format'] ) ? $assoc['format'] : 'table';\n        $rows   = array();\n        $paged  = 1;\n\n        do {\n            $posts = get_posts( array(\n                'post_type'      => explode( ',', $type ),\n                'post_status'    => 'publish',\n                'posts_per_page' => 100,\n                'paged'          => $paged++,\n                'orderby'        => 'ID',\n                'order'          => 'ASC',\n            ) );\n\n            foreach ( $posts as $post ) {\n                $issues = array();\n                $tags   = new WP_HTML_Tag_Processor( $post->post_content );\n                $level  = 1;\n                while ( $tags->next_tag() ) {\n                    $tag = $tags->get_tag();\n                    if ( 'IMG' === $tag && null === $tags->get_attribute( 'alt' ) ) {\n                        $issues['img'] = 'image without alt';\n                    } elseif ( 'IFRAME' === $tag && ! $tags->get_attribute( 'title' ) ) {\n                        $issues['iframe'] = 'iframe without title';\n                    } elseif ( preg_match( '/^H([1-6])$/', (string) $tag, $m ) ) {\n                        if ( (int) $m[1] > $level + 1 ) {\n                            $issues['heading'] = 'skipped heading level';\n                        }\n                        $level = (int) $m[1];\n                    }\n                    if ( (int) $tags->get_attribute( 'tabindex' ) > 0 ) {\n                        $issues['tabindex'] = 'positive tabindex';\n                    }\n                }\n                if ( preg_match( '#<a\\b[^>]*>\\s*</a>#i', $post->post_content ) ) {\n                    $issues['link'] = 'empty link';\n                }\n                if ( $issues ) {\n                    $rows[] = array( 'ID' => $post->ID, 'type' => $post->post_type, 'title' => $post->post_title, 'issues' => implode( '; ', $issues ), 'url' => get_permalink( $post ) );\n                }\n            }\n        } while ( count( $posts ) === 100 );\n\n        if ( ! $rows ) {\n            WP_CLI::success( 'No problems found.' );\n            return;\n        }\n\n        WP_CLI\\Utils\\format_items( $format, $rows, array( 'ID', 'type', 'title', 'issues', 'url' ) );\n    }, array( 'shortdesc' => 'Scan published content for common accessibility problems.' ) );\n}",
            "requires": [],
            "notes": "Run \"wp a11y-scan --post_type=page --format=csv > report.csv\"; it reads posts in batches so it works on large sites."
        },
        {
            "id": "a11y-content-report-page",
            "version": 1,
            "title": "Accessibility Report for All Posts and Pages",
            "description": "Adds Tools > Accessibility Report, which checks your published posts and pages for images without alt text, empty links, iframes without titles and skipped heading levels, and lists each problem with an edit link.",
            "category": "accessibility",
            "tags": [
                "audit",
                "report",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Accessibility Report', 'Accessibility Report', 'edit_others_posts', 'wpide-a11y-report', function () {\n        $paged = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 );\n        $query = new WP_Query( array(\n            'post_type'      => array( 'post', 'page' ),\n            'post_status'    => 'publish',\n            'posts_per_page' => 100,\n            'paged'          => $paged,\n            'orderby'        => 'modified',\n        ) );\n\n        echo '<div class=\"wrap\"><h1>Accessibility Report</h1><table class=\"widefat striped\"><thead><tr><th>Content</th><th>Problems</th></tr></thead><tbody>';\n        $found = 0;\n        foreach ( $query->posts as $post ) {\n            $issues = array();\n            if ( class_exists( 'WP_HTML_Tag_Processor' ) ) {\n                $tags  = new WP_HTML_Tag_Processor( $post->post_content );\n                $level = 1;\n                while ( $tags->next_tag() ) {\n                    $tag = $tags->get_tag();\n                    if ( 'IMG' === $tag && null === $tags->get_attribute( 'alt' ) ) {\n                        $issues['img'] = 'image without alt text';\n                    } elseif ( 'IFRAME' === $tag && ! $tags->get_attribute( 'title' ) ) {\n                        $issues['iframe'] = 'embed without a title';\n                    } elseif ( preg_match( '/^H([1-6])$/', (string) $tag, $m ) ) {\n                        if ( (int) $m[1] > $level + 1 ) {\n                            $issues['heading'] = 'skipped heading level';\n                        }\n                        $level = (int) $m[1];\n                    }\n                }\n            }\n            if ( preg_match( '#<a\\b[^>]*>\\s*</a>#i', $post->post_content ) ) {\n                $issues['link'] = 'empty link';\n            }\n            if ( ! $issues ) {\n                continue;\n            }\n            $found++;\n            printf( '<tr><td><a href=\"%s\">%s</a> <span class=\"description\">(%s)</span></td><td>%s</td></tr>', esc_url( get_edit_post_link( $post->ID ) ), esc_html( $post->post_title ? $post->post_title : '#' . $post->ID ), esc_html( $post->post_type ), esc_html( implode( ', ', $issues ) ) );\n        }\n        if ( ! $found ) {\n            echo '<tr><td colspan=\"2\">No problems found on this page of results.</td></tr>';\n        }\n        echo '</tbody></table>';\n        if ( $query->max_num_pages > 1 ) {\n            echo '<p>' . wp_kses_post( paginate_links( array( 'base' => add_query_arg( 'paged', '%#%' ), 'format' => '', 'current' => $paged, 'total' => $query->max_num_pages ) ) ) . '</p>';\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Checks 100 items per screen with \"Next\" links for large sites; results reflect the saved content, not theme or plugin output."
        },
        {
            "id": "a11y-stack-tables-on-phones",
            "version": 1,
            "title": "Stack Tables Into Labeled Cards on Phones",
            "description": "On small screens, turns each row of the tables in your content into a card where every value is shown with its column heading, so wide tables can be read without sideways scrolling.",
            "category": "accessibility",
            "tags": [
                "tables",
                "mobile",
                "responsive"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n@media (max-width: 600px) {\n    table.wpide-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }\n    table.wpide-stack, table.wpide-stack tbody, table.wpide-stack tr, table.wpide-stack td, table.wpide-stack th[scope=\"row\"] { display: block; width: 100%; }\n    table.wpide-stack tr { margin: 0 0 1em; border: 1px solid #ccc; border-radius: 6px; padding: .5em; }\n    table.wpide-stack td { border: 0; padding: .25em 0; }\n    table.wpide-stack td[data-label]::before { content: attr(data-label) \": \"; font-weight: 700; }\n}\n</style>\n<script>\n(function () {\n    document.querySelectorAll('.entry-content table, .wp-block-post-content table').forEach(function (table) {\n        var headRow = table.tHead ? table.tHead.rows[0] : null;\n        var bodyRows = table.tBodies.length ? Array.prototype.slice.call(table.tBodies[0].rows) : [];\n        if (!headRow && bodyRows.length && bodyRows[0].querySelectorAll('th').length === bodyRows[0].cells.length) {\n            headRow = bodyRows.shift();\n            headRow.classList.add('wpide-stack-head');\n        }\n        if (!headRow) { return; }\n        var labels = Array.prototype.map.call(headRow.cells, function (c) { return c.textContent.trim(); });\n        bodyRows.forEach(function (row) {\n            Array.prototype.forEach.call(row.cells, function (cell, i) {\n                if (labels[i] && !cell.hasAttribute('data-label')) { cell.setAttribute('data-label', labels[i]); }\n            });\n        });\n        table.classList.add('wpide-stack');\n    });\n})();\n</script>",
            "requires": [],
            "notes": "Only tables with a header row are changed; the table keeps its real structure for screen readers."
        },
        {
            "id": "a11y-forced-colors-fixes",
            "version": 1,
            "title": "Fixes for Windows High Contrast (Forced Colors) Mode",
            "description": "Makes buttons, focus outlines, icons and custom checkboxes stay visible when visitors use Windows High Contrast or another forced-colors mode, where many themes lose borders and icons.",
            "category": "accessibility",
            "tags": [
                "high-contrast",
                "css",
                "windows"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (forced-colors: active) {\n    button,\n    .button,\n    .wp-block-button__link,\n    .wp-element-button,\n    input[type=\"submit\"] {\n        border: 2px solid ButtonText !important;\n    }\n\n    a:focus-visible,\n    button:focus-visible,\n    input:focus-visible,\n    select:focus-visible,\n    textarea:focus-visible {\n        outline: 3px solid Highlight !important;\n        outline-offset: 2px;\n    }\n\n    svg {\n        forced-color-adjust: auto;\n        fill: currentColor;\n    }\n\n    .screen-reader-text:focus,\n    .skip-link:focus {\n        border: 2px solid CanvasText;\n    }\n}",
            "requires": [],
            "notes": "Only applies when the visitor's system forces its own colors; nobody else sees a difference."
        },
        {
            "id": "a11y-elementor-reduced-motion",
            "version": 1,
            "title": "Respect \"Reduce Motion\" in Elementor Animations",
            "description": "Shows Elementor content right away without entrance animations, motion effects or background slideshows for visitors whose device asks for reduced motion.",
            "category": "accessibility",
            "tags": [
                "elementor",
                "reduced-motion",
                "animation"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: reduce) {\n    .elementor-invisible {\n        visibility: visible !important;\n    }\n\n    .elementor-element.animated,\n    .elementor-element .animated,\n    .elementor-motion-effects-element,\n    .elementor-background-slideshow__slide__image {\n        animation: none !important;\n        transition: none !important;\n        transform: none !important;\n    }\n}",
            "requires": [
                "elementor"
            ],
            "notes": "Visitors without the reduced-motion setting still see your animations."
        },
        {
            "id": "a11y-divi-reduced-motion",
            "version": 1,
            "title": "Respect \"Reduce Motion\" in Divi Animations",
            "description": "Turns off Divi's entrance animations, parallax and scroll effects for visitors whose device asks for reduced motion, while everyone else keeps the effects.",
            "category": "accessibility",
            "tags": [
                "divi",
                "reduced-motion",
                "animation"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "@media (prefers-reduced-motion: reduce) {\n    .et_animated,\n    .et-waypoint,\n    .et_pb_animation_top,\n    .et_pb_animation_bottom,\n    .et_pb_animation_left,\n    .et_pb_animation_right {\n        opacity: 1 !important;\n        animation: none !important;\n        transform: none !important;\n    }\n\n    .et_parallax_bg,\n    .et_pb_parallax_css {\n        background-attachment: scroll !important;\n        transform: none !important;\n    }\n}",
            "requires": [
                "divi"
            ],
            "notes": "Content hidden until its animation starts is made visible immediately."
        },
        {
            "id": "a11y-readability-panel",
            "version": 1,
            "title": "Readability Score Panel in the Block Editor",
            "description": "Adds a \"Readability\" panel to the block editor sidebar with the word count, average sentence length, number of very long sentences and a Flesch reading ease score that updates as you write.",
            "category": "accessibility",
            "tags": [
                "readability",
                "block-editor",
                "writing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-readability', '', array( 'wp-plugins', 'wp-editor', 'wp-element', 'wp-data' ), null, true );\n    wp_enqueue_script( 'wpide-readability' );\n    wp_add_inline_script( 'wpide-readability', <<<'JS'\n(function (wp) {\n    var el = wp.element.createElement;\n    var Panel = (wp.editor && wp.editor.PluginDocumentSettingPanel) || (wp.editPost && wp.editPost.PluginDocumentSettingPanel);\n    if (!Panel) { return; }\n    function syllables(word) {\n        word = word.toLowerCase().replace(/[^a-z]/g, '');\n        if (word.length <= 3) { return 1; }\n        word = word.replace(/(?:[^laeiouy]es|ed|[^laeiouy]e)$/, '').replace(/^y/, '');\n        var m = word.match(/[aeiouy]{1,2}/g);\n        return m ? m.length : 1;\n    }\n    function Readability() {\n        var html = wp.data.useSelect(function (select) { return select('core/editor').getEditedPostContent(); }, []);\n        var text = new DOMParser().parseFromString(html || '', 'text/html').body.textContent || '';\n        var sentences = text.split(/[.!?]+\\s/).map(function (s) { return s.trim(); }).filter(Boolean);\n        var words = text.split(/\\s+/).filter(function (w) { return /[a-z0-9]/i.test(w); });\n        if (words.length < 30) { return el(Panel, { name: 'wpide-readability', title: 'Readability' }, el('p', null, 'Write a little more to see a score.')); }\n        var syl = words.reduce(function (sum, w) { return sum + syllables(w); }, 0);\n        var avg = words.length / Math.max(1, sentences.length);\n        var flesch = Math.round(206.835 - 1.015 * avg - 84.6 * (syl / words.length));\n        var long = sentences.filter(function (s) { return s.split(/\\s+/).length > 25; }).length;\n        var label = flesch >= 60 ? 'easy to read' : (flesch >= 40 ? 'fairly difficult' : 'difficult');\n        return el(Panel, { name: 'wpide-readability', title: 'Readability' },\n            el('p', null, 'Words: ' + words.length),\n            el('p', null, 'Average sentence: ' + avg.toFixed(1) + ' words'),\n            el('p', { style: { color: long ? '#b32d2e' : 'inherit' } }, 'Sentences over 25 words: ' + long),\n            el('p', null, 'Flesch reading ease: ' + flesch + ' (' + label + ')'));\n    }\n    wp.plugins.registerPlugin('wpide-readability', { render: Readability });\n})(window.wp);\nJS\n    );\n} );",
            "requires": [],
            "notes": "The Flesch score is designed for English text; for other languages focus on sentence length."
        },
        {
            "id": "a11y-no-autoplay-audio",
            "version": 1,
            "title": "Stop Audio and Unmuted Videos From Autoplaying",
            "description": "Removes the autoplay setting from audio players and from videos that are not muted in your content, so sound never starts without the visitor choosing to play it.",
            "category": "accessibility",
            "tags": [
                "audio",
                "autoplay",
                "wcag"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, 'autoplay' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag() ) {\n        $tag = $tags->get_tag();\n\n        if ( ( 'AUDIO' === $tag || ( 'VIDEO' === $tag && null === $tags->get_attribute( 'muted' ) ) ) && null !== $tags->get_attribute( 'autoplay' ) ) {\n            $tags->remove_attribute( 'autoplay' );\n            $tags->set_attribute( 'controls', true );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Muted background videos keep autoplaying; use the reduced-motion video snippet if those should stop too."
        },
        {
            "id": "privacy-woo-checkout-data-summary",
            "version": 1,
            "title": "\"How We Use Your Data\" Box at WooCommerce Checkout",
            "description": "Shows a short, plain-language summary above the checkout form explaining what order data you collect, why, and how long you keep it, with a link to your full privacy policy.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "checkout",
                "privacy-policy"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "woocommerce_before_checkout_form",
            "insert_number": 1,
            "code": "<div class=\"wpide-checkout-privacy\" style=\"margin:0 0 1.5em;padding:12px 16px;border-left:4px solid #2271b1;background:#f6f7f7;font-size:.95em;\">\n    <p style=\"margin:0 0 .5em;\"><strong>How we use your data</strong></p>\n    <ul style=\"margin:0 0 .5em 1.2em;\">\n        <li>We use your name, address and email only to process and deliver your order.</li>\n        <li>Payment details go straight to our payment provider; we never see or store your card number.</li>\n        <li>We keep order records for as long as tax law requires, then delete them.</li>\n    </ul>\n    <?php if ( get_privacy_policy_url() ) : ?>\n        <p style=\"margin:0;\"><a href=\"<?php echo esc_url( get_privacy_policy_url() ); ?>\" target=\"_blank\" rel=\"noopener\">Read our full privacy policy</a></p>\n    <?php endif; ?>\n</div>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Edit the three points to match your store; keep them short so they do not distract from checking out."
        },
        {
            "id": "privacy-edd-checkout-privacy-note",
            "version": 1,
            "title": "Privacy Note Above the Easy Digital Downloads Checkout",
            "description": "Adds a short note above the Easy Digital Downloads purchase form explaining which details are collected for the order and linking to your privacy policy.",
            "category": "privacy",
            "tags": [
                "edd",
                "checkout",
                "privacy-policy"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "edd_before_purchase_form",
            "insert_number": 1,
            "code": "<p class=\"wpide-edd-privacy\" style=\"padding:10px 14px;border:1px solid #c3c4c7;border-radius:6px;font-size:.95em;\">\n    We only ask for the details needed to deliver your purchase, send your receipt and manage your licence.\n    <?php if ( get_privacy_policy_url() ) : ?>\n        <a href=\"<?php echo esc_url( get_privacy_policy_url() ); ?>\" target=\"_blank\" rel=\"noopener\">Privacy policy</a>\n    <?php endif; ?>\n</p>",
            "requires": [
                "edd"
            ],
            "notes": "Change the wording to match how your store handles licences, receipts and newsletters."
        },
        {
            "id": "privacy-woo-thankyou-retention-note",
            "version": 1,
            "title": "Data Retention Note on the Order Thank-You Page",
            "description": "Adds a short note above the WooCommerce order confirmation telling customers how long their order data is kept and how to ask for a copy or deletion.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "retention",
                "orders"
            ],
            "code_type": "universal",
            "insert_method": "auto",
            "location": "woocommerce_before_thankyou",
            "insert_number": 1,
            "code": "<p class=\"wpide-thankyou-privacy\" style=\"font-size:.95em;padding:10px 14px;background:#f6f7f7;border-radius:6px;\">\n    We keep your order details for 7 years because tax law requires it. You can ask for a copy of your data or for anything else we hold about you to be deleted at any time<?php if ( get_privacy_policy_url() ) : ?> - see our <a href=\"<?php echo esc_url( get_privacy_policy_url() ); ?>\">privacy policy</a><?php endif; ?>.\n</p>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Match the retention period to your WooCommerce privacy settings (WooCommerce > Settings > Accounts & Privacy)."
        },
        {
            "id": "privacy-no-ip-in-comment-emails",
            "version": 1,
            "title": "Remove IP Addresses From Comment Notification Emails",
            "description": "Strips commenters' IP addresses and host names from the new comment and moderation emails WordPress sends to authors and administrators, so personal data does not pile up in inboxes.",
            "category": "privacy",
            "tags": [
                "comments",
                "email",
                "ip-address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_strip_comment_ip = function ( $message, $comment_id ) {\n    $comment = get_comment( $comment_id );\n\n    if ( ! $comment ) {\n        return $message;\n    }\n\n    // English wording first, then any remaining copy of the raw address (for translated emails).\n    $message = preg_replace( '/\\s*\\(IP address: [^)]*\\)/', '', $message );\n\n    if ( $comment->comment_author_IP ) {\n        $message = str_replace( $comment->comment_author_IP, '[hidden]', $message );\n    }\n\n    return $message;\n};\n\nadd_filter( 'comment_notification_text', $wpide_strip_comment_ip, 20, 2 );\nadd_filter( 'comment_moderation_text', $wpide_strip_comment_ip, 20, 2 );",
            "requires": [],
            "notes": "The IP address is still visible in the dashboard for administrators if you store it."
        },
        {
            "id": "a11y-auto-contrast-buttons-scss",
            "version": 1,
            "title": "Buttons With Automatically Readable Text (SCSS)",
            "description": "Styles buttons from one brand color and picks black or white text automatically based on how light the color is, with matching hover and focus states, so button text always stays readable.",
            "category": "accessibility",
            "tags": [
                "contrast",
                "buttons",
                "scss"
            ],
            "code_type": "scss",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "// Brand colors; button text color is chosen automatically.\n$wpide-primary: #2271b1;\n$wpide-secondary: #f0b849;\n\n@function wpide-text-on($bg) {\n    $brightness: (red($bg) * 299 + green($bg) * 587 + blue($bg) * 114) / 1000;\n    @if $brightness > 150 {\n        @return #111111;\n    }\n    @return #ffffff;\n}\n\n@mixin wpide-button($bg) {\n    background-color: $bg;\n    color: wpide-text-on($bg);\n    border: 2px solid darken($bg, 12%);\n\n    &:hover {\n        background-color: darken($bg, 8%);\n        color: wpide-text-on(darken($bg, 8%));\n    }\n\n    &:focus-visible {\n        outline: 3px solid wpide-text-on($bg);\n        outline-offset: -5px;\n        box-shadow: 0 0 0 3px darken($bg, 20%);\n    }\n}\n\n.wp-block-button__link,\n.wp-element-button,\n.button-primary {\n    @include wpide-button($wpide-primary);\n}\n\n.is-style-secondary .wp-block-button__link,\n.button-secondary {\n    @include wpide-button($wpide-secondary);\n}",
            "requires": [],
            "notes": "Change $wpide-primary and $wpide-secondary; the brightness formula is an approximation, so still check final colors with a contrast checker."
        },
        {
            "id": "a11y-plain-language-summary",
            "version": 1,
            "title": "\"In Short\" Plain-Language Summary Above Posts",
            "description": "Shows the post's excerpt in a highlighted \"In short\" box at the top of single posts, giving every reader, including people with reading or cognitive disabilities, a simple summary before the full text.",
            "category": "accessibility",
            "tags": [
                "readability",
                "excerpts",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "before_content",
            "insert_number": 1,
            "code": "if ( is_singular( 'post' ) && has_excerpt() ) {\n    printf(\n        '<aside class=\"wpide-in-short\" aria-label=\"Summary\" style=\"margin:0 0 1.5em;padding:14px 18px;border-left:4px solid #2271b1;background:#f0f6fc;border-radius:4px;\"><p style=\"margin:0 0 .25em;font-weight:700;\">In short</p><p style=\"margin:0;\">%s</p></aside>',\n        esc_html( get_the_excerpt() )\n    );\n}",
            "requires": [],
            "notes": "Only hand-written excerpts are shown (posts without one show nothing), so write them in short, simple sentences."
        },
        {
            "id": "a11y-html-lang-fallback",
            "version": 1,
            "title": "Add the Page Language When the Theme Leaves It Out",
            "description": "Sets the lang attribute on the page's html element from your site language when a theme forgets to print it, so screen readers pronounce your content in the right language.",
            "category": "accessibility",
            "tags": [
                "language",
                "screen-reader",
                "theme"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    printf(\n        \"<script>if(!document.documentElement.getAttribute('lang')){document.documentElement.setAttribute('lang',%s);}</script>\\n\",\n        wp_json_encode( str_replace( '_', '-', determine_locale() ) )\n    );\n}, 0 );",
            "requires": [],
            "notes": "Themes that call language_attributes() already do this; the script only acts when the attribute is missing."
        },
        {
            "id": "a11y-focusable-scroll-areas",
            "version": 1,
            "title": "Make Scrollable Areas Reachable by Keyboard",
            "description": "Finds code blocks, tables and other boxes that scroll sideways or vertically but contain nothing focusable, and makes them keyboard-focusable with a label, so keyboard users can scroll them with the arrow keys.",
            "category": "accessibility",
            "tags": [
                "keyboard",
                "scrolling",
                "tables"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<script>\n(function () {\n    function fix() {\n        document.querySelectorAll('pre, .wp-block-table, .wp-block-code, figure, .table-responsive, [style*=\"overflow\"]').forEach(function (el) {\n            var scrolls = el.scrollWidth > el.clientWidth + 2 || el.scrollHeight > el.clientHeight + 2;\n            var style = getComputedStyle(el);\n            var canScroll = /(auto|scroll)/.test(style.overflowX + style.overflowY);\n            if (!scrolls || !canScroll || el.hasAttribute('tabindex') || el.querySelector('a[href], button, input, select, textarea, [tabindex]')) { return; }\n            el.setAttribute('tabindex', '0');\n            if (!el.getAttribute('role')) { el.setAttribute('role', 'region'); }\n            if (!el.getAttribute('aria-label')) {\n                var caption = el.querySelector('caption, figcaption');\n                el.setAttribute('aria-label', caption ? caption.textContent.trim() : (el.tagName === 'PRE' || el.querySelector('code') ? 'Code example' : 'Scrollable content'));\n            }\n        });\n    }\n    window.addEventListener('load', fix);\n    var timer;\n    window.addEventListener('resize', function () { clearTimeout(timer); timer = setTimeout(fix, 300); });\n})();\n</script>",
            "requires": [],
            "notes": "Runs once after the page loads and again when the window is resized."
        },
        {
            "id": "a11y-woo-checkout-error-focus",
            "version": 1,
            "title": "Move Focus to WooCommerce Checkout Errors",
            "description": "When the classic WooCommerce checkout shows errors (such as a missing field or a declined card), moves keyboard focus to the error list and has screen readers read it out.",
            "category": "accessibility",
            "tags": [
                "woocommerce",
                "checkout",
                "focus"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_after_checkout_form",
            "insert_number": 1,
            "code": "<script>\njQuery(function ($) {\n    $(document.body).on('checkout_error', function () {\n        var box = document.querySelector('.woocommerce-NoticeGroup-checkout, .woocommerce-error');\n        if (!box) { return; }\n        box.setAttribute('tabindex', '-1');\n        box.setAttribute('role', 'alert');\n        setTimeout(function () { box.focus(); }, 100);\n    });\n});\n</script>",
            "requires": [
                "woocommerce"
            ],
            "notes": "The block-based checkout handles this itself; this snippet is for the classic [woocommerce_checkout] shortcode checkout."
        },
        {
            "id": "a11y-woo-cart-update-announcement",
            "version": 1,
            "title": "Announce Cart Updates on the WooCommerce Cart Page",
            "description": "Tells screen reader users when the cart has been updated after they change a quantity, remove a product or apply a coupon on the classic cart page, which otherwise happens silently.",
            "category": "accessibility",
            "tags": [
                "woocommerce",
                "cart",
                "screen-reader"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "woocommerce_after_cart",
            "insert_number": 1,
            "code": "<div id=\"wpide-cart-status\" role=\"status\" aria-live=\"polite\" style=\"position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);\"></div>\n<script>\njQuery(function ($) {\n    var region = document.getElementById('wpide-cart-status');\n    function say(text) { region.textContent = ''; setTimeout(function () { region.textContent = text; }, 100); }\n    $(document.body).on('updated_cart_totals', function () {\n        var total = $('.order-total .amount').last().text();\n        say('Cart updated.' + (total ? ' New total ' + total + '.' : ''));\n    });\n    $(document.body).on('applied_coupon removed_coupon', function (e) {\n        say(e.type === 'applied_coupon' ? 'Coupon applied.' : 'Coupon removed.');\n    });\n});\n</script>",
            "requires": [
                "woocommerce"
            ],
            "notes": "Uses its own polite live region, so no extra WordPress scripts are needed."
        },
        {
            "id": "sec-hide-plugin-asset-versions",
            "version": 1,
            "title": "Hide Plugin and Theme Versions in Asset URLs",
            "description": "Replaces the ?ver= value on plugin and theme CSS and JavaScript files with a short hash, so scanners cannot read your exact plugin versions from the page source, while caches still refresh whenever a version changes.",
            "category": "security",
            "tags": [
                "fingerprinting",
                "versions",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_hash_version = function ( $src ) {\n    if ( ! is_string( $src ) || false === strpos( $src, 'ver=' ) || false === strpos( $src, '/wp-content/' ) ) {\n        return $src;\n    }\n\n    $query = (string) wp_parse_url( $src, PHP_URL_QUERY );\n    parse_str( $query, $args );\n\n    if ( empty( $args['ver'] ) ) {\n        return $src;\n    }\n\n    return add_query_arg( 'ver', substr( wp_hash( $args['ver'] . '|' . strtok( $src, '?' ) ), 0, 8 ), $src );\n};\n\nadd_filter( 'style_loader_src', $wpide_hash_version, 9999 );\nadd_filter( 'script_loader_src', $wpide_hash_version, 9999 );",
            "requires": [],
            "notes": "Plugin readme files can still reveal versions; block access to readme.txt files on your server for full coverage."
        },
        {
            "id": "a11y-pause-buttons-for-sliders",
            "version": 1,
            "title": "Pause Buttons for Auto-Rotating Sliders",
            "description": "Adds a visible pause/play button to every auto-rotating Swiper slider on the page (used by many themes, Elementor and slider plugins) and stops them automatically for visitors who prefer reduced motion.",
            "category": "accessibility",
            "tags": [
                "sliders",
                "carousel",
                "reduced-motion"
            ],
            "code_type": "html",
            "insert_method": "auto",
            "location": "site_wide_footer",
            "insert_number": 1,
            "code": "<style>\n.wpide-slider-pause{position:absolute;right:8px;bottom:8px;z-index:20;padding:6px 12px;border:2px solid #fff;border-radius:4px;background:rgba(0,0,0,.7);color:#fff;font-size:14px;cursor:pointer;}\n.wpide-slider-pause:focus-visible{outline:3px solid #ffeb3b;outline-offset:2px;}\n</style>\n<script>\nwindow.addEventListener('load', function () {\n    var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    document.querySelectorAll('.swiper, .swiper-container').forEach(function (el) {\n        var swiper = el.swiper;\n        if (!swiper || !swiper.autoplay || !swiper.params.autoplay || el.querySelector('.wpide-slider-pause')) { return; }\n        var btn = document.createElement('button');\n        btn.type = 'button';\n        btn.className = 'wpide-slider-pause';\n        function set(paused) {\n            if (paused) { swiper.autoplay.stop(); } else { swiper.autoplay.start(); }\n            btn.textContent = paused ? 'Play slideshow' : 'Pause slideshow';\n            btn.setAttribute('aria-pressed', paused ? 'true' : 'false');\n        }\n        btn.addEventListener('click', function () { set(btn.getAttribute('aria-pressed') !== 'true'); });\n        if (getComputedStyle(el).position === 'static') { el.style.position = 'relative'; }\n        el.appendChild(btn);\n        set(reduce);\n    });\n});\n</script>",
            "requires": [],
            "notes": "Works with sliders built on Swiper; style .wpide-slider-pause to match your design."
        },
        {
            "id": "a11y-alt-text-editor-page",
            "version": 1,
            "title": "Alt Text Editor for Images Missing a Description",
            "description": "Adds Media > Alt Text Editor, a page that lists images without alternative text with a thumbnail and a text field next to each, so you can describe dozens of images quickly and save them all at once.",
            "category": "accessibility",
            "tags": [
                "alt-text",
                "media-library",
                "bulk"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_media_page( 'Alt Text Editor', 'Alt Text Editor', 'upload_files', 'wpide-alt-editor', function () {\n        if ( isset( $_POST['wpide_alt'] ) && check_admin_referer( 'wpide_alt_editor' ) ) {\n            $saved = 0;\n            foreach ( (array) wp_unslash( $_POST['wpide_alt'] ) as $id => $alt ) {\n                $id  = absint( $id );\n                $alt = sanitize_text_field( $alt );\n                if ( $id && '' !== $alt && current_user_can( 'edit_post', $id ) ) {\n                    update_post_meta( $id, '_wp_attachment_image_alt', $alt );\n                    $saved++;\n                }\n            }\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Saved alt text for %d image(s).', $saved ) ) );\n        }\n\n        $images = get_posts( array(\n            'post_type'      => 'attachment',\n            'post_mime_type' => 'image',\n            'post_status'    => 'inherit',\n            'posts_per_page' => 40,\n            'no_found_rows'  => true,\n            'meta_query'     => array(\n                'relation' => 'OR',\n                array( 'key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS' ),\n                array( 'key' => '_wp_attachment_image_alt', 'value' => '' ),\n            ),\n        ) );\n\n        echo '<div class=\"wrap\"><h1>Alt Text Editor</h1>';\n        if ( ! $images ) {\n            echo '<p>Every image has alternative text.</p></div>';\n            return;\n        }\n        echo '<p>Describe what each image shows or what it is for. Leave purely decorative images empty.</p><form method=\"post\">';\n        wp_nonce_field( 'wpide_alt_editor' );\n        echo '<table class=\"widefat striped\"><thead><tr><th style=\"width:110px\">Image</th><th>Alt text</th></tr></thead><tbody>';\n        foreach ( $images as $image ) {\n            printf(\n                '<tr><td>%1$s</td><td><label class=\"screen-reader-text\" for=\"wpide-alt-%2$d\">Alt text for %3$s</label><input type=\"text\" class=\"large-text\" id=\"wpide-alt-%2$d\" name=\"wpide_alt[%2$d]\" placeholder=\"%3$s\"><p class=\"description\">%4$s</p></td></tr>',\n                wp_get_attachment_image( $image->ID, array( 96, 96 ) ),\n                (int) $image->ID,\n                esc_attr( $image->post_title ),\n                esc_html( wp_basename( (string) get_attached_file( $image->ID ) ) )\n            );\n        }\n        echo '</tbody></table>';\n        submit_button( 'Save alt text' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Shows 40 images per page, newest first; leave a field empty to skip that image."
        },
        {
            "id": "a11y-name-image-only-links",
            "version": 1,
            "title": "Name Links That Contain Only an Image Without Alt Text",
            "description": "Gives links in your content whose only content is an image with empty alt text an accessible name, using the title of the page they point to, so screen readers no longer announce just the URL.",
            "category": "accessibility",
            "tags": [
                "links",
                "images",
                "screen-reader"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '<img' ) ) {\n        return $content;\n    }\n\n    return preg_replace_callback( '#<a\\b([^>]*)>\\s*(<img\\b[^>]*>)\\s*</a>#i', function ( $m ) {\n        if ( preg_match( '#\\baria-label=#i', $m[1] ) || ( preg_match( '#\\balt=([\"\\'])(.*?)\\1#i', $m[2], $alt ) && '' !== trim( $alt[2] ) ) ) {\n            return $m[0];\n        }\n\n        if ( ! preg_match( '#\\bhref=([\"\\'])(.*?)\\1#i', $m[1], $href ) ) {\n            return $m[0];\n        }\n\n        $url     = html_entity_decode( $href[2] );\n        $key     = 'wpide_linkname_' . md5( $url );\n        $post_id = wp_cache_get( $key, 'wpide' );\n\n        if ( false === $post_id ) {\n            $post_id = (int) url_to_postid( $url );\n            wp_cache_set( $key, $post_id, 'wpide', HOUR_IN_SECONDS );\n        }\n\n        if ( ! $post_id ) {\n            return $m[0];\n        }\n\n        return '<a' . $m[1] . ' aria-label=\"' . esc_attr( wp_strip_all_tags( get_the_title( $post_id ) ) ) . '\">' . $m[2] . '</a>';\n    }, $content );\n}, 20 );",
            "requires": [],
            "notes": "Only links to pages and posts on your own site can be named automatically; looked-up titles are cached."
        },
        {
            "id": "privacy-do-not-sell-link",
            "version": 1,
            "title": "\"Do Not Sell or Share My Personal Information\" Link",
            "description": "Adds a [do_not_sell_link] shortcode for the opt-out link US state privacy laws such as the CCPA require; clicking it records the visitor's opt-out in a cookie and tells marketing scripts held back by consent snippets not to run.",
            "category": "privacy",
            "tags": [
                "ccpa",
                "opt-out",
                "cookies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'do_not_sell_link', function ( $atts ) {\n    static $script = false;\n\n    $atts = shortcode_atts( array( 'text' => 'Do Not Sell or Share My Personal Information' ), $atts, 'do_not_sell_link' );\n    $html = '<button type=\"button\" class=\"wpide-optout\" style=\"background:none;border:0;padding:0;color:inherit;text-decoration:underline;cursor:pointer;font:inherit;\">' . esc_html( $atts['text'] ) . '</button><span class=\"wpide-optout-status\" role=\"status\" style=\"margin-left:.5em;\"></span>';\n\n    if ( ! $script ) {\n        $script = true;\n        $html  .= '<script>(function(){function out(){return /(?:^|; )wpide_optout=1/.test(document.cookie)||navigator.globalPrivacyControl===true;}function mark(){document.querySelectorAll(\".wpide-optout-status\").forEach(function(s){s.textContent=out()?\"You have opted out.\":\"\";});}document.addEventListener(\"click\",function(e){if(!e.target.closest(\".wpide-optout\")){return;}document.cookie=\"wpide_optout=1;path=/;max-age=\"+(365*86400)+\";SameSite=Lax\"+(location.protocol===\"https:\"?\";Secure\":\"\");document.cookie=\"wpide_consent=denied;path=/;max-age=\"+(365*86400)+\";SameSite=Lax\";document.dispatchEvent(new CustomEvent(\"wpide:consent\",{detail:\"denied\"}));mark();});document.addEventListener(\"DOMContentLoaded\",mark);})();</script>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Visitors who send the Global Privacy Control signal are treated as opted out automatically; your marketing tools still need to respect the wpide_optout cookie or the consent helpers."
        },
        {
            "id": "privacy-shorter-comment-cookies",
            "version": 1,
            "title": "Remember Commenters' Details for 30 Days Instead of a Year",
            "description": "Shortens how long the cookies that remember a commenter's name, email and website last, from WordPress's default of one year to 30 days.",
            "category": "privacy",
            "tags": [
                "comments",
                "cookies",
                "retention"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days the comment author cookies are kept.\n$wpide_comment_cookie_days = 30;\n\nadd_filter( 'comment_cookie_lifetime', function () use ( $wpide_comment_cookie_days ) {\n    return (int) $wpide_comment_cookie_days * DAY_IN_SECONDS;\n} );",
            "requires": [],
            "notes": "Change the number of days to suit your audience; the cookies are only set when the commenter ticks the consent box."
        },
        {
            "id": "sec-upload-rate-limit",
            "version": 1,
            "title": "Limit How Many Files Non-Administrators Can Upload per Hour",
            "description": "Caps uploads at 30 files per hour for each user who is not an administrator, which limits the damage a compromised author account or a buggy front-end upload form can do.",
            "category": "security",
            "tags": [
                "uploads",
                "rate-limit",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Uploads allowed per user per hour.\n$wpide_upload_hourly = 30;\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) use ( $wpide_upload_hourly ) {\n    if ( ! empty( $file['error'] ) || ! is_user_logged_in() || current_user_can( 'manage_options' ) ) {\n        return $file;\n    }\n\n    $key   = 'wpide_uploads_' . get_current_user_id();\n    $count = (int) get_transient( $key );\n\n    if ( $count >= $wpide_upload_hourly ) {\n        $file['error'] = sprintf( 'You have reached the limit of %d uploads per hour. Please try again later.', $wpide_upload_hourly );\n        return $file;\n    }\n\n    set_transient( $key, $count + 1, HOUR_IN_SECONDS );\n\n    return $file;\n}, 20 );",
            "requires": [],
            "notes": "Raise $wpide_upload_hourly for teams that upload large batches of photos."
        },
        {
            "id": "sec-sandbox-untrusted-iframes",
            "version": 1,
            "title": "Sandbox Iframes From Untrusted Sites in Your Content",
            "description": "Adds a restrictive sandbox to iframes in posts that come from sites not on your trusted list, so an embedded page cannot redirect your visitors, open pop-ups or run scripts against your site.",
            "category": "security",
            "tags": [
                "iframes",
                "embeds",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Hosts whose iframes are left alone (subdomains included).\n$wpide_trusted_frames = array( 'youtube.com', 'youtube-nocookie.com', 'vimeo.com', 'google.com', 'spotify.com', 'soundcloud.com' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_trusted_frames ) {\n    if ( false === stripos( $content, '<iframe' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $own  = wp_parse_url( home_url(), PHP_URL_HOST );\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'iframe' ) ) {\n        $host = strtolower( (string) wp_parse_url( (string) $tags->get_attribute( 'src' ), PHP_URL_HOST ) );\n\n        if ( ! $host || $host === $own || null !== $tags->get_attribute( 'sandbox' ) ) {\n            continue;\n        }\n\n        foreach ( $wpide_trusted_frames as $trusted ) {\n            if ( $host === $trusted || substr( $host, -strlen( '.' . $trusted ) ) === '.' . $trusted ) {\n                continue 2;\n            }\n        }\n\n        $tags->set_attribute( 'sandbox', 'allow-scripts allow-same-origin allow-forms' );\n        $tags->set_attribute( 'referrerpolicy', 'no-referrer' );\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Add every service you embed on purpose to $wpide_trusted_frames; sandboxed embeds from other sites may lose some features."
        },
        {
            "id": "perf-convert-myisam-to-innodb",
            "version": 1,
            "title": "Convert MyISAM Database Tables to InnoDB",
            "description": "Run on demand to convert your site's remaining MyISAM tables to InnoDB, the modern MySQL engine that handles many visitors at once far better and survives crashes more safely.",
            "category": "performance",
            "tags": [
                "database",
                "mysql",
                "innodb"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "global $wpdb;\n\n$wpide_tables = $wpdb->get_col( $wpdb->prepare(\n    \"SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND ENGINE = 'MyISAM' AND TABLE_NAME LIKE %s\",\n    DB_NAME,\n    $wpdb->esc_like( $wpdb->prefix ) . '%'\n) );\n\nif ( ! $wpide_tables ) {\n    echo 'All tables already use InnoDB.';\n} else {\n    foreach ( $wpide_tables as $wpide_table ) {\n        $wpide_ok = false !== $wpdb->query( 'ALTER TABLE `' . esc_sql( $wpide_table ) . '` ENGINE=InnoDB' );\n        printf( \"%s: %s\\n\", esc_html( $wpide_table ), $wpide_ok ? 'converted' : 'failed (' . esc_html( $wpdb->last_error ) . ')' );\n    }\n}",
            "requires": [],
            "notes": "Back up the database first and run it during a quiet period, since large tables take a while; tables already using InnoDB are skipped."
        },
        {
            "id": "perf-leftover-plugin-data-cleanup",
            "version": 1,
            "title": "Remove Leftover Data From Uninstalled Plugins",
            "description": "Adds Tools > Leftover Plugin Data, where you enter a prefix (such as old_slider_) to see how many options, post meta, user meta and scheduled tasks use it, and delete them after reviewing the counts.",
            "category": "performance",
            "tags": [
                "database",
                "cleanup",
                "plugins"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Leftover Plugin Data', 'Leftover Plugin Data', 'manage_options', 'wpide-leftovers', function () {\n        global $wpdb;\n\n        $prefix  = isset( $_POST['wpide_prefix'] ) ? sanitize_key( wp_unslash( $_POST['wpide_prefix'] ) ) : '';\n        $blocked = array( 'wp_', 'wp', '_wp', 'widget', 'theme', 'user', 'site', 'blog', 'woocommerce', 'wc_', '_transient', '_site_transient' );\n        $valid   = strlen( $prefix ) >= 4 && ! in_array( $prefix, $blocked, true ) && 0 !== strpos( $prefix, $wpdb->prefix );\n        $counts  = array();\n\n        if ( $prefix && isset( $_POST['_wpnonce'] ) && check_admin_referer( 'wpide_leftovers' ) ) {\n            if ( ! $valid ) {\n                echo '<div class=\"notice notice-error\"><p>Please use a more specific prefix (at least 4 characters, not a WordPress prefix).</p></div>';\n            } else {\n                $like   = $wpdb->esc_like( $prefix ) . '%';\n                $counts = array(\n                    'Options'   => (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s\", $like ) ),\n                    'Post meta' => (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s\", $like ) ),\n                    'User meta' => (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s\", $like ) ),\n                );\n                $cron = 0;\n                foreach ( (array) _get_cron_array() as $hooks ) {\n                    foreach ( array_keys( (array) $hooks ) as $hook ) {\n                        $cron += 0 === strpos( $hook, $prefix ) ? 1 : 0;\n                    }\n                }\n                $counts['Scheduled tasks'] = $cron;\n\n                if ( isset( $_POST['wpide_delete_leftovers'] ) && current_user_can( 'manage_options' ) ) {\n                    $wpdb->query( $wpdb->prepare( \"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s\", $like ) );\n                    $wpdb->query( $wpdb->prepare( \"DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s\", $like ) );\n                    $wpdb->query( $wpdb->prepare( \"DELETE FROM {$wpdb->usermeta} WHERE meta_key LIKE %s\", $like ) );\n                    foreach ( (array) _get_cron_array() as $hooks ) {\n                        foreach ( array_keys( (array) $hooks ) as $hook ) {\n                            if ( 0 === strpos( $hook, $prefix ) ) {\n                                wp_unschedule_hook( $hook );\n                            }\n                        }\n                    }\n                    wp_cache_flush();\n                    printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Deleted %d item(s) starting with \"%s\".', array_sum( $counts ), $prefix ) ) );\n                    $counts = array();\n                }\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Leftover Plugin Data</h1><form method=\"post\">';\n        wp_nonce_field( 'wpide_leftovers' );\n        echo '<p><label for=\"wpide-prefix\">Prefix used by the removed plugin</label><br><input type=\"text\" id=\"wpide-prefix\" name=\"wpide_prefix\" value=\"' . esc_attr( $prefix ) . '\" class=\"regular-text\" placeholder=\"old_slider_\"></p>';\n        submit_button( 'Preview', 'secondary', 'wpide_preview', false );\n        if ( $counts && array_sum( $counts ) ) {\n            echo '<table class=\"widefat striped\" style=\"max-width:420px;margin:1em 0\"><tbody>';\n            foreach ( $counts as $label => $count ) {\n                printf( '<tr><td>%s</td><td>%s</td></tr>', esc_html( $label ), esc_html( number_format_i18n( $count ) ) );\n            }\n            echo '</tbody></table>';\n            submit_button( 'Delete these items', 'delete', 'wpide_delete_leftovers', false, array( 'onclick' => \"return confirm('Delete all items with this prefix? This cannot be undone.');\" ) );\n        } elseif ( $counts ) {\n            echo '<p>Nothing uses this prefix.</p>';\n        }\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Prefixes shorter than 4 characters and WordPress's own prefixes are refused; always preview first and keep a backup."
        },
        {
            "id": "sec-block-scripted-login-agents",
            "version": 1,
            "title": "Block Login Attempts From Scripts and Command-Line Tools",
            "description": "Refuses login, password reset and registration requests sent by tools such as curl, wget, python-requests or empty user agents, which real browsers never use and brute-force scripts often do.",
            "category": "security",
            "tags": [
                "login",
                "bots",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User agent fragments (lowercase) that are never real browsers.\n$wpide_script_agents = array( 'curl/', 'wget/', 'python-requests', 'python-urllib', 'go-http-client', 'libwww-perl', 'java/', 'okhttp', 'httpclient', 'scrapy', 'masscan', 'nikto', 'wpscan' );\n\nadd_action( 'login_init', function () use ( $wpide_script_agents ) {\n    if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '' ) ) {\n        return;\n    }\n\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';\n    $block = '' === $agent;\n\n    foreach ( $wpide_script_agents as $fragment ) {\n        if ( false !== strpos( $agent, $fragment ) ) {\n            $block = true;\n            break;\n        }\n    }\n\n    if ( $block ) {\n        status_header( 403 );\n        nocache_headers();\n        exit( 'Forbidden.' );\n    }\n}, 0 );",
            "requires": [],
            "notes": "Legitimate apps that log in through wp-login.php with such tools will be blocked too; the REST API and application passwords are not affected."
        },
        {
            "id": "sec-current-password-for-account-changes",
            "version": 1,
            "title": "Ask for the Current Password Before Changing Email or Password",
            "description": "Adds a \"Current password\" field to the profile screen and refuses changes to your own email address or password unless it is correct, so someone using an unattended logged-in browser cannot take over the account.",
            "category": "security",
            "tags": [
                "passwords",
                "profile",
                "account-takeover"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'show_user_profile', function () {\n    echo '<h2>Confirm changes</h2><table class=\"form-table\" role=\"presentation\"><tr><th><label for=\"wpide-current-pass\">Current password</label></th><td><input type=\"password\" name=\"wpide_current_pass\" id=\"wpide-current-pass\" class=\"regular-text\" autocomplete=\"current-password\"><p class=\"description\">Required to change your email address or password.</p></td></tr></table>';\n} );\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) {\n    if ( ! $update || empty( $user->ID ) || get_current_user_id() !== (int) $user->ID ) {\n        return;\n    }\n\n    $current = get_userdata( $user->ID );\n    $changes = ( ! empty( $user->user_pass ) ) || ( isset( $user->user_email ) && strtolower( $user->user_email ) !== strtolower( $current->user_email ) );\n\n    if ( ! $changes ) {\n        return;\n    }\n\n    $given = isset( $_POST['wpide_current_pass'] ) ? (string) wp_unslash( $_POST['wpide_current_pass'] ) : '';\n\n    if ( '' === $given || ! wp_check_password( $given, $current->user_pass, $current->ID ) ) {\n        $errors->add( 'wpide_current_pass', '<strong>Error:</strong> Enter your current password to change your email address or password.' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Applies only when users edit their own profile; administrators editing other users are not asked."
        },
        {
            "id": "perf-no-avatars-in-admin",
            "version": 1,
            "title": "Turn Off Avatars in the Dashboard Only",
            "description": "Stops wp-admin from loading an avatar for every row of the comments and users screens and the dashboard, which saves dozens of requests to Gravatar, while avatars keep showing on the front of the site.",
            "category": "performance",
            "tags": [
                "avatars",
                "admin",
                "gravatar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_show_avatars', function ( $value ) {\n    if ( ! is_admin() || wp_doing_ajax() ) {\n        return $value;\n    }\n\n    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;\n\n    if ( $screen && $screen->is_block_editor() ) {\n        return $value;\n    }\n\n    return '0';\n} );",
            "requires": [],
            "notes": "The block editor and front-end comment lists are not affected."
        },
        {
            "id": "a11y-no-justified-text",
            "version": 1,
            "title": "Turn Off Justified Text for Easier Reading",
            "description": "Aligns justified paragraphs in posts to the start of the line instead, because the uneven gaps that justified text creates make reading harder for people with dyslexia and low vision.",
            "category": "accessibility",
            "tags": [
                "readability",
                "typography",
                "dyslexia"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": ".entry-content p:not([class*=\"has-text-align-\"]):not([style*=\"text-align\"]),\n.wp-block-post-content p:not([class*=\"has-text-align-\"]):not([style*=\"text-align\"]),\n.has-text-align-justify,\n[style*=\"text-align: justify\"],\n[style*=\"text-align:justify\"] {\n    text-align: start !important;\n    hyphens: manual;\n}",
            "requires": [],
            "notes": "Paragraphs you centered or right-aligned in the editor keep their alignment; only justified text and the theme's default paragraph alignment are changed."
        },
        {
            "id": "perf-revisions-cleanup-tool",
            "version": 1,
            "title": "Old Revisions Cleanup Tool",
            "description": "Adds Tools > Clean Up Revisions, which shows how many post revisions your database holds and deletes revisions older than a chosen number of days after you confirm, always keeping the newest few per post.",
            "category": "performance",
            "tags": [
                "database",
                "revisions",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Revisions newer than this are kept, and the newest N per post are always kept.\n$wpide_rev_days = 30;\n$wpide_rev_keep = 3;\n\nadd_action( 'admin_menu', function () use ( $wpide_rev_days, $wpide_rev_keep ) {\n    add_management_page( 'Clean Up Revisions', 'Clean Up Revisions', 'manage_options', 'wpide-revisions-cleanup', function () use ( $wpide_rev_days, $wpide_rev_keep ) {\n        global $wpdb;\n\n        $cutoff = gmdate( 'Y-m-d H:i:s', time() - $wpide_rev_days * DAY_IN_SECONDS );\n\n        if ( isset( $_POST['wpide_clean_revisions'] ) && check_admin_referer( 'wpide_clean_revisions' ) && current_user_can( 'manage_options' ) ) {\n            // Revisions that are old enough and not among the newest N of their post.\n            $ids = $wpdb->get_col( $wpdb->prepare(\n                \"SELECT r.ID FROM {$wpdb->posts} r\n                 WHERE r.post_type = 'revision' AND r.post_date_gmt < %s\n                 AND ( SELECT COUNT(*) FROM {$wpdb->posts} n WHERE n.post_parent = r.post_parent AND n.post_type = 'revision' AND n.post_date_gmt > r.post_date_gmt ) >= %d\n                 LIMIT 500\",\n                $cutoff,\n                $wpide_rev_keep\n            ) );\n            foreach ( $ids as $id ) {\n                wp_delete_post_revision( (int) $id );\n            }\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Deleted %d revision(s).', count( $ids ) ) ) );\n        }\n\n        $total = (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision'\" );\n        $old   = (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_date_gmt < %s\", $cutoff ) );\n\n        echo '<div class=\"wrap\"><h1>Clean Up Revisions</h1>';\n        printf( '<p>%s</p>', esc_html( sprintf( 'Your database holds %1$s revision(s); %2$s are older than %3$d days. The newest %4$d revisions of each post are always kept.', number_format_i18n( $total ), number_format_i18n( $old ), $wpide_rev_days, $wpide_rev_keep ) ) );\n        echo '<form method=\"post\" onsubmit=\"return confirm(\\'Permanently delete old revisions? Make sure you have a backup.\\');\">';\n        wp_nonce_field( 'wpide_clean_revisions' );\n        submit_button( 'Delete old revisions (up to 500)', 'primary', 'wpide_clean_revisions', true, $old ? array() : array( 'disabled' => 'disabled' ) );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Back up your database first; it works in batches of 500 so it never times out, so click again until the count reaches zero."
        },
        {
            "id": "perf-orphaned-meta-cleanup",
            "version": 1,
            "title": "Orphaned Metadata Cleanup Tool",
            "description": "Adds Tools > Orphaned Metadata, which counts post, comment, term and user meta rows whose post, comment, term or user no longer exists, and removes them after you confirm.",
            "category": "performance",
            "tags": [
                "database",
                "cleanup",
                "metadata"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Orphaned Metadata', 'Orphaned Metadata', 'manage_options', 'wpide-orphaned-meta', function () {\n        global $wpdb;\n\n        $tables = array(\n            'Post meta'    => array( $wpdb->postmeta, 'post_id', $wpdb->posts, 'ID' ),\n            'Comment meta' => array( $wpdb->commentmeta, 'comment_id', $wpdb->comments, 'comment_ID' ),\n            'Term meta'    => array( $wpdb->termmeta, 'term_id', $wpdb->terms, 'term_id' ),\n            'User meta'    => array( $wpdb->usermeta, 'user_id', $wpdb->users, 'ID' ),\n        );\n\n        if ( isset( $_POST['wpide_clean_orphans'] ) && check_admin_referer( 'wpide_clean_orphans' ) && current_user_can( 'manage_options' ) ) {\n            $deleted = 0;\n            foreach ( $tables as list( $meta, $fk, $parent, $pk ) ) {\n                $deleted += (int) $wpdb->query( \"DELETE m FROM $meta m LEFT JOIN $parent p ON p.$pk = m.$fk WHERE p.$pk IS NULL\" );\n            }\n            wp_cache_flush();\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Removed %d orphaned row(s).', $deleted ) ) );\n        }\n\n        echo '<div class=\"wrap\"><h1>Orphaned Metadata</h1><table class=\"widefat striped\" style=\"max-width:520px\"><thead><tr><th>Type</th><th>Orphaned rows</th></tr></thead><tbody>';\n        $any = 0;\n        foreach ( $tables as $label => list( $meta, $fk, $parent, $pk ) ) {\n            $count = (int) $wpdb->get_var( \"SELECT COUNT(*) FROM $meta m LEFT JOIN $parent p ON p.$pk = m.$fk WHERE p.$pk IS NULL\" );\n            $any  += $count;\n            printf( '<tr><td>%s</td><td>%s</td></tr>', esc_html( $label ), esc_html( number_format_i18n( $count ) ) );\n        }\n        echo '</tbody></table><form method=\"post\" onsubmit=\"return confirm(\\'Delete all orphaned metadata rows?\\');\">';\n        wp_nonce_field( 'wpide_clean_orphans' );\n        submit_button( 'Delete orphaned rows', 'primary', 'wpide_clean_orphans', true, $any ? array() : array( 'disabled' => 'disabled' ) );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Leftover rows usually come from deleted plugins or direct database edits; take a backup before deleting."
        },
        {
            "id": "perf-drafts-trash-cleanup",
            "version": 1,
            "title": "Auto-Drafts and Trash Cleanup Tool",
            "description": "Adds Tools > Clean Up Drafts and Trash with counts of leftover auto-drafts, trashed posts and trashed or spam comments, and a button to remove them all permanently after confirmation.",
            "category": "performance",
            "tags": [
                "database",
                "cleanup",
                "trash"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Clean Up Drafts and Trash', 'Clean Up Trash', 'manage_options', 'wpide-trash-cleanup', function () {\n        global $wpdb;\n\n        $day = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS );\n\n        if ( isset( $_POST['wpide_clean_trash'] ) && check_admin_referer( 'wpide_clean_trash' ) && current_user_can( 'manage_options' ) ) {\n            $posts    = $wpdb->get_col( $wpdb->prepare( \"SELECT ID FROM {$wpdb->posts} WHERE ( post_status = 'auto-draft' AND post_date_gmt < %s ) OR post_status = 'trash' LIMIT 300\", $day ) );\n            $comments = $wpdb->get_col( \"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_approved IN ('trash','spam') LIMIT 500\" );\n            foreach ( $posts as $id ) {\n                wp_delete_post( (int) $id, true );\n            }\n            foreach ( $comments as $id ) {\n                wp_delete_comment( (int) $id, true );\n            }\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Deleted %d post(s) and %d comment(s). Run again if more remain.', count( $posts ), count( $comments ) ) ) );\n        }\n\n        $counts = array(\n            'Auto-drafts (older than a day)' => (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'auto-draft' AND post_date_gmt < %s\", $day ) ),\n            'Trashed posts and pages'         => (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'trash'\" ),\n            'Trashed comments'                => (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = 'trash'\" ),\n            'Spam comments'                   => (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->comments} WHERE comment_approved = 'spam'\" ),\n        );\n\n        echo '<div class=\"wrap\"><h1>Clean Up Drafts and Trash</h1><table class=\"widefat striped\" style=\"max-width:520px\"><tbody>';\n        foreach ( $counts as $label => $count ) {\n            printf( '<tr><td>%s</td><td>%s</td></tr>', esc_html( $label ), esc_html( number_format_i18n( $count ) ) );\n        }\n        echo '</tbody></table><form method=\"post\" onsubmit=\"return confirm(\\'Permanently delete these items?\\');\">';\n        wp_nonce_field( 'wpide_clean_trash' );\n        submit_button( 'Delete permanently', 'primary', 'wpide_clean_trash', true, array_sum( $counts ) ? array() : array( 'disabled' => 'disabled' ) );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Auto-drafts are empty placeholders WordPress creates when you open the editor; they are safe to delete once they are a day old."
        },
        {
            "id": "perf-clear-oembed-cache",
            "version": 1,
            "title": "Clear the Embed (oEmbed) Cache",
            "description": "Run on demand to delete every cached embed (videos, tweets and other oEmbed results) from the database, so they are fetched fresh and stale or broken embeds disappear.",
            "category": "performance",
            "tags": [
                "oembed",
                "cache",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "global $wpdb;\n\n$wpide_meta = (int) $wpdb->query( \"DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE '\\_oembed\\_%'\" );\n$wpide_ids  = $wpdb->get_col( \"SELECT ID FROM {$wpdb->posts} WHERE post_type = 'oembed_cache' LIMIT 2000\" );\n\nforeach ( $wpide_ids as $wpide_id ) {\n    wp_delete_post( (int) $wpide_id, true );\n}\n\nwp_cache_flush();\n\nprintf( 'Removed %d cached embed meta row(s) and %d oEmbed cache post(s).', $wpide_meta, count( $wpide_ids ) );",
            "requires": [],
            "notes": "Run it from the snippet editor; embeds are fetched again the next time each page is viewed."
        },
        {
            "id": "perf-orphan-term-relationships",
            "version": 1,
            "title": "Remove Orphaned Term Relationships and Fix Term Counts",
            "description": "Run on demand to delete category and tag links that point to posts that no longer exist, and then recount how many posts each term has.",
            "category": "performance",
            "tags": [
                "database",
                "taxonomies",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "global $wpdb;\n\n// Links between terms and posts that were deleted without cleaning up (links to link-manager objects are kept).\n$wpide_removed = (int) $wpdb->query(\n    \"DELETE tr FROM {$wpdb->term_relationships} tr\n     LEFT JOIN {$wpdb->posts} p ON p.ID = tr.object_id\n     LEFT JOIN {$wpdb->links} l ON l.link_id = tr.object_id\n     WHERE p.ID IS NULL AND l.link_id IS NULL\"\n);\n\n$wpide_recounted = 0;\nforeach ( get_taxonomies() as $wpide_tax ) {\n    $wpide_terms = get_terms( array( 'taxonomy' => $wpide_tax, 'hide_empty' => false, 'fields' => 'tt_ids' ) );\n    if ( ! is_wp_error( $wpide_terms ) && $wpide_terms ) {\n        wp_update_term_count_now( $wpide_terms, $wpide_tax );\n        $wpide_recounted += count( $wpide_terms );\n    }\n}\n\nprintf( 'Removed %d orphaned relationship(s) and recounted %d term(s).', $wpide_removed, $wpide_recounted );",
            "requires": [],
            "notes": "Run it from the snippet editor; on very large sites run it outside busy hours."
        },
        {
            "id": "perf-duplicate-postmeta-cleanup",
            "version": 1,
            "title": "Remove Duplicate Post Meta Rows",
            "description": "Run on demand to find custom field rows that are exact duplicates (same post, key and value) and keep only one of each, a common side effect of buggy imports and plugins.",
            "category": "performance",
            "tags": [
                "database",
                "metadata",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "global $wpdb;\n\n$wpide_deleted = (int) $wpdb->query(\n    \"DELETE m1 FROM {$wpdb->postmeta} m1\n     INNER JOIN {$wpdb->postmeta} m2\n        ON m1.post_id = m2.post_id AND m1.meta_key = m2.meta_key AND m1.meta_value = m2.meta_value AND m1.meta_id > m2.meta_id\"\n);\n\nif ( $wpide_deleted ) {\n    wp_cache_flush();\n}\n\nprintf( 'Removed %d duplicate post meta row(s).', $wpide_deleted );",
            "requires": [],
            "notes": "Only exact duplicates are removed, keeping the oldest row; back up your database first."
        },
        {
            "id": "perf-database-table-sizes",
            "version": 1,
            "title": "Database Table Sizes and Optimize Buttons",
            "description": "Adds Tools > Database Tables listing every table of your site with its row count, data and index size and reclaimable space, sorted by size, with an Optimize button for tables that need it.",
            "category": "performance",
            "tags": [
                "database",
                "tables",
                "optimize"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Database Tables', 'Database Tables', 'manage_options', 'wpide-db-tables', function () {\n        global $wpdb;\n\n        $tables = $wpdb->get_results( $wpdb->prepare(\n            'SELECT TABLE_NAME AS name, ENGINE AS engine, TABLE_ROWS AS row_count, DATA_LENGTH AS data, INDEX_LENGTH AS idx, DATA_FREE AS free\n             FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME LIKE %s ORDER BY ( DATA_LENGTH + INDEX_LENGTH ) DESC',\n            DB_NAME,\n            $wpdb->esc_like( $wpdb->prefix ) . '%'\n        ) );\n        $names = wp_list_pluck( $tables, 'name' );\n\n        if ( isset( $_POST['wpide_optimize'] ) && check_admin_referer( 'wpide_optimize_table' ) && current_user_can( 'manage_options' ) ) {\n            $table = sanitize_text_field( wp_unslash( $_POST['wpide_optimize'] ) );\n            if ( in_array( $table, $names, true ) ) {\n                $wpdb->query( 'OPTIMIZE TABLE `' . esc_sql( $table ) . '`' );\n                printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( 'Optimized ' . $table . '.' ) );\n            }\n        }\n\n        $total = 0;\n        echo '<div class=\"wrap\"><h1>Database Tables</h1><table class=\"widefat striped\"><thead><tr><th>Table</th><th>Engine</th><th>Rows (approx.)</th><th>Data</th><th>Indexes</th><th>Reclaimable</th><th></th></tr></thead><tbody>';\n        foreach ( $tables as $t ) {\n            $total += $t->data + $t->idx;\n            printf(\n                '<tr><td><code>%s</code></td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>',\n                esc_html( $t->name ),\n                esc_html( $t->engine ),\n                esc_html( number_format_i18n( (int) $t->row_count ) ),\n                esc_html( size_format( (int) $t->data, 1 ) ),\n                esc_html( size_format( (int) $t->idx, 1 ) ),\n                esc_html( $t->free ? size_format( (int) $t->free, 1 ) : '-' )\n            );\n            if ( $t->free > MB_IN_BYTES ) {\n                echo '<form method=\"post\" style=\"margin:0\">';\n                wp_nonce_field( 'wpide_optimize_table' );\n                echo '<button class=\"button button-small\" name=\"wpide_optimize\" value=\"' . esc_attr( $t->name ) . '\">Optimize</button></form>';\n            }\n            echo '</td></tr>';\n        }\n        printf( '</tbody></table><p><strong>%s</strong></p></div>', esc_html( 'Total size: ' . size_format( $total, 1 ) ) );\n    } );\n} );",
            "requires": [],
            "notes": "Optimizing locks the table briefly on MyISAM; on InnoDB it rebuilds the table, so run it outside peak hours."
        },
        {
            "id": "perf-autoload-inspector",
            "version": 1,
            "title": "Autoloaded Options Inspector",
            "description": "Adds Tools > Autoloaded Options showing the total size of options WordPress loads on every page and the 40 largest ones, with a button to stop autoloading any option that does not need it.",
            "category": "performance",
            "tags": [
                "database",
                "autoload",
                "options"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Autoloaded Options', 'Autoloaded Options', 'manage_options', 'wpide-autoload', function () {\n        global $wpdb;\n\n        $protected = array( 'siteurl', 'home', 'blogname', 'active_plugins', 'cron', 'template', 'stylesheet', 'rewrite_rules', 'permalink_structure', 'wp_user_roles', $wpdb->prefix . 'user_roles' );\n        $autoload  = function_exists( 'wp_autoload_values_to_autoload' ) ? wp_autoload_values_to_autoload() : array( 'yes' );\n        $in        = \"'\" . implode( \"','\", array_map( 'esc_sql', $autoload ) ) . \"'\";\n\n        if ( isset( $_POST['wpide_noautoload'] ) && check_admin_referer( 'wpide_noautoload' ) && current_user_can( 'manage_options' ) ) {\n            $name = sanitize_text_field( wp_unslash( $_POST['wpide_noautoload'] ) );\n            if ( ! in_array( $name, $protected, true ) && function_exists( 'wp_set_option_autoload' ) ) {\n                wp_set_option_autoload( $name, false );\n                printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( 'Autoload turned off for ' . $name . '.' ) );\n            }\n        }\n\n        $total = (int) $wpdb->get_var( \"SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options} WHERE autoload IN ($in)\" );\n        $rows  = $wpdb->get_results( \"SELECT option_name, LENGTH(option_value) AS size FROM {$wpdb->options} WHERE autoload IN ($in) ORDER BY size DESC LIMIT 40\" );\n\n        echo '<div class=\"wrap\"><h1>Autoloaded Options</h1>';\n        printf( '<p>%s</p>', esc_html( sprintf( 'Autoloaded data loaded on every request: %s. Below about 800 KB is healthy.', size_format( $total, 1 ) ) ) );\n        echo '<table class=\"widefat striped\"><thead><tr><th>Option</th><th>Size</th><th></th></tr></thead><tbody>';\n        foreach ( $rows as $row ) {\n            printf( '<tr><td><code>%s</code></td><td>%s</td><td>', esc_html( $row->option_name ), esc_html( size_format( (int) $row->size, 1 ) ) );\n            if ( ! in_array( $row->option_name, $protected, true ) && 0 !== strpos( $row->option_name, '_transient' ) ) {\n                echo '<form method=\"post\" style=\"margin:0\" onsubmit=\"return confirm(\\'Stop autoloading this option?\\');\">';\n                wp_nonce_field( 'wpide_noautoload' );\n                echo '<button class=\"button button-small\" name=\"wpide_noautoload\" value=\"' . esc_attr( $row->option_name ) . '\">Stop autoloading</button></form>';\n            }\n            echo '</td></tr>';\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Turning autoload off is safe for options a plugin only reads now and then; core options such as siteurl, active_plugins and cron are protected."
        },
        {
            "id": "perf-orphan-cron-cleanup",
            "version": 1,
            "title": "Find and Remove Orphaned Cron Events",
            "description": "Adds Tools > Orphaned Cron Events, listing scheduled tasks that no active plugin or theme handles any more (often left by uninstalled plugins) and removing them after you confirm.",
            "category": "performance",
            "tags": [
                "cron",
                "cleanup",
                "scheduling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Orphaned Cron Events', 'Orphaned Cron Events', 'manage_options', 'wpide-orphan-cron', function () {\n        $find = function () {\n            $orphans = array();\n            foreach ( (array) _get_cron_array() as $time => $hooks ) {\n                foreach ( (array) $hooks as $hook => $events ) {\n                    if ( ! has_action( $hook ) ) {\n                        $orphans[ $hook ] = ( isset( $orphans[ $hook ] ) ? $orphans[ $hook ] : 0 ) + count( $events );\n                    }\n                }\n            }\n            return $orphans;\n        };\n\n        if ( isset( $_POST['wpide_cron_hooks'] ) && check_admin_referer( 'wpide_orphan_cron' ) && current_user_can( 'manage_options' ) ) {\n            $orphans = $find();\n            $removed = 0;\n            foreach ( array_map( 'sanitize_text_field', (array) wp_unslash( $_POST['wpide_cron_hooks'] ) ) as $hook ) {\n                if ( isset( $orphans[ $hook ] ) ) {\n                    $removed += (int) wp_unschedule_hook( $hook );\n                }\n            }\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Removed %d scheduled event(s).', $removed ) ) );\n        }\n\n        $orphans = $find();\n        echo '<div class=\"wrap\"><h1>Orphaned Cron Events</h1>';\n        if ( ! $orphans ) {\n            echo '<p>Every scheduled event has a handler. Nothing to clean up.</p></div>';\n            return;\n        }\n        echo '<form method=\"post\" onsubmit=\"return confirm(\\'Remove the selected scheduled events?\\');\">';\n        wp_nonce_field( 'wpide_orphan_cron' );\n        echo '<table class=\"widefat striped\" style=\"max-width:640px\"><thead><tr><td class=\"check-column\"></td><th>Hook</th><th>Scheduled events</th></tr></thead><tbody>';\n        foreach ( $orphans as $hook => $count ) {\n            printf( '<tr><th class=\"check-column\"><input type=\"checkbox\" name=\"wpide_cron_hooks[]\" value=\"%1$s\" aria-label=\"%2$s\" checked></th><td><code>%3$s</code></td><td>%4$d</td></tr>', esc_attr( $hook ), esc_attr( 'Select ' . $hook ), esc_html( $hook ), (int) $count );\n        }\n        echo '</tbody></table>';\n        submit_button( 'Remove selected events' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "A task is shown when nothing is hooked to it on admin pages; a few plugins only hook their tasks during cron, so check the hook names before deleting."
        },
        {
            "id": "perf-woo-log-retention",
            "version": 1,
            "title": "Keep WooCommerce Logs for 7 Days",
            "description": "Makes WooCommerce delete its log entries (payment gateways, shipping, webhooks and more) after 7 days instead of 30, keeping the log table and folder small.",
            "category": "performance",
            "tags": [
                "woocommerce",
                "logs",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days to keep WooCommerce logs.\n$wpide_wc_log_days = 7;\n\nadd_filter( 'woocommerce_logger_days_to_retain_logs', function () use ( $wpide_wc_log_days ) {\n    return (int) $wpide_wc_log_days;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Raise the number while you are debugging a payment or shipping problem."
        },
        {
            "id": "perf-unused-image-size-files",
            "version": 1,
            "title": "Delete Image Size Files No Longer Registered",
            "description": "Adds Tools > Unused Image Sizes, which finds resized copies of your images made for sizes your current theme and plugins no longer register, shows how much space they take, and deletes them in batches after confirmation.",
            "category": "performance",
            "tags": [
                "images",
                "media",
                "disk-space"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Unused Image Sizes', 'Unused Image Sizes', 'manage_options', 'wpide-unused-sizes', function () {\n        $registered = array_merge( get_intermediate_image_sizes(), array( 'full', 'original_image' ) );\n        $uploads    = wp_get_upload_dir();\n        $delete     = isset( $_POST['wpide_delete_sizes'] ) && check_admin_referer( 'wpide_unused_sizes' ) && current_user_can( 'manage_options' );\n        $offset     = $delete ? 0 : ( isset( $_GET['offset'] ) ? absint( $_GET['offset'] ) : 0 );\n        $found      = array();\n        $bytes      = 0;\n        $deleted    = 0;\n\n        $ids = get_posts( array(\n            'post_type'      => 'attachment',\n            'post_mime_type' => 'image',\n            'post_status'    => 'inherit',\n            'posts_per_page' => 300,\n            'offset'         => $offset,\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) );\n\n        foreach ( $ids as $id ) {\n            $meta = wp_get_attachment_metadata( $id );\n            if ( empty( $meta['sizes'] ) || empty( $meta['file'] ) ) {\n                continue;\n            }\n            $dir     = trailingslashit( $uploads['basedir'] ) . trailingslashit( dirname( $meta['file'] ) );\n            $changed = false;\n            foreach ( $meta['sizes'] as $size => $data ) {\n                if ( in_array( $size, $registered, true ) || empty( $data['file'] ) ) {\n                    continue;\n                }\n                $path = $dir . $data['file'];\n                $found[ $size ] = ( isset( $found[ $size ] ) ? $found[ $size ] : 0 ) + 1;\n                if ( file_exists( $path ) ) {\n                    $bytes += filesize( $path );\n                    if ( $delete && wp_delete_file_from_directory( $path, $uploads['basedir'] ) ) {\n                        $deleted++;\n                    }\n                }\n                if ( $delete ) {\n                    unset( $meta['sizes'][ $size ] );\n                    $changed = true;\n                }\n            }\n            if ( $changed ) {\n                wp_update_attachment_metadata( $id, $meta );\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Unused Image Sizes</h1>';\n        if ( $delete ) {\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Deleted %d file(s), freeing %s. Run again to process more images.', $deleted, size_format( $bytes, 1 ) ) ) );\n        }\n        printf( '<p>%s</p>', esc_html( sprintf( 'Checked %d images (starting at #%d). Sizes registered now: %s.', count( $ids ), $offset, implode( ', ', get_intermediate_image_sizes() ) ) ) );\n        if ( $found && ! $delete ) {\n            echo '<table class=\"widefat striped\" style=\"max-width:520px\"><thead><tr><th>Unregistered size</th><th>Files</th></tr></thead><tbody>';\n            foreach ( $found as $size => $count ) {\n                printf( '<tr><td><code>%s</code></td><td>%d</td></tr>', esc_html( $size ), (int) $count );\n            }\n            printf( '</tbody></table><p>%s</p>', esc_html( 'Space used by these files: ' . size_format( $bytes, 1 ) ) );\n            echo '<form method=\"post\" onsubmit=\"return confirm(\\'Delete these resized files? Originals are kept.\\');\">';\n            wp_nonce_field( 'wpide_unused_sizes' );\n            submit_button( 'Delete unused size files in this batch', 'primary', 'wpide_delete_sizes' );\n            echo '</form>';\n        } elseif ( ! $delete ) {\n            echo '<p>No unused size files in this batch.</p>';\n        }\n        if ( count( $ids ) === 300 && ! $delete ) {\n            echo '<p><a class=\"button\" href=\"' . esc_url( add_query_arg( 'offset', $offset + 300 ) ) . '\">Check the next 300 images</a></p>';\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Pages that still hard-code a removed size will fall back to a larger size; back up your uploads folder first."
        },
        {
            "id": "perf-delete-pingbacks",
            "version": 1,
            "title": "Delete All Pingbacks and Trackbacks",
            "description": "Run on demand to permanently delete every pingback and trackback stored as a comment, which on older blogs are often thousands of spammy rows nobody reads.",
            "category": "performance",
            "tags": [
                "comments",
                "pingbacks",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "on_demand",
            "insert_number": 1,
            "code": "global $wpdb;\n\n$wpide_ids     = $wpdb->get_col( \"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_type IN ('pingback','trackback') LIMIT 5000\" );\n$wpide_deleted = 0;\n\nforeach ( $wpide_ids as $wpide_id ) {\n    if ( wp_delete_comment( (int) $wpide_id, true ) ) {\n        $wpide_deleted++;\n    }\n}\n\nprintf( 'Deleted %d pingback(s) and trackback(s).%s', $wpide_deleted, count( $wpide_ids ) === 5000 ? ' Run again to delete more.' : '' );",
            "requires": [],
            "notes": "Run it from the snippet editor; pair it with a snippet that disables pingbacks so new ones do not arrive."
        },
        {
            "id": "perf-purge-expired-sessions",
            "version": 1,
            "title": "Remove Expired Login Sessions From the Database",
            "description": "Once a day, removes expired login sessions that WordPress keeps in user meta until the user logs in again, which on membership sites and shops adds up to a lot of dead data.",
            "category": "performance",
            "tags": [
                "sessions",
                "database",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_purge_sessions' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_purge_sessions' );\n    }\n} );\n\nadd_action( 'wpide_purge_sessions', function () {\n    global $wpdb;\n\n    $rows = $wpdb->get_results( \"SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE meta_key = 'session_tokens' LIMIT 500\" );\n\n    foreach ( $rows as $row ) {\n        $sessions = maybe_unserialize( $row->meta_value );\n\n        if ( ! is_array( $sessions ) ) {\n            continue;\n        }\n\n        $active = array_filter( $sessions, function ( $session ) {\n            return isset( $session['expiration'] ) && $session['expiration'] >= time();\n        } );\n\n        if ( count( $active ) === count( $sessions ) ) {\n            continue;\n        }\n\n        if ( $active ) {\n            update_user_meta( (int) $row->user_id, 'session_tokens', $active );\n        } else {\n            delete_user_meta( (int) $row->user_id, 'session_tokens' );\n        }\n    }\n} );",
            "requires": [],
            "notes": "Processes up to 500 users per run; active sessions are never touched."
        },
        {
            "id": "perf-duplicate-query-report",
            "version": 1,
            "title": "Duplicate Database Queries Report for Administrators",
            "description": "Add ?wpide_queries=1 to any page while logged in as an administrator to see, at the bottom of the page, the queries that ran more than once and the ten slowest queries, with the code that called them.",
            "category": "performance",
            "tags": [
                "database",
                "queries",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_enable_query_log = function () {\n    if ( isset( $_GET['wpide_queries'] ) && ! defined( 'SAVEQUERIES' ) && function_exists( 'wp_get_current_user' ) && current_user_can( 'manage_options' ) ) {\n        define( 'SAVEQUERIES', true );\n    }\n};\n\nif ( did_action( 'plugins_loaded' ) ) {\n    $wpide_enable_query_log();\n} else {\n    add_action( 'plugins_loaded', $wpide_enable_query_log, 0 );\n}\n\n$wpide_query_report = function () {\n    global $wpdb;\n\n    if ( ! isset( $_GET['wpide_queries'] ) || ! current_user_can( 'manage_options' ) || empty( $wpdb->queries ) ) {\n        return;\n    }\n\n    $groups = array();\n    foreach ( $wpdb->queries as $q ) {\n        $sql = preg_replace( '/\\s+/', ' ', trim( $q[0] ) );\n        if ( ! isset( $groups[ $sql ] ) ) {\n            $groups[ $sql ] = array( 'count' => 0, 'time' => 0.0, 'caller' => $q[2] );\n        }\n        $groups[ $sql ]['count']++;\n        $groups[ $sql ]['time'] += (float) $q[1];\n    }\n\n    $dupes = array_filter( $groups, function ( $g ) {\n        return $g['count'] > 1;\n    } );\n    uasort( $dupes, function ( $a, $b ) {\n        return $b['count'] <=> $a['count'];\n    } );\n    $slow = $wpdb->queries;\n    usort( $slow, function ( $a, $b ) {\n        return $b[1] <=> $a[1];\n    } );\n\n    echo '<div style=\"position:relative;z-index:99999;background:#fff;color:#1d2327;padding:16px;font:13px/1.5 monospace;border-top:4px solid #2271b1;\">';\n    printf( '<p><strong>%s</strong></p>', esc_html( sprintf( '%d queries, %d duplicated statements.', count( $wpdb->queries ), count( $dupes ) ) ) );\n    echo '<p><strong>Duplicated queries</strong></p><ol>';\n    foreach ( array_slice( $dupes, 0, 25, true ) as $sql => $g ) {\n        printf( '<li>%s &times; %s <br><small>%s</small></li>', (int) $g['count'], esc_html( wp_html_excerpt( $sql, 300, '...' ) ), esc_html( $g['caller'] ) );\n    }\n    echo '</ol><p><strong>Slowest queries</strong></p><ol>';\n    foreach ( array_slice( $slow, 0, 10 ) as $q ) {\n        printf( '<li>%.4fs %s <br><small>%s</small></li>', (float) $q[1], esc_html( wp_html_excerpt( preg_replace( '/\\s+/', ' ', $q[0] ), 300, '...' ) ), esc_html( $q[2] ) );\n    }\n    echo '</ol></div>';\n};\n\nadd_action( 'wp_footer', $wpide_query_report, 9999 );\nadd_action( 'admin_footer', $wpide_query_report, 9999 );",
            "requires": [],
            "notes": "Query logging only switches on for that one request, so normal visitors and pages are not slowed down."
        },
        {
            "id": "perf-log-query-heavy-pages",
            "version": 1,
            "title": "Log Pages That Run Too Many Database Queries",
            "description": "Records front-end URLs that run more database queries than a limit you set, with the query count and load time, and lists the worst offenders under Tools > Heavy Pages.",
            "category": "performance",
            "tags": [
                "database",
                "monitoring",
                "queries"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Pages above this many queries are logged.\n$wpide_query_limit = 150;\n\nadd_action( 'shutdown', function () use ( $wpide_query_limit ) {\n    if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return;\n    }\n\n    $count = get_num_queries();\n\n    if ( $count < $wpide_query_limit || empty( $_SERVER['REQUEST_URI'] ) ) {\n        return;\n    }\n\n    $log   = (array) get_option( 'wpide_heavy_pages', array() );\n    $url   = esc_url_raw( home_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) );\n    $log[ md5( $url ) ] = array(\n        'url'     => $url,\n        'queries' => $count,\n        'time'    => round( timer_float(), 3 ),\n        'user'    => is_user_logged_in(),\n        'when'    => time(),\n    );\n\n    update_option( 'wpide_heavy_pages', array_slice( $log, -100, null, true ), false );\n}, 999 );\n\nadd_action( 'admin_menu', function () use ( $wpide_query_limit ) {\n    add_management_page( 'Heavy Pages', 'Heavy Pages', 'manage_options', 'wpide-heavy-pages', function () use ( $wpide_query_limit ) {\n        if ( isset( $_POST['wpide_heavy_clear'] ) && check_admin_referer( 'wpide_heavy_clear' ) ) {\n            delete_option( 'wpide_heavy_pages' );\n        }\n\n        $log = (array) get_option( 'wpide_heavy_pages', array() );\n        uasort( $log, function ( $a, $b ) {\n            return $b['queries'] <=> $a['queries'];\n        } );\n\n        echo '<div class=\"wrap\"><h1>Heavy Pages</h1><p>' . esc_html( sprintf( 'Front-end pages that ran more than %d database queries.', $wpide_query_limit ) ) . '</p>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>URL</th><th>Queries</th><th>Load time</th><th>Logged in</th><th>Last seen</th></tr></thead><tbody>';\n        if ( ! $log ) {\n            echo '<tr><td colspan=\"5\">Nothing logged yet.</td></tr>';\n        }\n        foreach ( $log as $row ) {\n            printf( '<tr><td><a href=\"%1$s\">%2$s</a></td><td>%3$d</td><td>%4$ss</td><td>%5$s</td><td>%6$s</td></tr>', esc_url( $row['url'] ), esc_html( $row['url'] ), (int) $row['queries'], esc_html( $row['time'] ), $row['user'] ? 'yes' : 'no', esc_html( human_time_diff( (int) $row['when'] ) . ' ago' ) );\n        }\n        echo '</tbody></table><form method=\"post\">';\n        wp_nonce_field( 'wpide_heavy_clear' );\n        submit_button( 'Clear log', 'secondary', 'wpide_heavy_clear' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Keeps the 100 most recent heavy pages in one option; nothing is logged for admin, AJAX, REST or cron requests."
        },
        {
            "id": "perf-log-slow-http-requests",
            "version": 1,
            "title": "Log Slow Outgoing HTTP Requests",
            "description": "Times every request your site makes to other servers (update checks, APIs, license servers) and records the ones slower than a second under Tools > Slow HTTP Calls, so you can find what is holding up page loads.",
            "category": "performance",
            "tags": [
                "http",
                "monitoring",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Requests slower than this many seconds are logged.\n$wpide_http_slow = 1.0;\n\nadd_filter( 'pre_http_request', function ( $pre, $args, $url ) {\n    $GLOBALS['wpide_http_timers'][ md5( $url ) ] = microtime( true );\n    return $pre;\n}, 1, 3 );\n\nadd_action( 'http_api_debug', function ( $response, $context, $class, $args, $url ) use ( $wpide_http_slow ) {\n    $key = md5( $url );\n\n    if ( empty( $GLOBALS['wpide_http_timers'][ $key ] ) ) {\n        return;\n    }\n\n    $time = microtime( true ) - $GLOBALS['wpide_http_timers'][ $key ];\n    unset( $GLOBALS['wpide_http_timers'][ $key ] );\n\n    if ( $time < $wpide_http_slow ) {\n        return;\n    }\n\n    $parts   = wp_parse_url( $url );\n    $clean   = ( isset( $parts['host'] ) ? $parts['host'] : '' ) . ( isset( $parts['path'] ) ? $parts['path'] : '' );\n    $where   = is_admin() ? 'admin' : ( wp_doing_cron() ? 'cron' : 'front' );\n    $log     = (array) get_option( 'wpide_slow_http', array() );\n    $log[]   = array(\n        'url'    => $clean,\n        'method' => isset( $args['method'] ) ? $args['method'] : 'GET',\n        'time'   => round( $time, 2 ),\n        'status' => is_wp_error( $response ) ? $response->get_error_message() : wp_remote_retrieve_response_code( $response ),\n        'where'  => $where,\n        'when'   => time(),\n    );\n\n    update_option( 'wpide_slow_http', array_slice( $log, -100 ), false );\n}, 10, 5 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Slow HTTP Calls', 'Slow HTTP Calls', 'manage_options', 'wpide-slow-http', function () {\n        $log = array_reverse( (array) get_option( 'wpide_slow_http', array() ) );\n\n        echo '<div class=\"wrap\"><h1>Slow HTTP Calls</h1><table class=\"widefat striped\"><thead><tr><th>When</th><th>Request</th><th>Time</th><th>Result</th><th>Context</th></tr></thead><tbody>';\n        if ( ! $log ) {\n            echo '<tr><td colspan=\"5\">No slow requests logged yet.</td></tr>';\n        }\n        foreach ( $log as $row ) {\n            printf( '<tr><td>%s</td><td><code>%s %s</code></td><td>%ss</td><td>%s</td><td>%s</td></tr>', esc_html( human_time_diff( (int) $row['when'] ) . ' ago' ), esc_html( $row['method'] ), esc_html( $row['url'] ), esc_html( $row['time'] ), esc_html( (string) $row['status'] ), esc_html( $row['where'] ) );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Keeps the latest 100 slow calls; URLs are stored without their query strings so API keys are not logged."
        },
        {
            "id": "perf-slow-cron-log",
            "version": 1,
            "title": "Log Scheduled Tasks That Take Too Long",
            "description": "Measures how long each scheduled task (cron event) takes to run and records the slow ones under Tools > Slow Cron Tasks, so you can spot the plugin that makes WP-Cron sluggish.",
            "category": "performance",
            "tags": [
                "cron",
                "monitoring",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Tasks slower than this many seconds are logged.\n$wpide_cron_slow = 3;\n\nadd_action( 'init', function () use ( $wpide_cron_slow ) {\n    if ( ! wp_doing_cron() ) {\n        return;\n    }\n\n    foreach ( (array) _get_cron_array() as $time => $hooks ) {\n        if ( $time > time() ) {\n            break;\n        }\n        foreach ( array_keys( (array) $hooks ) as $hook ) {\n            add_action( $hook, function () use ( $hook ) {\n                $GLOBALS['wpide_cron_start'][ $hook ] = microtime( true );\n            }, PHP_INT_MIN );\n            add_action( $hook, function () use ( $hook, $wpide_cron_slow ) {\n                if ( empty( $GLOBALS['wpide_cron_start'][ $hook ] ) ) {\n                    return;\n                }\n                $took = microtime( true ) - $GLOBALS['wpide_cron_start'][ $hook ];\n                if ( $took >= $wpide_cron_slow ) {\n                    $log            = (array) get_option( 'wpide_slow_cron', array() );\n                    $log[ $hook ]   = array( 'time' => round( $took, 2 ), 'when' => time(), 'max' => max( $took, isset( $log[ $hook ]['max'] ) ? (float) $log[ $hook ]['max'] : 0 ) );\n                    update_option( 'wpide_slow_cron', array_slice( $log, -50, null, true ), false );\n                }\n            }, PHP_INT_MAX );\n        }\n    }\n}, 99 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Slow Cron Tasks', 'Slow Cron Tasks', 'manage_options', 'wpide-slow-cron', function () {\n        $log = (array) get_option( 'wpide_slow_cron', array() );\n\n        echo '<div class=\"wrap\"><h1>Slow Cron Tasks</h1><table class=\"widefat striped\"><thead><tr><th>Hook</th><th>Last run</th><th>Slowest run</th><th>When</th></tr></thead><tbody>';\n        if ( ! $log ) {\n            echo '<tr><td colspan=\"4\">No slow tasks recorded yet.</td></tr>';\n        }\n        foreach ( $log as $hook => $row ) {\n            printf( '<tr><td><code>%s</code></td><td>%ss</td><td>%ss</td><td>%s</td></tr>', esc_html( $hook ), esc_html( $row['time'] ), esc_html( round( (float) $row['max'], 2 ) ), esc_html( human_time_diff( (int) $row['when'] ) . ' ago' ) );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Only runs during cron requests; tasks faster than $wpide_cron_slow seconds are not recorded."
        },
        {
            "id": "perf-server-timing-header",
            "version": 1,
            "title": "Server-Timing Header for Administrators",
            "description": "Adds a Server-Timing header with PHP time, database query count and peak memory to pages viewed by administrators, so you can read the numbers in your browser's developer tools Network tab.",
            "category": "performance",
            "tags": [
                "debugging",
                "headers",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_server_timing = function () {\n    if ( headers_sent() || ! is_user_logged_in() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    header( sprintf(\n        'Server-Timing: php;dur=%.1f;desc=\"PHP until the template starts\", db;desc=\"%d queries\", mem;desc=\"%s peak memory\"',\n        timer_float() * 1000,\n        get_num_queries(),\n        size_format( memory_get_peak_usage( true ), 1 )\n    ) );\n};\n\n// Sent just before the page template (or admin screen) starts rendering.\nadd_action( 'template_redirect', $wpide_server_timing, PHP_INT_MAX );\nadd_action( 'admin_init', $wpide_server_timing, PHP_INT_MAX );",
            "requires": [],
            "notes": "Visitors never receive the header; the time covers everything WordPress does before the page template starts rendering."
        },
        {
            "id": "perf-web-vitals-collector",
            "version": 1,
            "title": "Collect Real Core Web Vitals From Your Visitors",
            "description": "Measures Largest Contentful Paint, Cumulative Layout Shift and Interaction to Next Paint in visitors' browsers and shows the 75th percentile of the last 7 days per page type in a dashboard widget.",
            "category": "performance",
            "tags": [
                "core-web-vitals",
                "monitoring",
                "dashboard"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Share of page views that report (0.2 = 20%).\n$wpide_vitals_sample = 0.2;\n\nadd_action( 'rest_api_init', function () {\n    register_rest_route( 'wpide/v1', '/vitals', array(\n        'methods'             => 'POST',\n        'permission_callback' => '__return_true',\n        'args'                => array(\n            'type' => array( 'type' => 'string', 'enum' => array( 'home', 'single', 'page', 'archive', 'product', 'other' ), 'required' => true ),\n            'lcp'  => array( 'type' => 'number', 'minimum' => 0, 'maximum' => 60000 ),\n            'cls'  => array( 'type' => 'number', 'minimum' => 0, 'maximum' => 10 ),\n            'inp'  => array( 'type' => 'number', 'minimum' => 0, 'maximum' => 60000 ),\n        ),\n        'callback'            => function ( WP_REST_Request $request ) {\n            $day   = gmdate( 'Ymd' );\n            $store = (array) get_option( 'wpide_vitals', array() );\n            $type  = $request['type'];\n\n            foreach ( array( 'lcp', 'cls', 'inp' ) as $metric ) {\n                if ( null === $request[ $metric ] ) {\n                    continue;\n                }\n                $values = isset( $store[ $day ][ $type ][ $metric ] ) ? $store[ $day ][ $type ][ $metric ] : array();\n                if ( count( $values ) < 500 ) {\n                    $values[] = 'cls' === $metric ? round( (float) $request[ $metric ], 3 ) : (int) $request[ $metric ];\n                }\n                $store[ $day ][ $type ][ $metric ] = $values;\n            }\n\n            krsort( $store );\n            update_option( 'wpide_vitals', array_slice( $store, 0, 7, true ), false );\n\n            return new WP_REST_Response( null, 204 );\n        },\n    ) );\n} );\n\nadd_action( 'wp_footer', function () use ( $wpide_vitals_sample ) {\n    if ( is_user_logged_in() ) {\n        return;\n    }\n\n    $type = is_front_page() ? 'home' : ( function_exists( 'is_product' ) && is_product() ? 'product' : ( is_single() ? 'single' : ( is_page() ? 'page' : ( is_archive() || is_home() ? 'archive' : 'other' ) ) ) );\n    ?>\n<script>\n(function () {\n    if (Math.random() > <?php echo (float) $wpide_vitals_sample; ?> || !('PerformanceObserver' in window)) { return; }\n    var data = { type: <?php echo wp_json_encode( $type ); ?> }, cls = 0, inp = 0;\n    function observe(type, cb) { try { new PerformanceObserver(function (l) { l.getEntries().forEach(cb); }).observe({ type: type, buffered: true }); } catch (e) {} }\n    observe('largest-contentful-paint', function (e) { data.lcp = Math.round(e.startTime); });\n    observe('layout-shift', function (e) { if (!e.hadRecentInput) { cls += e.value; data.cls = Math.round(cls * 1000) / 1000; } });\n    observe('event', function (e) { if (e.interactionId && e.duration > inp) { inp = e.duration; data.inp = Math.round(inp); } });\n    var sent = false;\n    function send() {\n        if (sent || document.visibilityState !== 'hidden') { return; }\n        sent = true;\n        navigator.sendBeacon && navigator.sendBeacon(<?php echo wp_json_encode( esc_url_raw( rest_url( 'wpide/v1/vitals' ) ) ); ?>, new Blob([JSON.stringify(data)], { type: 'application/json' }));\n    }\n    document.addEventListener('visibilitychange', send);\n})();\n</script>\n    <?php\n}, 99 );\n\nadd_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_vitals', 'Core Web Vitals (last 7 days, p75)', function () {\n        $merged = array();\n        foreach ( (array) get_option( 'wpide_vitals', array() ) as $types ) {\n            foreach ( $types as $type => $metrics ) {\n                foreach ( $metrics as $metric => $values ) {\n                    $merged[ $type ][ $metric ] = array_merge( isset( $merged[ $type ][ $metric ] ) ? $merged[ $type ][ $metric ] : array(), $values );\n                }\n            }\n        }\n        if ( ! $merged ) {\n            echo '<p>No measurements yet. Data appears after visitors browse the site.</p>';\n            return;\n        }\n        $p75 = function ( $values ) {\n            sort( $values );\n            return $values ? $values[ (int) floor( 0.75 * ( count( $values ) - 1 ) ) ] : null;\n        };\n        echo '<table class=\"widefat striped\"><thead><tr><th>Page type</th><th>LCP</th><th>CLS</th><th>INP</th><th>Samples</th></tr></thead><tbody>';\n        foreach ( $merged as $type => $m ) {\n            $lcp = isset( $m['lcp'] ) ? $p75( $m['lcp'] ) : null;\n            $cls = isset( $m['cls'] ) ? $p75( $m['cls'] ) : null;\n            $inp = isset( $m['inp'] ) ? $p75( $m['inp'] ) : null;\n            printf(\n                '<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%d</td></tr>',\n                esc_html( $type ),\n                null === $lcp ? '-' : esc_html( round( $lcp / 1000, 2 ) . 's' ),\n                null === $cls ? '-' : esc_html( $cls ),\n                null === $inp ? '-' : esc_html( $inp . 'ms' ),\n                isset( $m['lcp'] ) ? count( $m['lcp'] ) : 0\n            );\n        }\n        echo '</tbody></table><p class=\"description\">Good: LCP under 2.5s, CLS under 0.1, INP under 200ms.</p>';\n    } );\n} );",
            "requires": [],
            "notes": "Samples 20% of page views by default and stores only numbers per page type, no visitor data; values are rounded and capped to keep storage tiny."
        },
        {
            "id": "perf-admin-ajax-usage-log",
            "version": 1,
            "title": "Find What Is Calling admin-ajax.php",
            "description": "Counts admin-ajax.php requests per action (and whether they come from the front end or the dashboard) for the current day and the previous six, and shows the busiest ones under Tools > AJAX Usage.",
            "category": "performance",
            "tags": [
                "ajax",
                "monitoring",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    if ( ! wp_doing_ajax() || empty( $_REQUEST['action'] ) ) {\n        return;\n    }\n\n    $action = substr( sanitize_key( wp_unslash( $_REQUEST['action'] ) ), 0, 60 );\n    $from   = ( isset( $_SERVER['HTTP_REFERER'] ) && false !== strpos( (string) wp_unslash( $_SERVER['HTTP_REFERER'] ), '/wp-admin/' ) ) ? 'admin' : 'front';\n\n    add_action( 'shutdown', function () use ( $action, $from ) {\n        $day = gmdate( 'Ymd' );\n        $log = (array) get_option( 'wpide_ajax_usage', array() );\n        $key = $action . '|' . $from;\n\n        $log[ $day ][ $key ] = ( isset( $log[ $day ][ $key ] ) ? (int) $log[ $day ][ $key ] : 0 ) + 1;\n        krsort( $log );\n        update_option( 'wpide_ajax_usage', array_slice( $log, 0, 7, true ), false );\n    } );\n}, 0 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'AJAX Usage', 'AJAX Usage', 'manage_options', 'wpide-ajax-usage', function () {\n        $totals = array();\n        foreach ( (array) get_option( 'wpide_ajax_usage', array() ) as $day ) {\n            foreach ( $day as $key => $count ) {\n                $totals[ $key ] = ( isset( $totals[ $key ] ) ? $totals[ $key ] : 0 ) + (int) $count;\n            }\n        }\n        arsort( $totals );\n\n        echo '<div class=\"wrap\"><h1>AJAX Usage (last 7 days)</h1><table class=\"widefat striped\" style=\"max-width:720px\"><thead><tr><th>Action</th><th>Called from</th><th>Requests</th></tr></thead><tbody>';\n        if ( ! $totals ) {\n            echo '<tr><td colspan=\"3\">No AJAX requests recorded yet.</td></tr>';\n        }\n        foreach ( array_slice( $totals, 0, 50, true ) as $key => $count ) {\n            list( $action, $from ) = explode( '|', $key );\n            printf( '<tr><td><code>%s</code></td><td>%s</td><td>%s</td></tr>', esc_html( $action ), esc_html( $from ), esc_html( number_format_i18n( $count ) ) );\n        }\n        echo '</tbody></table><p class=\"description\">\"heartbeat\" is the WordPress Heartbeat API; high front-end counts usually come from plugins polling for updates.</p></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Counts are batched in memory and written once per request; deactivate the snippet when you have found the culprit."
        },
        {
            "id": "perf-page-weight-admin-bar",
            "version": 1,
            "title": "Page Weight Summary in the Admin Bar",
            "description": "Shows an admin bar item on the front of the site with the total size of the page's scripts, stylesheets, images and fonts, and a breakdown with the heaviest files when you click it.",
            "category": "performance",
            "tags": [
                "admin-bar",
                "page-weight",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-page-weight',\n        'title' => 'Page weight: ...',\n        'href'  => '#',\n    ) );\n}, 96 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function () {\n    var link = document.querySelector('#wp-admin-bar-wpide-page-weight > a');\n    if (!link || !window.performance || !performance.getEntriesByType) { return; }\n    function kb(b) { return b > 1048576 ? (b / 1048576).toFixed(2) + ' MB' : Math.round(b / 1024) + ' KB'; }\n    function collect() {\n        var groups = {}, files = [], total = 0;\n        var nav = performance.getEntriesByType('navigation')[0];\n        if (nav) { total += nav.transferSize || 0; groups.html = nav.transferSize || 0; }\n        performance.getEntriesByType('resource').forEach(function (r) {\n            var size = r.transferSize || r.encodedBodySize || 0;\n            var type = /\\.css(\\?|$)/.test(r.name) || r.initiatorType === 'css' && /\\.css/.test(r.name) ? 'css' : (r.initiatorType === 'script' ? 'js' : (r.initiatorType === 'img' || /\\.(png|jpe?g|gif|webp|avif|svg)(\\?|$)/.test(r.name) ? 'images' : (/\\.(woff2?|ttf|otf)(\\?|$)/.test(r.name) ? 'fonts' : 'other')));\n            groups[type] = (groups[type] || 0) + size;\n            total += size;\n            files.push([size, r.name]);\n        });\n        files.sort(function (a, b) { return b[0] - a[0]; });\n        return { groups: groups, files: files, total: total };\n    }\n    window.addEventListener('load', function () {\n        setTimeout(function () { link.textContent = 'Page weight: ' + kb(collect().total); }, 1000);\n    });\n    link.addEventListener('click', function (e) {\n        e.preventDefault();\n        var d = collect();\n        var lines = Object.keys(d.groups).map(function (k) { return k + ': ' + kb(d.groups[k]); });\n        lines.push('', 'Heaviest files:');\n        d.files.slice(0, 8).forEach(function (f) { lines.push(kb(f[0]) + '  ' + f[1].split('?')[0]); });\n        window.alert('Total: ' + kb(d.total) + '\\n\\n' + lines.join('\\n'));\n    });\n})();\n</script>\n    <?php\n}, 99 );",
            "requires": [],
            "notes": "Sizes are the transferred (compressed) bytes reported by your browser; files served from another domain without timing headers count as 0."
        },
        {
            "id": "perf-compression-caching-site-health",
            "version": 1,
            "title": "Site Health Check for Compression and Browser Caching",
            "description": "Adds a Site Health test that requests your homepage and one of your theme's stylesheets and reports whether text files are compressed (gzip or Brotli) and whether static files get long browser cache lifetimes.",
            "category": "performance",
            "tags": [
                "site-health",
                "compression",
                "caching"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'site_status_tests', function ( $tests ) {\n    $tests['direct']['wpide_compression_cache'] = array(\n        'label' => 'Compression and browser caching',\n        'test'  => function () {\n            $result = get_transient( 'wpide_compression_check' );\n\n            if ( false === $result ) {\n                $args = array( 'timeout' => 8, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), 'headers' => array( 'Accept-Encoding' => 'gzip, br' ), 'decompress' => false );\n                $home = wp_remote_get( home_url( '/' ), $args );\n                $css  = wp_remote_get( get_stylesheet_uri(), $args );\n                $cc   = (string) wp_remote_retrieve_header( $css, 'cache-control' );\n                $age  = preg_match( '/max-age=(\\d+)/', $cc, $m ) ? (int) $m[1] : 0;\n                $result = array(\n                    'compressed' => ! is_wp_error( $home ) && '' !== (string) wp_remote_retrieve_header( $home, 'content-encoding' ),\n                    'cached'     => ! is_wp_error( $css ) && ( $age >= 30 * DAY_IN_SECONDS || '' !== (string) wp_remote_retrieve_header( $css, 'expires' ) ),\n                    'error'      => is_wp_error( $home ),\n                );\n                set_transient( 'wpide_compression_check', $result, 12 * HOUR_IN_SECONDS );\n            }\n\n            $problems = array();\n            if ( ! $result['compressed'] ) {\n                $problems[] = 'pages are not compressed with gzip or Brotli';\n            }\n            if ( ! $result['cached'] ) {\n                $problems[] = 'static files such as CSS do not have a long browser cache lifetime';\n            }\n\n            return array(\n                'label'       => $result['error'] ? 'Compression and caching could not be checked' : ( $problems ? 'Compression or browser caching is missing' : 'Compression and browser caching are enabled' ),\n                'status'      => $problems ? 'recommended' : 'good',\n                'badge'       => array( 'label' => 'Performance', 'color' => 'blue' ),\n                'description' => '<p>' . esc_html( $problems ? 'Your server reports that ' . implode( ' and ', $problems ) . '. Ask your host to enable compression and long cache headers for static files.' : 'Text responses are compressed and static files are cached by browsers.' ) . '</p>',\n                'test'        => 'wpide_compression_cache',\n            );\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Both are configured in your web server or CDN, not in WordPress; the result is cached for 12 hours."
        },
        {
            "id": "perf-preload-lcp-image-per-page",
            "version": 1,
            "title": "Preload a Chosen Hero Image per Page",
            "description": "Adds a \"Preload hero image\" field to the post and page editor; when filled in with an image URL, that image is preloaded with high priority on that page, improving Largest Contentful Paint for pages whose main image is not the featured image.",
            "category": "performance",
            "tags": [
                "preload",
                "lcp",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that get the field.\n$wpide_lcp_types = array( 'post', 'page' );\n\nadd_action( 'add_meta_boxes', function () use ( $wpide_lcp_types ) {\n    add_meta_box( 'wpide-lcp-preload', 'Preload hero image', function ( $post ) {\n        wp_nonce_field( 'wpide_lcp_preload', 'wpide_lcp_nonce' );\n        printf(\n            '<label for=\"wpide-lcp-url\" class=\"screen-reader-text\">Hero image URL</label><input type=\"url\" id=\"wpide-lcp-url\" name=\"wpide_lcp_url\" class=\"widefat\" value=\"%s\" placeholder=\"https://...\"><p class=\"description\">URL of the large image at the top of this page.</p>',\n            esc_attr( get_post_meta( $post->ID, '_wpide_lcp_url', true ) )\n        );\n    }, $wpide_lcp_types, 'side', 'low' );\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    if ( ! isset( $_POST['wpide_lcp_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_lcp_nonce'] ), 'wpide_lcp_preload' ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n\n    $url = isset( $_POST['wpide_lcp_url'] ) ? esc_url_raw( wp_unslash( $_POST['wpide_lcp_url'] ) ) : '';\n\n    if ( $url ) {\n        update_post_meta( $post_id, '_wpide_lcp_url', $url );\n    } else {\n        delete_post_meta( $post_id, '_wpide_lcp_url' );\n    }\n} );\n\nadd_filter( 'wp_preload_resources', function ( $resources ) {\n    if ( ! is_singular() ) {\n        return $resources;\n    }\n\n    $url = get_post_meta( get_queried_object_id(), '_wpide_lcp_url', true );\n\n    if ( $url ) {\n        $resources[] = array( 'href' => $url, 'as' => 'image', 'fetchpriority' => 'high' );\n    }\n\n    return $resources;\n} );",
            "requires": [],
            "notes": "Use it for hero images set in page builders or cover blocks; preloading the wrong image wastes bandwidth, so pick the one visible at the top."
        },
        {
            "id": "perf-delay-third-party-scripts",
            "version": 1,
            "title": "Delay Third-Party Scripts Until the Visitor Interacts",
            "description": "Holds back the scripts you list (chat widgets, heatmaps, social widgets) until the visitor first scrolls, taps, clicks or presses a key, or after a timeout, so they no longer slow down the initial page load.",
            "category": "performance",
            "tags": [
                "javascript",
                "delay",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Script handles, or fragments of their URL, to delay.\n$wpide_delay_scripts = array( 'hotjar', 'tawk', 'intercom', 'crisp', 'facebook', 'connect.facebook.net', 'static.hotjar.com' );\n// Seconds after which delayed scripts load anyway (0 = only on interaction).\n$wpide_delay_timeout = 8;\n\nadd_filter( 'script_loader_tag', function ( $tag, $handle, $src ) use ( $wpide_delay_scripts ) {\n    $match = false;\n\n    foreach ( $wpide_delay_scripts as $needle ) {\n        if ( $handle === $needle || ( $src && false !== strpos( $src, $needle ) ) ) {\n            $match = true;\n            break;\n        }\n    }\n\n    if ( ! $match || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $tag;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $tag );\n\n    while ( $tags->next_tag( 'script' ) ) {\n        $tags->set_attribute( 'type', 'text/wpide-delay' );\n    }\n\n    return $tags->get_updated_html();\n}, 30, 3 );\n\nadd_action( 'wp_footer', function () use ( $wpide_delay_timeout ) {\n    ?>\n<script>\n(function () {\n    var done = false, events = ['scroll', 'pointerdown', 'keydown', 'touchstart', 'mousemove'];\n    function run() {\n        if (done) { return; }\n        done = true;\n        events.forEach(function (e) { window.removeEventListener(e, run, { passive: true }); });\n        var list = Array.prototype.slice.call(document.querySelectorAll('script[type=\"text/wpide-delay\"]'));\n        (function next() {\n            var old = list.shift();\n            if (!old) { return; }\n            var s = document.createElement('script');\n            Array.prototype.forEach.call(old.attributes, function (a) { if (a.name !== 'type') { s.setAttribute(a.name, a.value); } });\n            if (old.src) { s.onload = s.onerror = next; s.async = false; } else { s.text = old.text; }\n            old.parentNode.replaceChild(s, old);\n            if (!old.src) { next(); }\n        })();\n    }\n    events.forEach(function (e) { window.addEventListener(e, run, { passive: true }); });\n    <?php if ( $wpide_delay_timeout > 0 ) : ?>\n    setTimeout(run, <?php echo (int) $wpide_delay_timeout * 1000; ?>);\n    <?php endif; ?>\n})();\n</script>\n    <?php\n}, 999 );",
            "requires": [],
            "notes": "List handles or parts of script URLs; never delay scripts that render content above the fold or that other scripts depend on."
        },
        {
            "id": "perf-remove-global-styles-classic",
            "version": 1,
            "title": "Remove Block Theme Styles From Classic Themes",
            "description": "Stops classic (non-block) themes from loading the global styles, classic theme styles and duotone SVG filters that WordPress adds for block themes, saving inline CSS on every page.",
            "category": "performance",
            "tags": [
                "css",
                "classic-themes",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    if ( wp_is_block_theme() ) {\n        return;\n    }\n\n    wp_dequeue_style( 'global-styles' );\n    wp_dequeue_style( 'classic-theme-styles' );\n    wp_dequeue_style( 'core-block-supports-duotone' );\n}, 100 );\n\nadd_action( 'init', function () {\n    if ( function_exists( 'wp_is_block_theme' ) && ! wp_is_block_theme() ) {\n        remove_action( 'wp_body_open', 'wp_global_styles_render_svg_filters' );\n        remove_action( 'wp_footer', 'wp_enqueue_global_styles', 1 );\n    }\n} );",
            "requires": [],
            "notes": "Only runs on classic themes; check that buttons and colors set in the editor still look right after enabling it."
        },
        {
            "id": "perf-inline-small-block-styles",
            "version": 1,
            "title": "Inline Small Block Stylesheets Instead of Loading Files",
            "description": "Lets WordPress print more of the small per-block stylesheets directly in the page instead of as separate files, which saves several network requests on pages with many different blocks.",
            "category": "performance",
            "tags": [
                "css",
                "blocks",
                "requests"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Total bytes of block CSS WordPress may inline per page (the default is 20000).\n$wpide_inline_budget = 40000;\n\nadd_filter( 'styles_inline_size_limit', function () use ( $wpide_inline_budget ) {\n    return (int) $wpide_inline_budget;\n} );",
            "requires": [],
            "notes": "Works together with loading block styles separately; raise or lower the byte budget to balance page size against request count."
        },
        {
            "id": "perf-preload-key-assets",
            "version": 1,
            "title": "Preload Key Scripts and Stylesheets by Handle",
            "description": "Adds preload hints for the enqueued scripts and stylesheets you name, so the browser fetches files that are discovered late (such as a slider script or icon font CSS) right at the start of the page load.",
            "category": "performance",
            "tags": [
                "preload",
                "scripts",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Handles to preload: 'script' or 'style'.\n$wpide_preload_handles = array(\n    'swiper'       => 'script',\n    'font-awesome' => 'style',\n);\n\nadd_filter( 'wp_preload_resources', function ( $resources ) use ( $wpide_preload_handles ) {\n    foreach ( $wpide_preload_handles as $handle => $type ) {\n        $registry = 'script' === $type ? wp_scripts() : wp_styles();\n\n        if ( empty( $registry->registered[ $handle ] ) || ! in_array( $handle, $registry->queue, true ) || ! $registry->registered[ $handle ]->src ) {\n            continue;\n        }\n\n        $item = $registry->registered[ $handle ];\n        $src  = $item->src;\n\n        if ( ! preg_match( '#^(https?:)?//#', $src ) ) {\n            $src = $registry->base_url . $src;\n        }\n\n        $ver = $item->ver ? $item->ver : $registry->default_version;\n        $resources[] = array(\n            'href' => add_query_arg( 'ver', $ver, $src ),\n            'as'   => 'script' === $type ? 'script' : 'style',\n        );\n    }\n\n    return $resources;\n} );",
            "requires": [],
            "notes": "Only handles that are actually enqueued on the current page are preloaded; preloading too much slows down more important files."
        },
        {
            "id": "perf-google-fonts-display-swap",
            "version": 1,
            "title": "Show Text Immediately While Google Fonts Load",
            "description": "Adds display=swap to every Google Fonts stylesheet your theme and plugins load, so text appears right away in a fallback font instead of staying invisible while the web font downloads.",
            "category": "performance",
            "tags": [
                "google-fonts",
                "fonts",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'style_loader_src', function ( $src ) {\n    if ( ! is_string( $src ) || false === strpos( $src, 'fonts.googleapis.com' ) || false !== strpos( $src, 'display=' ) ) {\n        return $src;\n    }\n\n    return $src . ( false === strpos( $src, '?' ) ? '?' : '&' ) . 'display=swap';\n}, 20 );",
            "requires": [],
            "notes": "URLs that already set a display value are left alone."
        },
        {
            "id": "perf-prefetch-next-page",
            "version": 1,
            "title": "Prefetch the Next Page of Archives and Paginated Posts",
            "description": "Adds a prefetch hint for the next page of blog archives, category pages and multi-page posts, so it opens almost instantly when visitors click \"Next\".",
            "category": "performance",
            "tags": [
                "prefetch",
                "pagination",
                "archives"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "add_action( 'wp_head', function () {\n    global $wp_query, $page, $numpages;\n\n    $next = '';\n\n    if ( ( is_home() || is_archive() || is_search() ) && get_query_var( 'paged', 1 ) < (int) $wp_query->max_num_pages ) {\n        $next = get_next_posts_page_link();\n    } elseif ( is_singular() && $numpages > 1 && $page < $numpages ) {\n        $next = _wp_link_page( $page + 1 );\n        $next = preg_match( '#href=\"([^\"]+)\"#', $next, $m ) ? $m[1] : '';\n    }\n\n    if ( $next ) {\n        printf( \"<script>if(!(navigator.connection&&navigator.connection.saveData)){var l=document.createElement('link');l.rel='prefetch';l.href=%s;document.head.appendChild(l);}</script>\\n\", wp_json_encode( esc_url_raw( $next ) ) );\n    }\n}, 20 );",
            "requires": [],
            "notes": "Prefetching uses a little extra bandwidth for visitors who never click; it is skipped for visitors with data saver turned on."
        },
        {
            "id": "perf-inline-critical-css",
            "version": 1,
            "title": "Inline Critical CSS From Your Theme",
            "description": "Prints the contents of a critical.css file from your active theme folder directly in the page head, so above-the-fold content is styled before the main stylesheet finishes loading.",
            "category": "performance",
            "tags": [
                "css",
                "critical-css",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// File name inside the active theme folder.\n$wpide_critical_file = 'critical.css';\n\nadd_action( 'wp_head', function () use ( $wpide_critical_file ) {\n    $path = get_stylesheet_directory() . '/' . $wpide_critical_file;\n\n    if ( ! is_readable( $path ) ) {\n        return;\n    }\n\n    $css = wp_cache_get( 'wpide_critical_css', 'wpide' );\n\n    if ( false === $css ) {\n        $css = (string) file_get_contents( $path );\n        $css = preg_replace( '#/\\*.*?\\*/#s', '', $css );\n        $css = trim( preg_replace( '/\\s+/', ' ', $css ) );\n        wp_cache_set( 'wpide_critical_css', $css, 'wpide', HOUR_IN_SECONDS );\n    }\n\n    if ( $css ) {\n        echo '<style id=\"wpide-critical-css\">' . wp_strip_all_tags( $css ) . \"</style>\\n\";\n    }\n}, 1 );",
            "requires": [],
            "notes": "Generate a critical.css file (under 14 KB) with a critical CSS tool and put it in your (child) theme folder; nothing happens until the file exists."
        },
        {
            "id": "perf-content-image-dimensions",
            "version": 1,
            "title": "Add Missing Width and Height to Images in Content",
            "description": "Adds width and height attributes to images in your posts that lack them (for example images pasted from other sites or old posts), using the real image file size, which prevents layout jumps while pages load.",
            "category": "performance",
            "tags": [
                "images",
                "cls",
                "core-web-vitals"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'the_content', function ( $content ) {\n    if ( false === stripos( $content, '<img' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $uploads = wp_get_upload_dir();\n    $base    = set_url_scheme( $uploads['baseurl'], 'https' );\n    $tags    = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'img' ) ) {\n        if ( $tags->get_attribute( 'width' ) && $tags->get_attribute( 'height' ) ) {\n            continue;\n        }\n\n        $src = set_url_scheme( strtok( (string) $tags->get_attribute( 'src' ), '?' ), 'https' );\n\n        if ( 0 !== strpos( $src, $base ) ) {\n            continue;\n        }\n\n        $key  = 'wpide_dim_' . md5( $src );\n        $size = wp_cache_get( $key, 'wpide' );\n\n        if ( false === $size ) {\n            $size = get_transient( $key );\n        }\n\n        if ( false === $size ) {\n            $path = $uploads['basedir'] . substr( $src, strlen( $base ) );\n            $info = ( false === strpos( $path, '..' ) && is_readable( $path ) ) ? wp_getimagesize( $path ) : false;\n            $size = $info ? array( (int) $info[0], (int) $info[1] ) : array( 0, 0 );\n            set_transient( $key, $size, MONTH_IN_SECONDS );\n        }\n\n        wp_cache_set( $key, $size, 'wpide' );\n\n        if ( $size[0] && $size[1] ) {\n            $tags->set_attribute( 'width', (string) $size[0] );\n            $tags->set_attribute( 'height', (string) $size[1] );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Only images stored in your uploads folder can be measured; sizes are cached per image so files are not read on every page view."
        },
        {
            "id": "perf-serve-uploads-from-cdn",
            "version": 1,
            "title": "Serve Media and Static Files From a CDN Domain",
            "description": "Rewrites the URLs of your uploads, theme and plugin files to a CDN or pull-zone domain you set, so images, CSS and JavaScript are delivered from the CDN while pages are still served by your site.",
            "category": "performance",
            "tags": [
                "cdn",
                "images",
                "static-files"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Your CDN address, without a trailing slash.\n$wpide_cdn_url = 'https://cdn.example.com';\n// Folders whose files are served from the CDN.\n$wpide_cdn_dirs = array( 'wp-content/uploads', 'wp-content/themes', 'wp-content/plugins', 'wp-includes' );\n\nif ( 'https://cdn.example.com' !== $wpide_cdn_url ) {\n    $wpide_cdn_rewrite = function ( $url ) use ( $wpide_cdn_url, $wpide_cdn_dirs ) {\n        if ( ! is_string( $url ) || is_admin() || is_preview() || is_customize_preview() ) {\n            return $url;\n        }\n\n        $site = untrailingslashit( set_url_scheme( site_url(), 'https' ) );\n\n        foreach ( $wpide_cdn_dirs as $dir ) {\n            foreach ( array( $site, set_url_scheme( $site, 'http' ) ) as $origin ) {\n                if ( 0 === strpos( $url, $origin . '/' . $dir . '/' ) ) {\n                    return untrailingslashit( $wpide_cdn_url ) . substr( $url, strlen( $origin ) );\n                }\n            }\n        }\n\n        return $url;\n    };\n\n    add_filter( 'wp_get_attachment_url', $wpide_cdn_rewrite, 99 );\n    add_filter( 'script_loader_src', $wpide_cdn_rewrite, 99 );\n    add_filter( 'style_loader_src', $wpide_cdn_rewrite, 99 );\n    add_filter( 'wp_calculate_image_srcset', function ( $sources ) use ( $wpide_cdn_rewrite ) {\n        foreach ( (array) $sources as $width => $source ) {\n            $sources[ $width ]['url'] = $wpide_cdn_rewrite( $source['url'] );\n        }\n        return $sources;\n    }, 99 );\n    add_filter( 'the_content', function ( $content ) use ( $wpide_cdn_url ) {\n        $uploads = wp_get_upload_dir();\n        $from    = set_url_scheme( $uploads['baseurl'], 'https' );\n        $to      = untrailingslashit( $wpide_cdn_url ) . wp_make_link_relative( $from );\n\n        return str_replace( array( $from, set_url_scheme( $from, 'http' ) ), $to, $content );\n    }, 99 );\n}",
            "requires": [],
            "notes": "Create a pull zone that points to your site first and set $wpide_cdn_url to its address; admin pages and the block editor keep using your own domain."
        },
        {
            "id": "perf-filemtime-asset-versions",
            "version": 1,
            "title": "Cache-Busting Versions Based on File Changes",
            "description": "Replaces the version number on your theme's and plugins' CSS and JavaScript URLs with the file's last-modified time, so browsers and CDNs cache files for a long time but always fetch a fresh copy right after you change one.",
            "category": "performance",
            "tags": [
                "cache-busting",
                "css",
                "javascript"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_mtime_version = function ( $src ) {\n    if ( ! is_string( $src ) || false !== strpos( $src, '/wp-includes/' ) || false !== strpos( $src, '/wp-admin/' ) ) {\n        return $src;\n    }\n\n    $content_url = set_url_scheme( content_url(), 'https' );\n    $clean       = set_url_scheme( strtok( $src, '?' ), 'https' );\n\n    if ( 0 !== strpos( $clean, $content_url ) ) {\n        return $src;\n    }\n\n    $path = WP_CONTENT_DIR . substr( $clean, strlen( $content_url ) );\n\n    if ( false !== strpos( $path, '..' ) || ! is_file( $path ) ) {\n        return $src;\n    }\n\n    return add_query_arg( 'ver', (string) filemtime( $path ), $src );\n};\n\nadd_filter( 'style_loader_src', $wpide_mtime_version, 15 );\nadd_filter( 'script_loader_src', $wpide_mtime_version, 15 );",
            "requires": [],
            "notes": "File times are cached in memory for the request; WordPress core files keep their normal version."
        },
        {
            "id": "perf-content-visibility-below-fold",
            "version": 1,
            "title": "Skip Rendering Off-Screen Comments and Footers Until Needed",
            "description": "Uses the CSS content-visibility property so browsers skip laying out the comments section, footer and related posts until the visitor scrolls near them, which speeds up rendering of long pages.",
            "category": "performance",
            "tags": [
                "css",
                "rendering",
                "core-web-vitals"
            ],
            "code_type": "css",
            "insert_method": "auto",
            "location": "site_wide_header",
            "insert_number": 1,
            "code": "#comments,\n.comments-area,\n.wp-block-comments,\n.site-footer,\nfooter.wp-block-template-part,\n.related-posts,\n.jp-relatedposts {\n    content-visibility: auto;\n    contain-intrinsic-size: auto 800px;\n}",
            "requires": [],
            "notes": "Adjust the selectors to your theme; the size hint keeps the scrollbar from jumping."
        },
        {
            "id": "perf-woo-password-meter-account-only",
            "version": 1,
            "title": "Load the WooCommerce Password Strength Meter Only When Needed",
            "description": "Removes the password strength meter script (about 400 KB with its dictionary) from every page except the account, checkout and password reset pages where customers actually choose a password.",
            "category": "performance",
            "tags": [
                "woocommerce",
                "javascript",
                "page-weight"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_print_scripts', function () {\n    if ( ! function_exists( 'is_account_page' ) ) {\n        return;\n    }\n\n    if ( is_account_page() || is_checkout() || ( isset( $_GET['action'] ) && 'rp' === $_GET['action'] ) ) {\n        return;\n    }\n\n    wp_dequeue_script( 'wc-password-strength-meter' );\n    wp_dequeue_script( 'password-strength-meter' );\n    wp_dequeue_script( 'zxcvbn-async' );\n}, 100 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "The meter still appears on the pages where it is useful."
        },
        {
            "id": "perf-feed-sitemap-cache-headers",
            "version": 1,
            "title": "Browser and CDN Caching for Feeds and Sitemaps",
            "description": "Sends cache headers on RSS feeds and XML sitemaps so feed readers, crawlers and CDNs can reuse them for an hour instead of making WordPress rebuild them on every request.",
            "category": "performance",
            "tags": [
                "caching",
                "feeds",
                "sitemaps"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Seconds feeds and sitemaps may be cached.\n$wpide_feed_cache_seconds = HOUR_IN_SECONDS;\n\nadd_action( 'template_redirect', function () use ( $wpide_feed_cache_seconds ) {\n    if ( headers_sent() || is_user_logged_in() ) {\n        return;\n    }\n\n    if ( is_feed() || get_query_var( 'sitemap' ) || get_query_var( 'sitemap-stylesheet' ) ) {\n        header( sprintf( 'Cache-Control: public, max-age=%1$d, s-maxage=%1$d, stale-while-revalidate=60', (int) $wpide_feed_cache_seconds ) );\n    }\n}, 1 );",
            "requires": [],
            "notes": "Lower $wpide_feed_cache_seconds if you publish many times an hour and want new posts to appear in feeds faster."
        },
        {
            "id": "perf-cached-shortcode",
            "version": 1,
            "title": "Cache Slow Shortcodes With a Wrapper Shortcode",
            "description": "Adds a [cached] shortcode you wrap around any slow shortcode or content, such as [cached ttl=\"3600\"][some_feed][/cached]; its output is stored for the time you choose and served from cache after that.",
            "category": "performance",
            "tags": [
                "shortcode",
                "cache",
                "transients"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_shortcode( 'cached', function ( $atts, $content = '' ) {\n    $atts = shortcode_atts( array( 'ttl' => HOUR_IN_SECONDS, 'key' => '' ), $atts, 'cached' );\n\n    if ( '' === trim( (string) $content ) ) {\n        return '';\n    }\n\n    $post_id = get_the_ID() ? get_the_ID() : 0;\n    $key     = 'wpide_sc_' . md5( $post_id . '|' . $atts['key'] . '|' . $content . '|' . determine_locale() );\n    $html    = get_transient( $key );\n\n    if ( false === $html ) {\n        $html = do_shortcode( $content );\n        set_transient( $key, $html, max( 60, (int) $atts['ttl'] ) );\n\n        if ( $post_id ) {\n            $keys   = (array) get_post_meta( $post_id, '_wpide_cached_keys', true );\n            $keys[] = $key;\n            update_post_meta( $post_id, '_wpide_cached_keys', array_values( array_unique( array_filter( $keys ) ) ) );\n        }\n    }\n\n    return $html;\n} );\n\nadd_action( 'save_post', function ( $post_id ) {\n    foreach ( (array) get_post_meta( $post_id, '_wpide_cached_keys', true ) as $key ) {\n        if ( $key ) {\n            delete_transient( $key );\n        }\n    }\n    delete_post_meta( $post_id, '_wpide_cached_keys' );\n} );",
            "requires": [],
            "notes": "Do not wrap content that differs per visitor (cart, account, forms with nonces); the cache is cleared whenever the post is updated."
        },
        {
            "id": "perf-cache-public-rest-responses",
            "version": 1,
            "title": "Cache Public REST API Responses for Visitors",
            "description": "Stores the responses of public GET requests to the REST API (such as post lists used by headless front ends and block widgets) for a few minutes for logged-out visitors, and clears the cache when content changes.",
            "category": "performance",
            "tags": [
                "rest-api",
                "cache",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Route prefixes to cache, and for how long (seconds).\n$wpide_rest_cache_routes = array( '/wp/v2/posts', '/wp/v2/pages', '/wp/v2/categories', '/wp/v2/tags', '/wp/v2/media' );\n$wpide_rest_cache_ttl    = 5 * MINUTE_IN_SECONDS;\n\n$wpide_rest_cache_key = function ( WP_REST_Request $request ) use ( $wpide_rest_cache_routes ) {\n    if ( 'GET' !== $request->get_method() || is_user_logged_in() || isset( $request['_embed'] ) ) {\n        return '';\n    }\n\n    foreach ( $wpide_rest_cache_routes as $prefix ) {\n        if ( 0 === strpos( $request->get_route(), $prefix ) ) {\n            $params = $request->get_query_params();\n            ksort( $params );\n            return 'wpide_rest_' . (int) get_option( 'wpide_rest_cache_gen', 1 ) . '_' . md5( $request->get_route() . wp_json_encode( $params ) );\n        }\n    }\n\n    return '';\n};\n\nadd_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) use ( $wpide_rest_cache_key ) {\n    $key = $wpide_rest_cache_key( $request );\n\n    if ( null !== $result || ! $key ) {\n        return $result;\n    }\n\n    $cached = get_transient( $key );\n\n    if ( is_array( $cached ) ) {\n        $response = new WP_REST_Response( $cached['data'], $cached['status'] );\n        $response->set_headers( array_merge( $cached['headers'], array( 'X-WPIDE-Cache' => 'HIT' ) ) );\n        return $response;\n    }\n\n    return $result;\n}, 10, 3 );\n\nadd_filter( 'rest_request_after_callbacks', function ( $response, $handler, $request ) use ( $wpide_rest_cache_key, $wpide_rest_cache_ttl ) {\n    $key = $wpide_rest_cache_key( $request );\n\n    if ( $key && $response instanceof WP_REST_Response && 200 === $response->get_status() ) {\n        $data = rest_get_server()->response_to_data( $response, false );\n        set_transient( $key, array( 'data' => $data, 'status' => 200, 'headers' => $response->get_headers() ), $wpide_rest_cache_ttl );\n    }\n\n    return $response;\n}, 99, 3 );\n\n$wpide_rest_cache_flush = function () {\n    update_option( 'wpide_rest_cache_gen', (int) get_option( 'wpide_rest_cache_gen', 1 ) + 1, false );\n};\nadd_action( 'save_post', $wpide_rest_cache_flush );\nadd_action( 'deleted_post', $wpide_rest_cache_flush );\nadd_action( 'edited_term', $wpide_rest_cache_flush );\nadd_action( 'delete_attachment', $wpide_rest_cache_flush );",
            "requires": [],
            "notes": "Only routes in $wpide_rest_cache_routes are cached and requests using _embed are skipped; with a persistent object cache the entries live in memory instead of the database."
        },
        {
            "id": "perf-send-emails-in-background",
            "version": 1,
            "title": "Send Emails in the Background",
            "description": "Queues emails sent by WordPress and plugins and sends them a few seconds later through WP-Cron, so form submissions, orders and registrations no longer wait for the mail server.",
            "category": "performance",
            "tags": [
                "email",
                "cron",
                "speed"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_wp_mail', function ( $return, $atts ) {\n    if ( null !== $return || ! empty( $GLOBALS['wpide_mail_sending'] ) || ! empty( $atts['attachments'] ) || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return $return;\n    }\n\n    $queue   = (array) get_option( 'wpide_mail_queue', array() );\n    $queue[] = array(\n        'to'      => $atts['to'],\n        'subject' => $atts['subject'],\n        'message' => $atts['message'],\n        'headers' => $atts['headers'],\n    );\n    update_option( 'wpide_mail_queue', $queue, false );\n\n    if ( ! wp_next_scheduled( 'wpide_send_mail_queue' ) ) {\n        wp_schedule_single_event( time() + 5, 'wpide_send_mail_queue' );\n    }\n\n    return true;\n}, 99, 2 );\n\nadd_action( 'wpide_send_mail_queue', function () {\n    $queue = (array) get_option( 'wpide_mail_queue', array() );\n    delete_option( 'wpide_mail_queue' );\n\n    $GLOBALS['wpide_mail_sending'] = true;\n    foreach ( $queue as $mail ) {\n        if ( ! empty( $mail['to'] ) ) {\n            wp_mail( $mail['to'], $mail['subject'], $mail['message'], $mail['headers'] );\n        }\n    }\n    unset( $GLOBALS['wpide_mail_sending'] );\n} );",
            "requires": [],
            "notes": "Emails with attachments are sent immediately, because temporary attachment files may be gone later; make sure WP-Cron runs reliably."
        },
        {
            "id": "perf-disable-autosave-for-types",
            "version": 1,
            "title": "Turn Off Autosave for Chosen Post Types",
            "description": "Stops the editor from autosaving the post types you list (for example products or custom data types edited by many people), which removes frequent background requests and extra autosave revisions.",
            "category": "performance",
            "tags": [
                "autosave",
                "editor",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types without autosave.\n$wpide_no_autosave_types = array( 'product', 'shop_order' );\n\nadd_action( 'admin_enqueue_scripts', function ( $hook ) use ( $wpide_no_autosave_types ) {\n    if ( ! in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {\n        return;\n    }\n\n    $screen = get_current_screen();\n\n    if ( $screen && in_array( $screen->post_type, $wpide_no_autosave_types, true ) ) {\n        wp_dequeue_script( 'autosave' );\n    }\n} );\n\nadd_filter( 'block_editor_settings_all', function ( $settings, $context ) use ( $wpide_no_autosave_types ) {\n    if ( isset( $context->post->post_type ) && in_array( $context->post->post_type, $wpide_no_autosave_types, true ) ) {\n        $settings['autosaveInterval'] = 86400;\n    }\n\n    return $settings;\n}, 10, 2 );",
            "requires": [],
            "notes": "Unsaved changes are lost if the browser crashes, so only use it for post types where that is acceptable."
        },
        {
            "id": "perf-limit-deep-pagination",
            "version": 1,
            "title": "Stop Crawlers Paging Endlessly Through Old Archives",
            "description": "Returns a \"not found\" page for archive pages beyond page 50 (such as /page/873/) for logged-out visitors, which keeps bots from hammering your database with very slow deep pagination queries.",
            "category": "performance",
            "tags": [
                "pagination",
                "crawlers",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_cl",
            "insert_number": 1,
            "code": "// Highest archive page number served to logged-out visitors.\n$wpide_max_page = 50;\n\nadd_action( 'template_redirect', function () use ( $wpide_max_page ) {\n    global $wp_query;\n\n    if ( is_user_logged_in() || ! is_paged() || (int) get_query_var( 'paged' ) <= $wpide_max_page ) {\n        return;\n    }\n\n    $wp_query->set_404();\n    status_header( 404 );\n    nocache_headers();\n}, 1 );",
            "requires": [],
            "notes": "Visitors can still reach old posts through search, categories and the sitemap; raise $wpide_max_page if your archives are browsed that deep on purpose."
        },
        {
            "id": "perf-cache-comment-counts",
            "version": 1,
            "title": "Cache Site-Wide Comment Counts",
            "description": "Stores the comment totals that WordPress shows in the admin menu, toolbar and dashboard for 10 minutes, avoiding a slow counting query on every admin page view on sites with many comments.",
            "category": "performance",
            "tags": [
                "comments",
                "admin",
                "cache"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_count_comments', function ( $stats, $post_id ) {\n    if ( ! empty( $stats ) || (int) $post_id ) {\n        return $stats;\n    }\n\n    $cached = get_transient( 'wpide_comment_counts' );\n\n    if ( is_object( $cached ) ) {\n        return $cached;\n    }\n\n    // Same numbers wp_count_comments() would compute, stored for reuse.\n    $counts = (object) get_comment_count( 0 );\n    $counts->moderated = $counts->awaiting_moderation;\n    unset( $counts->awaiting_moderation );\n    set_transient( 'wpide_comment_counts', $counts, 10 * MINUTE_IN_SECONDS );\n\n    return $counts;\n}, 10, 2 );\n\n$wpide_flush_comment_counts = function () {\n    delete_transient( 'wpide_comment_counts' );\n};\nadd_action( 'wp_insert_comment', $wpide_flush_comment_counts );\nadd_action( 'transition_comment_status', $wpide_flush_comment_counts );\nadd_action( 'deleted_comment', $wpide_flush_comment_counts );\nadd_action( 'trashed_comment', $wpide_flush_comment_counts );\nadd_action( 'spammed_comment', $wpide_flush_comment_counts );",
            "requires": [],
            "notes": "The cache is cleared whenever a comment is added, approved, spammed or deleted, so counts stay accurate."
        },
        {
            "id": "perf-early-hints-link-header",
            "version": 1,
            "title": "Preload Header for Your Theme Stylesheet",
            "description": "Sends an HTTP Link preload header for your theme's main stylesheet, so browsers, and CDNs that support Early Hints, start downloading the CSS before the HTML has even arrived.",
            "category": "performance",
            "tags": [
                "preload",
                "headers",
                "css"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Extra files to preload: URL => 'style', 'script' or 'font'.\n$wpide_link_preloads = array();\n\nadd_action( 'send_headers', function () use ( $wpide_link_preloads ) {\n    if ( headers_sent() || is_admin() ) {\n        return;\n    }\n\n    $theme  = wp_get_theme();\n    $files  = array( add_query_arg( 'ver', $theme->get( 'Version' ), get_stylesheet_uri() ) => 'style' ) + $wpide_link_preloads;\n    $values = array();\n\n    foreach ( $files as $url => $as ) {\n        $value = sprintf( '<%s>; rel=preload; as=%s', esc_url_raw( $url ), $as );\n        if ( 'font' === $as ) {\n            $value .= '; crossorigin';\n        }\n        $values[] = $value;\n    }\n\n    header( 'Link: ' . implode( ', ', $values ), false );\n} );",
            "requires": [],
            "notes": "Add more files (for example a key web font) to $wpide_link_preloads; keep the list short, since preloading too much slows everything else down."
        },
        {
            "id": "perf-yoast-no-indexables",
            "version": 1,
            "title": "Stop Yoast SEO Building Indexables on Small or Busy Sites",
            "description": "Turns off Yoast SEO's indexables, the background tables Yoast fills for every post and term, which removes heavy database writes on large sites or cheap hosting.",
            "category": "performance",
            "tags": [
                "yoast",
                "database",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'Yoast\\WP\\SEO\\should_index_indexables', '__return_false' );",
            "requires": [
                "yoast"
            ],
            "notes": "Yoast features that depend on indexables (internal linking suggestions, some insights) stop working; SEO titles, meta and sitemaps keep working."
        },
        {
            "id": "perf-speculation-exclude-action-links",
            "version": 1,
            "title": "Keep Speculative Loading Away From Cart, Checkout and Account Pages",
            "description": "Tells WordPress's built-in speculative loading (prefetching pages on hover) to skip cart, checkout and account pages, which are personal, never cached and expensive to build, so hovering over their links no longer costs server time.",
            "category": "performance",
            "tags": [
                "speculative-loading",
                "prefetch",
                "woocommerce"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Extra paths (relative to your home URL) that should never be prefetched.\n$wpide_no_speculation = array( '/cart/*', '/checkout/*', '/my-account/*' );\n\nadd_filter( 'wp_speculation_rules_href_exclude_paths', function ( $paths ) use ( $wpide_no_speculation ) {\n    if ( function_exists( 'wc_get_page_id' ) ) {\n        $home = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );\n        foreach ( array( 'cart', 'checkout', 'myaccount' ) as $page ) {\n            $id = wc_get_page_id( $page );\n            if ( $id > 0 ) {\n                $path = (string) wp_parse_url( get_permalink( $id ), PHP_URL_PATH );\n                $wpide_no_speculation[] = '/' . trim( substr( $path, strlen( $home ) ), '/' ) . '/*';\n            }\n        }\n    }\n\n    return array_values( array_unique( array_merge( (array) $paths, $wpide_no_speculation ) ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.8 or later; WooCommerce page addresses are detected automatically, and you can add other paths such as a members area."
        },
        {
            "id": "privacy-hold-scripts-until-consent",
            "version": 1,
            "title": "Hold Tracking Scripts Until the Visitor Consents",
            "description": "Turns the scripts you list by handle into inactive placeholders and only runs them after the visitor gives consent, through the WP Consent API when a consent plugin provides it or the simple wpide_consent cookie otherwise.",
            "category": "privacy",
            "tags": [
                "consent",
                "scripts",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Script handle => consent category (functional, preferences, statistics, marketing).\n$wpide_consent_scripts = array(\n    'google-analytics' => 'statistics',\n    'facebook-pixel'   => 'marketing',\n);\n\nadd_filter( 'script_loader_tag', function ( $tag, $handle ) use ( $wpide_consent_scripts ) {\n    if ( empty( $wpide_consent_scripts[ $handle ] ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $tag;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $tag );\n\n    while ( $tags->next_tag( 'script' ) ) {\n        $tags->set_attribute( 'type', 'text/plain' );\n        $tags->set_attribute( 'data-wpide-consent', $wpide_consent_scripts[ $handle ] );\n    }\n\n    return $tags->get_updated_html();\n}, 20, 2 );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    function allowed(category) {\n        if (typeof window.wp_has_consent === 'function') { return window.wp_has_consent(category); }\n        return /(?:^|; )wpide_consent=granted/.test(document.cookie);\n    }\n    function release() {\n        document.querySelectorAll('script[type=\"text/plain\"][data-wpide-consent]').forEach(function (old) {\n            if (!allowed(old.getAttribute('data-wpide-consent'))) { return; }\n            var s = document.createElement('script');\n            Array.prototype.forEach.call(old.attributes, function (a) {\n                if (a.name !== 'type' && a.name !== 'data-wpide-consent') { s.setAttribute(a.name, a.value); }\n            });\n            if (!old.src) { s.text = old.text; }\n            old.parentNode.replaceChild(s, old);\n        });\n    }\n    release();\n    document.addEventListener('wp_listen_for_consent_change', release);\n    document.addEventListener('wpide:consent', release);\n})();\n</script>\n    <?php\n}, 99 );",
            "requires": [],
            "notes": "Find script handles with a \"list enqueued assets\" snippet; scripts that other scripts depend on should not be held back."
        },
        {
            "id": "privacy-consent-content-shortcode",
            "version": 1,
            "title": "Show Embeds Only After Consent With a Shortcode",
            "description": "Wraps any embed or HTML in [consent_content category=\"marketing\"]...[/consent_content] so it stays hidden behind a short notice until the visitor consents, using the WP Consent API when available.",
            "category": "privacy",
            "tags": [
                "consent",
                "shortcode",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'consent_content', function ( $atts, $content = '' ) {\n    static $script = false;\n\n    $atts = shortcode_atts( array(\n        'category' => 'marketing',\n        'notice'   => 'This content is provided by a third party that may set cookies.',\n    ), $atts, 'consent_content' );\n\n    if ( '' === trim( (string) $content ) ) {\n        return '';\n    }\n\n    $html = sprintf(\n        '<div class=\"wpide-consent-box\" data-category=\"%1$s\"><template>%2$s</template><div class=\"wpide-consent-note\" style=\"padding:16px;border:1px solid #ccc;background:#f6f7f7;\"><p style=\"margin:0 0 8px\">%3$s</p><button type=\"button\" class=\"wpide-consent-load\">Load content</button></div></div>',\n        esc_attr( sanitize_key( $atts['category'] ) ),\n        do_shortcode( $content ),\n        esc_html( $atts['notice'] )\n    );\n\n    if ( ! $script ) {\n        $script = true;\n        $html  .= '<script>(function(){function ok(c){if(typeof window.wp_has_consent===\"function\"){return window.wp_has_consent(c);}return /(?:^|; )wpide_consent=granted/.test(document.cookie);}function show(box){var t=box.querySelector(\"template\");if(!t){return;}box.replaceChildren(t.content.cloneNode(true));}function run(){document.querySelectorAll(\".wpide-consent-box\").forEach(function(b){if(ok(b.getAttribute(\"data-category\"))){show(b);}});}document.addEventListener(\"click\",function(e){var b=e.target.closest(\".wpide-consent-load\");if(b){show(b.closest(\".wpide-consent-box\"));}});document.addEventListener(\"wp_listen_for_consent_change\",run);document.addEventListener(\"wpide:consent\",run);if(document.readyState!==\"loading\"){run();}else{document.addEventListener(\"DOMContentLoaded\",run);}})();</script>';\n    }\n\n    return $html;\n} );",
            "requires": [],
            "notes": "The notice offers a \"Load content\" button that unlocks this item only; full consent still comes from your cookie banner."
        },
        {
            "id": "privacy-mask-comment-ip",
            "version": 1,
            "title": "Store Only Shortened IP Addresses for Comments",
            "description": "Saves commenters' IP addresses with the last part removed (for example 203.0.113.0), which is still enough for spam checks but no longer identifies a person.",
            "category": "privacy",
            "tags": [
                "comments",
                "ip-address",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// 'mask' keeps the network part of the address, 'none' stores nothing.\n$wpide_comment_ip_mode = 'mask';\n\nadd_filter( 'pre_comment_user_ip', function ( $ip ) use ( $wpide_comment_ip_mode ) {\n    if ( 'none' === $wpide_comment_ip_mode ) {\n        return '';\n    }\n\n    return function_exists( 'wp_privacy_anonymize_ip' ) ? wp_privacy_anonymize_ip( $ip ) : '';\n}, 20 );",
            "requires": [],
            "notes": "Set $wpide_comment_ip_mode to 'none' to store no IP address at all; some anti-spam services work less well without it."
        },
        {
            "id": "privacy-no-comment-user-agent",
            "version": 1,
            "title": "Don't Store Commenters' Browser Details",
            "description": "Stops WordPress from saving each commenter's browser user agent string with their comment, which is rarely needed and counts as personal data.",
            "category": "privacy",
            "tags": [
                "comments",
                "user-agent",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_comment_user_agent', '__return_empty_string', 20 );",
            "requires": [],
            "notes": "Akismet still receives the user agent at submission time for its spam check; it is just not stored."
        },
        {
            "id": "privacy-anonymize-old-comment-ips",
            "version": 1,
            "title": "Anonymize Comment IP Addresses After 60 Days",
            "description": "Once a day, shortens the stored IP address of approved comments older than 60 days, so you keep recent IPs for spam handling without holding personal data forever.",
            "category": "privacy",
            "tags": [
                "comments",
                "retention",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Keep full IP addresses for this many days.\n$wpide_ip_retention_days = 60;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_anonymize_comment_ips' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_anonymize_comment_ips' );\n    }\n} );\n\nadd_action( 'wpide_anonymize_comment_ips', function () use ( $wpide_ip_retention_days ) {\n    global $wpdb;\n\n    $cutoff = gmdate( 'Y-m-d H:i:s', time() - $wpide_ip_retention_days * DAY_IN_SECONDS );\n    $rows   = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT comment_ID, comment_author_IP FROM {$wpdb->comments}\n         WHERE comment_date_gmt < %s AND comment_approved = '1' AND comment_author_IP <> ''\n         AND comment_author_IP NOT LIKE %s AND comment_author_IP NOT LIKE %s LIMIT 500\",\n        $cutoff,\n        '%.0',\n        '%::'\n    ) );\n\n    foreach ( $rows as $row ) {\n        $wpdb->update( $wpdb->comments, array( 'comment_author_IP' => wp_privacy_anonymize_ip( $row->comment_author_IP ) ), array( 'comment_ID' => (int) $row->comment_ID ) );\n        clean_comment_cache( (int) $row->comment_ID );\n    }\n} );",
            "requires": [],
            "notes": "Change $wpide_ip_retention_days to your retention period; already-shortened addresses are skipped."
        },
        {
            "id": "privacy-purge-old-spam-comments",
            "version": 1,
            "title": "Delete Spam Comments Older Than 30 Days",
            "description": "Permanently removes comments marked as spam once they are older than 30 days, so spammers' names, emails and IP addresses do not pile up in your database.",
            "category": "privacy",
            "tags": [
                "comments",
                "spam",
                "retention"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days to keep spam before it is deleted.\n$wpide_spam_days = 30;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_purge_old_spam' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_purge_old_spam' );\n    }\n} );\n\nadd_action( 'wpide_purge_old_spam', function () use ( $wpide_spam_days ) {\n    global $wpdb;\n\n    $ids = $wpdb->get_col( $wpdb->prepare(\n        \"SELECT comment_ID FROM {$wpdb->comments} WHERE comment_approved = 'spam' AND comment_date_gmt < %s LIMIT 200\",\n        gmdate( 'Y-m-d H:i:s', time() - $wpide_spam_days * DAY_IN_SECONDS )\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_delete_comment( (int) $id, true );\n    }\n} );",
            "requires": [],
            "notes": "Runs daily in batches of 200; check the Spam folder now and then if you rely on rescuing false positives."
        },
        {
            "id": "privacy-mask-session-ip",
            "version": 1,
            "title": "Don't Keep Full IP Addresses in Login Sessions",
            "description": "Shortens the IP address and drops the browser details WordPress stores with every login session, so session records no longer hold full personal data.",
            "category": "privacy",
            "tags": [
                "sessions",
                "ip-address",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'attach_session_information', function ( $session ) {\n    if ( ! empty( $session['ip'] ) ) {\n        $session['ip'] = wp_privacy_anonymize_ip( $session['ip'] );\n    }\n\n    unset( $session['ua'] );\n\n    return $session;\n}, 20 );",
            "requires": [],
            "notes": "Security snippets that compare session IPs will compare the shortened address instead."
        },
        {
            "id": "privacy-clean-old-privacy-requests",
            "version": 1,
            "title": "Remove Completed Privacy Requests After 90 Days",
            "description": "Deletes finished export and erasure requests (and failed ones) from Tools > Export/Erase Personal Data once they are 90 days old, since the request records themselves contain email addresses.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "retention",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Age in days after which finished requests are removed.\n$wpide_request_days = 90;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_clean_privacy_requests' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_clean_privacy_requests' );\n    }\n} );\n\nadd_action( 'wpide_clean_privacy_requests', function () use ( $wpide_request_days ) {\n    $ids = get_posts( array(\n        'post_type'      => 'user_request',\n        'post_status'    => array( 'request-completed', 'request-failed' ),\n        'posts_per_page' => 100,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n        'date_query'     => array( array( 'column' => 'post_modified_gmt', 'before' => $wpide_request_days . ' days ago' ) ),\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_delete_post( $id, true );\n    }\n} );",
            "requires": [],
            "notes": "Keep records longer if you need them as proof that requests were handled."
        },
        {
            "id": "privacy-inactive-subscribers-report",
            "version": 1,
            "title": "Inactive Subscribers Cleanup Page",
            "description": "Adds Users > Inactive Subscribers, listing subscriber accounts older than two years that have never commented, with checkboxes to delete the ones you select after a confirmation.",
            "category": "privacy",
            "tags": [
                "users",
                "retention",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Accounts registered more than this many days ago are listed.\n$wpide_inactive_days = 730;\n\nadd_action( 'admin_menu', function () use ( $wpide_inactive_days ) {\n    add_users_page( 'Inactive Subscribers', 'Inactive Subscribers', 'delete_users', 'wpide-inactive-subscribers', function () use ( $wpide_inactive_days ) {\n        global $wpdb;\n\n        if ( ! current_user_can( 'delete_users' ) ) {\n            return;\n        }\n\n        if ( isset( $_POST['wpide_delete_users'] ) && check_admin_referer( 'wpide_inactive_delete' ) ) {\n            require_once ABSPATH . 'wp-admin/includes/user.php';\n            $deleted = 0;\n            foreach ( array_map( 'absint', (array) wp_unslash( $_POST['wpide_delete_users'] ) ) as $user_id ) {\n                $user = get_userdata( $user_id );\n                if ( $user && array( 'subscriber' ) === array_values( $user->roles ) && get_current_user_id() !== $user_id && wp_delete_user( $user_id ) ) {\n                    $deleted++;\n                }\n            }\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Deleted %d account(s).', $deleted ) ) );\n        }\n\n        $users = get_users( array(\n            'role'         => 'subscriber',\n            'number'       => 200,\n            'orderby'      => 'registered',\n            'order'        => 'ASC',\n            'date_query'   => array( array( 'column' => 'user_registered', 'before' => $wpide_inactive_days . ' days ago' ) ),\n            'fields'       => array( 'ID', 'user_login', 'user_email', 'user_registered' ),\n            'count_total'  => false,\n        ) );\n\n        $with_comments = array();\n        if ( $users ) {\n            $ids           = implode( ',', array_map( 'intval', wp_list_pluck( $users, 'ID' ) ) );\n            $with_comments = array_map( 'intval', $wpdb->get_col( \"SELECT DISTINCT user_id FROM {$wpdb->comments} WHERE user_id IN ($ids)\" ) );\n        }\n\n        echo '<div class=\"wrap\"><h1>Inactive Subscribers</h1>';\n        echo '<p>' . esc_html( sprintf( 'Subscribers registered more than %d days ago who have never commented (first 200).', $wpide_inactive_days ) ) . '</p>';\n        echo '<form method=\"post\" onsubmit=\"return confirm(\\'Delete the selected accounts permanently?\\');\">';\n        wp_nonce_field( 'wpide_inactive_delete' );\n        echo '<table class=\"widefat striped\"><thead><tr><td class=\"check-column\"></td><th>User</th><th>Email</th><th>Registered</th></tr></thead><tbody>';\n        $shown = 0;\n        foreach ( $users as $user ) {\n            if ( in_array( (int) $user->ID, $with_comments, true ) ) {\n                continue;\n            }\n            $shown++;\n            printf(\n                '<tr><th class=\"check-column\"><input type=\"checkbox\" name=\"wpide_delete_users[]\" value=\"%1$d\" aria-label=\"%2$s\"></th><td>%3$s</td><td>%4$s</td><td>%5$s</td></tr>',\n                (int) $user->ID,\n                esc_attr( 'Select ' . $user->user_login ),\n                esc_html( $user->user_login ),\n                esc_html( $user->user_email ),\n                esc_html( mysql2date( get_option( 'date_format' ), $user->user_registered ) )\n            );\n        }\n        if ( ! $shown ) {\n            echo '<tr><td colspan=\"4\">No inactive subscribers found.</td></tr>';\n        }\n        echo '</tbody></table>';\n        if ( $shown ) {\n            submit_button( 'Delete selected accounts', 'delete' );\n        }\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Only the subscriber role is listed and nothing is deleted without your confirmation; customers of shops and members of membership plugins usually have their own roles."
        },
        {
            "id": "privacy-export-user-meta",
            "version": 1,
            "title": "Include Custom Profile Fields in Personal Data Exports",
            "description": "Adds the custom user fields you list (such as phone or company) to the personal data export file WordPress creates under Tools > Export Personal Data.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "export",
                "user-meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User meta key => label shown in the export.\n$wpide_export_user_meta = array(\n    'phone'           => 'Phone number',\n    'company'         => 'Company',\n    'billing_country' => 'Country',\n);\n\nadd_filter( 'wp_privacy_personal_data_exporters', function ( $exporters ) use ( $wpide_export_user_meta ) {\n    $exporters['wpide-user-meta'] = array(\n        'exporter_friendly_name' => 'Extra profile fields',\n        'callback'               => function ( $email ) use ( $wpide_export_user_meta ) {\n            $user = get_user_by( 'email', $email );\n            $data = array();\n\n            if ( $user ) {\n                foreach ( $wpide_export_user_meta as $key => $label ) {\n                    $value = get_user_meta( $user->ID, $key, true );\n                    if ( '' !== $value && null !== $value ) {\n                        $data[] = array( 'name' => $label, 'value' => is_scalar( $value ) ? (string) $value : wp_json_encode( $value ) );\n                    }\n                }\n            }\n\n            return array(\n                'data' => $data ? array( array( 'group_id' => 'wpide-profile', 'group_label' => 'Extra profile fields', 'item_id' => 'user-' . ( $user ? $user->ID : 0 ), 'data' => $data ) ) : array(),\n                'done' => true,\n            );\n        },\n    );\n\n    return $exporters;\n} );",
            "requires": [],
            "notes": "List the meta keys your theme or plugins store for users, with the label you want in the export."
        },
        {
            "id": "privacy-erase-user-meta",
            "version": 1,
            "title": "Erase Custom Profile Fields on Erasure Requests",
            "description": "Deletes the custom user fields you list when you run a personal data erasure request under Tools > Erase Personal Data, so they are not left behind.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "erasure",
                "user-meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User meta keys removed by an erasure request.\n$wpide_erase_user_meta = array( 'phone', 'company', 'date_of_birth' );\n\nadd_filter( 'wp_privacy_personal_data_erasers', function ( $erasers ) use ( $wpide_erase_user_meta ) {\n    $erasers['wpide-user-meta'] = array(\n        'eraser_friendly_name' => 'Extra profile fields',\n        'callback'             => function ( $email ) use ( $wpide_erase_user_meta ) {\n            $user    = get_user_by( 'email', $email );\n            $removed = false;\n\n            if ( $user ) {\n                foreach ( $wpide_erase_user_meta as $key ) {\n                    if ( metadata_exists( 'user', $user->ID, $key ) ) {\n                        $removed = delete_user_meta( $user->ID, $key ) || $removed;\n                    }\n                }\n            }\n\n            return array(\n                'items_removed'  => $removed,\n                'items_retained' => false,\n                'messages'       => array(),\n                'done'           => true,\n            );\n        },\n    );\n\n    return $erasers;\n} );",
            "requires": [],
            "notes": "Only the listed keys are removed; the account itself is not deleted."
        },
        {
            "id": "privacy-comment-meta-export-erase",
            "version": 1,
            "title": "Include Custom Comment Fields in Privacy Exports and Erasures",
            "description": "Adds extra comment fields stored as comment meta (such as a phone number or rating) to personal data exports, and removes them during erasure requests.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "comments",
                "export"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Comment meta key => label in the export.\n$wpide_comment_meta_fields = array(\n    'phone'  => 'Phone number',\n    'rating' => 'Rating',\n);\n\n$wpide_comment_ids_for = function ( $email, $page ) {\n    return get_comments( array(\n        'author_email' => $email,\n        'number'       => 100,\n        'paged'        => max( 1, (int) $page ),\n        'fields'       => 'ids',\n        'status'       => 'all',\n        'orderby'      => 'comment_ID',\n        'order'        => 'ASC',\n    ) );\n};\n\nadd_filter( 'wp_privacy_personal_data_exporters', function ( $exporters ) use ( $wpide_comment_meta_fields, $wpide_comment_ids_for ) {\n    $exporters['wpide-comment-meta'] = array(\n        'exporter_friendly_name' => 'Extra comment fields',\n        'callback'               => function ( $email, $page = 1 ) use ( $wpide_comment_meta_fields, $wpide_comment_ids_for ) {\n            $ids   = $wpide_comment_ids_for( $email, $page );\n            $items = array();\n\n            foreach ( $ids as $id ) {\n                $data = array();\n                foreach ( $wpide_comment_meta_fields as $key => $label ) {\n                    $value = get_comment_meta( $id, $key, true );\n                    if ( '' !== $value ) {\n                        $data[] = array( 'name' => $label, 'value' => is_scalar( $value ) ? (string) $value : wp_json_encode( $value ) );\n                    }\n                }\n                if ( $data ) {\n                    $items[] = array( 'group_id' => 'comments', 'group_label' => 'Comments', 'item_id' => 'comment-' . $id, 'data' => $data );\n                }\n            }\n\n            return array( 'data' => $items, 'done' => count( $ids ) < 100 );\n        },\n    );\n\n    return $exporters;\n} );\n\nadd_filter( 'wp_privacy_personal_data_erasers', function ( $erasers ) use ( $wpide_comment_meta_fields, $wpide_comment_ids_for ) {\n    $erasers['wpide-comment-meta'] = array(\n        'eraser_friendly_name' => 'Extra comment fields',\n        'callback'             => function ( $email, $page = 1 ) use ( $wpide_comment_meta_fields, $wpide_comment_ids_for ) {\n            $ids     = $wpide_comment_ids_for( $email, $page );\n            $removed = false;\n\n            foreach ( $ids as $id ) {\n                foreach ( array_keys( $wpide_comment_meta_fields ) as $key ) {\n                    $removed = delete_comment_meta( $id, $key ) || $removed;\n                }\n            }\n\n            return array( 'items_removed' => $removed, 'items_retained' => false, 'messages' => array(), 'done' => count( $ids ) < 100 );\n        },\n    );\n\n    return $erasers;\n} );",
            "requires": [],
            "notes": "List the comment meta keys your comment form or plugins store; each request handles up to 100 comments per page."
        },
        {
            "id": "privacy-woo-export-order-meta",
            "version": 1,
            "title": "Add Custom Order Fields to WooCommerce Privacy Exports",
            "description": "Includes extra checkout fields you store on orders (such as a VAT number or delivery notes) in the order section of personal data exports.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "gdpr",
                "export"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Order meta key => label in the export.\n$wpide_woo_export_meta = array(\n    '_billing_vat_number' => 'VAT number',\n    '_delivery_notes'     => 'Delivery notes',\n);\n\nadd_filter( 'woocommerce_privacy_export_order_personal_data_props', function ( $props ) use ( $wpide_woo_export_meta ) {\n    foreach ( $wpide_woo_export_meta as $key => $label ) {\n        $props[ 'wpide_meta_' . $key ] = $label;\n    }\n\n    return $props;\n} );\n\nadd_filter( 'woocommerce_privacy_export_order_personal_data_prop', function ( $value, $prop, $order ) use ( $wpide_woo_export_meta ) {\n    if ( 0 !== strpos( $prop, 'wpide_meta_' ) || ! is_a( $order, 'WC_Order' ) ) {\n        return $value;\n    }\n\n    $key = substr( $prop, strlen( 'wpide_meta_' ) );\n\n    return isset( $wpide_woo_export_meta[ $key ] ) ? (string) $order->get_meta( $key ) : $value;\n}, 10, 3 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Use the order meta keys your checkout field plugin saves; keys starting with an underscore are fine."
        },
        {
            "id": "privacy-policy-guide-text",
            "version": 1,
            "title": "Add Your Own Section to the Privacy Policy Guide",
            "description": "Adds a suggested paragraph about your custom code (such as analytics, embeds or contact forms) to the Privacy Policy Guide under Settings > Privacy, ready to copy into your policy.",
            "category": "privacy",
            "tags": [
                "privacy-policy",
                "gdpr",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Heading and suggested policy text.\n$wpide_policy_name = 'Site customizations';\n$wpide_policy_text = '<p>We use privacy-friendly analytics that do not set cookies or store your IP address.</p>'\n    . '<p>Embedded videos and maps only load after you click them, so the provider does not receive data before you choose to watch or view.</p>'\n    . '<p>Messages sent through our contact form are emailed to us and deleted from our inbox after 12 months.</p>';\n\nadd_action( 'admin_init', function () use ( $wpide_policy_name, $wpide_policy_text ) {\n    if ( function_exists( 'wp_add_privacy_policy_content' ) ) {\n        wp_add_privacy_policy_content( $wpide_policy_name, wp_kses_post( wpautop( $wpide_policy_text, false ) ) );\n    }\n} );",
            "requires": [],
            "notes": "Edit the text to describe what your site really collects; the guide only suggests content and does not change your policy page."
        },
        {
            "id": "privacy-policy-footer-link",
            "version": 1,
            "title": "Privacy Policy Link in the Site Footer",
            "description": "Adds a link to the privacy policy page you set under Settings > Privacy at the bottom of every page, for themes that do not show it.",
            "category": "privacy",
            "tags": [
                "privacy-policy",
                "footer",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $link = get_the_privacy_policy_link( '<p class=\"wpide-privacy-link\" style=\"text-align:center;font-size:14px;margin:1em 0;\">', '</p>' );\n\n    if ( $link ) {\n        echo wp_kses_post( $link );\n    }\n}, 40 );",
            "requires": [],
            "notes": "Nothing is printed until a published privacy policy page is selected in Settings > Privacy."
        },
        {
            "id": "privacy-policy-updated-shortcode",
            "version": 1,
            "title": "\"Privacy Policy Last Updated\" Date Shortcode",
            "description": "Adds a [privacy_policy_updated] shortcode that prints the date your privacy policy page was last changed, so the date on the page is always correct.",
            "category": "privacy",
            "tags": [
                "privacy-policy",
                "shortcode",
                "dates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'privacy_policy_updated', function ( $atts ) {\n    $atts = shortcode_atts( array(\n        'format' => get_option( 'date_format' ),\n        'prefix' => 'Last updated: ',\n    ), $atts, 'privacy_policy_updated' );\n\n    $page_id = (int) get_option( 'wp_page_for_privacy_policy' );\n\n    if ( ! $page_id || 'publish' !== get_post_status( $page_id ) ) {\n        return '';\n    }\n\n    return '<p class=\"wpide-policy-updated\">' . esc_html( $atts['prefix'] . get_the_modified_date( $atts['format'], $page_id ) ) . '</p>';\n} );",
            "requires": [],
            "notes": "Use [privacy_policy_updated format=\"F j, Y\"] to choose a date format; it defaults to your site's date format."
        },
        {
            "id": "privacy-self-host-google-fonts",
            "version": 1,
            "title": "Self-Host Google Fonts Used by Your Theme and Plugins",
            "description": "Detects Google Fonts stylesheets loaded on your site, lets you download them to your own server from Tools > Local Google Fonts, and then serves the local copies so visitors' browsers never contact Google.",
            "category": "privacy",
            "tags": [
                "google-fonts",
                "gdpr",
                "fonts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'style_loader_src', function ( $src ) {\n    if ( ! is_string( $src ) || false === strpos( $src, 'fonts.googleapis.com' ) ) {\n        return $src;\n    }\n\n    $key   = md5( html_entity_decode( $src ) );\n    $local = (array) get_option( 'wpide_local_gfonts', array() );\n\n    if ( ! empty( $local[ $key ] ) ) {\n        $uploads = wp_get_upload_dir();\n        return $uploads['baseurl'] . '/wpide-fonts/' . $key . '.css';\n    }\n\n    $found = (array) get_option( 'wpide_found_gfonts', array() );\n    if ( ! isset( $found[ $key ] ) && count( $found ) < 20 ) {\n        $found[ $key ] = html_entity_decode( $src );\n        update_option( 'wpide_found_gfonts', $found, false );\n    }\n\n    return $src;\n}, 99 );\n\nadd_filter( 'wp_resource_hints', function ( $urls ) {\n    if ( ! get_option( 'wpide_local_gfonts' ) ) {\n        return $urls;\n    }\n\n    return array_filter( $urls, function ( $url ) {\n        $href = is_array( $url ) ? ( isset( $url['href'] ) ? $url['href'] : '' ) : $url;\n        return false === strpos( $href, 'fonts.gstatic.com' ) && false === strpos( $href, 'fonts.googleapis.com' );\n    } );\n}, 99 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'Local Google Fonts', 'Local Google Fonts', 'manage_options', 'wpide-local-fonts', function () {\n        $found   = (array) get_option( 'wpide_found_gfonts', array() );\n        $local   = (array) get_option( 'wpide_local_gfonts', array() );\n        $uploads = wp_get_upload_dir();\n        $dir     = trailingslashit( $uploads['basedir'] ) . 'wpide-fonts';\n\n        if ( isset( $_POST['wpide_font_key'] ) && check_admin_referer( 'wpide_local_fonts' ) && current_user_can( 'manage_options' ) ) {\n            $key = sanitize_key( wp_unslash( $_POST['wpide_font_key'] ) );\n\n            if ( isset( $found[ $key ] ) && wp_mkdir_p( $dir ) ) {\n                $agent    = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';\n                $response = wp_safe_remote_get( set_url_scheme( $found[ $key ], 'https' ), array( 'timeout' => 15, 'user-agent' => $agent ) );\n                $css      = wp_remote_retrieve_body( $response );\n                $ok       = 200 === wp_remote_retrieve_response_code( $response ) && false !== strpos( $css, '@font-face' );\n\n                if ( $ok && preg_match_all( '#url\\((https://fonts\\.gstatic\\.com/[^)]+)\\)#', $css, $m ) ) {\n                    foreach ( array_unique( $m[1] ) as $font_url ) {\n                        $file = $key . '-' . md5( $font_url ) . '.' . pathinfo( wp_parse_url( $font_url, PHP_URL_PATH ), PATHINFO_EXTENSION );\n                        $get  = wp_safe_remote_get( $font_url, array( 'timeout' => 15, 'stream' => true, 'filename' => $dir . '/' . $file ) );\n                        if ( is_wp_error( $get ) || 200 !== wp_remote_retrieve_response_code( $get ) ) {\n                            $ok = false;\n                            break;\n                        }\n                        $css = str_replace( $font_url, $uploads['baseurl'] . '/wpide-fonts/' . $file, $css );\n                    }\n                }\n\n                if ( $ok && false !== file_put_contents( $dir . '/' . $key . '.css', $css ) ) {\n                    $local[ $key ] = time();\n                    update_option( 'wpide_local_gfonts', $local, false );\n                    echo '<div class=\"notice notice-success\"><p>Fonts downloaded. Your site now serves them locally.</p></div>';\n                } else {\n                    echo '<div class=\"notice notice-error\"><p>The fonts could not be downloaded. Check that your server can reach fonts.googleapis.com.</p></div>';\n                }\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Local Google Fonts</h1>';\n        if ( ! $found ) {\n            echo '<p>No Google Fonts detected yet. Visit some pages of your site, then come back.</p></div>';\n            return;\n        }\n        echo '<table class=\"widefat striped\"><thead><tr><th>Stylesheet</th><th>Status</th><th></th></tr></thead><tbody>';\n        foreach ( $found as $key => $url ) {\n            echo '<tr><td><code>' . esc_html( $url ) . '</code></td><td>' . ( isset( $local[ $key ] ) ? 'Served locally' : 'Loaded from Google' ) . '</td><td>';\n            echo '<form method=\"post\">';\n            wp_nonce_field( 'wpide_local_fonts' );\n            echo '<input type=\"hidden\" name=\"wpide_font_key\" value=\"' . esc_attr( $key ) . '\">';\n            submit_button( isset( $local[ $key ] ) ? 'Download again' : 'Download and serve locally', 'secondary small', 'submit', false );\n            echo '</form></td></tr>';\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Visit a few front-end pages first so the fonts are detected; fonts loaded with @import inside a theme stylesheet are not detected."
        },
        {
            "id": "privacy-noreferrer-external-links",
            "version": 1,
            "title": "Don't Send Referrer Information to Other Sites",
            "description": "Adds rel=\"noreferrer\" to links in your content that point to other websites, so the sites you link to do not learn which page the visitor came from.",
            "category": "privacy",
            "tags": [
                "links",
                "referrer",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Domains that should still receive the referrer.\n$wpide_referrer_allowed = array( 'amazon.com' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_referrer_allowed ) {\n    if ( false === stripos( $content, '<a' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $own  = wp_parse_url( home_url(), PHP_URL_HOST );\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'a' ) ) {\n        $host = wp_parse_url( (string) $tags->get_attribute( 'href' ), PHP_URL_HOST );\n\n        if ( ! $host || $host === $own ) {\n            continue;\n        }\n\n        foreach ( $wpide_referrer_allowed as $domain ) {\n            if ( $host === $domain || substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) {\n                continue 2;\n            }\n        }\n\n        $rel = preg_split( '/\\s+/', trim( (string) $tags->get_attribute( 'rel' ) ), -1, PREG_SPLIT_NO_EMPTY );\n        $rel = array_unique( array_merge( $rel, array( 'noreferrer', 'noopener' ) ) );\n        $tags->set_attribute( 'rel', implode( ' ', $rel ) );\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "Affiliate programs that rely on the referrer to credit you may stop working; add their domains to $wpide_referrer_allowed."
        },
        {
            "id": "privacy-cookie-list-shortcode",
            "version": 1,
            "title": "Cookie List Table Shortcode",
            "description": "Adds a [cookie_list] shortcode that prints a clear table of the cookies your site uses, with provider, purpose and duration, for your cookie or privacy policy page.",
            "category": "privacy",
            "tags": [
                "cookies",
                "shortcode",
                "privacy-policy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Name, provider, purpose, duration.\n$wpide_cookie_list = array(\n    array( 'wordpress_logged_in_*', 'This site', 'Keeps you logged in.', 'Session or 14 days' ),\n    array( 'wp-settings-*', 'This site', 'Remembers editor and admin preferences for logged-in users.', '1 year' ),\n    array( 'comment_author_*', 'This site', 'Remembers your name and email for the comment form, if you agree.', '1 year' ),\n    array( 'wpide_consent', 'This site', 'Remembers your cookie choice.', '6 months' ),\n    array( '_ga, _ga_*', 'Google Analytics', 'Counts visits anonymously, only after you consent.', '2 years' ),\n);\n\nadd_shortcode( 'cookie_list', function () use ( $wpide_cookie_list ) {\n    $html = '<div class=\"wpide-cookie-table\" style=\"overflow-x:auto\"><table><thead><tr><th scope=\"col\">Cookie</th><th scope=\"col\">Provider</th><th scope=\"col\">Purpose</th><th scope=\"col\">Duration</th></tr></thead><tbody>';\n\n    foreach ( $wpide_cookie_list as $row ) {\n        $html .= '<tr>';\n        foreach ( array_pad( $row, 4, '' ) as $i => $cell ) {\n            $html .= 0 === $i ? '<td><code>' . esc_html( $cell ) . '</code></td>' : '<td>' . esc_html( $cell ) . '</td>';\n        }\n        $html .= '</tr>';\n    }\n\n    return $html . '</tbody></table></div>';\n} );",
            "requires": [],
            "notes": "Keep the list in the snippet up to date whenever you add or remove a tool that sets cookies."
        },
        {
            "id": "privacy-consent-log",
            "version": 1,
            "title": "Consent Record Log With Shortened IP Addresses",
            "description": "Records each cookie consent choice (accepted or rejected, categories, date and privacy policy version) in its own database table, with only a shortened IP address, and lists the latest records under Tools > Consent Log.",
            "category": "privacy",
            "tags": [
                "consent",
                "gdpr",
                "logging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days to keep consent records.\n$wpide_consent_keep_days = 395;\n\n$wpide_consent_table = function () {\n    global $wpdb;\n    return $wpdb->prefix . 'wpide_consent_log';\n};\n\nadd_action( 'admin_init', function () use ( $wpide_consent_table ) {\n    global $wpdb;\n\n    if ( '1' === get_option( 'wpide_consent_log_db' ) ) {\n        return;\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n    dbDelta( 'CREATE TABLE ' . $wpide_consent_table() . \" (\n        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n        created datetime NOT NULL,\n        choice varchar(20) NOT NULL,\n        categories varchar(190) NOT NULL DEFAULT '',\n        ip varchar(45) NOT NULL DEFAULT '',\n        policy varchar(32) NOT NULL DEFAULT '',\n        PRIMARY KEY  (id),\n        KEY created (created)\n    ) \" . $wpdb->get_charset_collate() . ';' );\n    update_option( 'wpide_consent_log_db', '1' );\n} );\n\nadd_action( 'rest_api_init', function () use ( $wpide_consent_table ) {\n    register_rest_route( 'wpide/v1', '/consent', array(\n        'methods'             => 'POST',\n        'permission_callback' => '__return_true',\n        'args'                => array(\n            'choice'     => array( 'type' => 'string', 'enum' => array( 'granted', 'denied', 'partial' ), 'required' => true ),\n            'categories' => array( 'type' => 'string', 'default' => '' ),\n        ),\n        'callback'            => function ( WP_REST_Request $request ) use ( $wpide_consent_table ) {\n            global $wpdb;\n\n            $ip   = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_privacy_anonymize_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) ) : '';\n            $rate = 'wpide_consent_rate_' . md5( $ip );\n            if ( (int) get_transient( $rate ) >= 10 ) {\n                return new WP_REST_Response( array( 'ok' => false ), 429 );\n            }\n            set_transient( $rate, (int) get_transient( $rate ) + 1, HOUR_IN_SECONDS );\n\n            if ( '1' !== get_option( 'wpide_consent_log_db' ) ) {\n                return new WP_REST_Response( array( 'ok' => false ), 503 );\n            }\n\n            $policy_id = (int) get_option( 'wp_page_for_privacy_policy' );\n            $wpdb->insert( $wpide_consent_table(), array(\n                'created'    => current_time( 'mysql', true ),\n                'choice'     => $request['choice'],\n                'categories' => substr( sanitize_text_field( $request['categories'] ), 0, 190 ),\n                'ip'         => $ip,\n                'policy'     => $policy_id ? (string) get_post_modified_time( 'Y-m-d', true, $policy_id ) : '',\n            ) );\n\n            return array( 'ok' => true );\n        },\n    ) );\n} );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    var url = <?php echo wp_json_encode( esc_url_raw( rest_url( 'wpide/v1/consent' ) ) ); ?>;\n    var timer;\n    function send(choice, categories) {\n        clearTimeout(timer);\n        timer = setTimeout(function () {\n            var body = new URLSearchParams({ choice: choice, categories: categories || '' });\n            if (navigator.sendBeacon) { navigator.sendBeacon(url, body); } else { fetch(url, { method: 'POST', body: body, keepalive: true }); }\n        }, 500);\n    }\n    document.addEventListener('wpide:consent', function (e) { send(e.detail === 'granted' ? 'granted' : 'denied', 'all'); });\n    document.addEventListener('wp_listen_for_consent_change', function (e) {\n        var detail = e.detail || {};\n        var cats = Object.keys(detail);\n        var granted = cats.filter(function (c) { return detail[c] === 'allow' || detail[c] === 'granted'; });\n        send(granted.length === cats.length ? 'granted' : (granted.length ? 'partial' : 'denied'), granted.join(','));\n    });\n})();\n</script>\n    <?php\n}, 99 );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_consent_log_cleanup' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_consent_log_cleanup' );\n    }\n} );\n\nadd_action( 'wpide_consent_log_cleanup', function () use ( $wpide_consent_table, $wpide_consent_keep_days ) {\n    global $wpdb;\n\n    if ( '1' === get_option( 'wpide_consent_log_db' ) ) {\n        $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpide_consent_table() . ' WHERE created < %s LIMIT 5000', gmdate( 'Y-m-d H:i:s', time() - $wpide_consent_keep_days * DAY_IN_SECONDS ) ) );\n    }\n} );\n\nadd_action( 'admin_menu', function () use ( $wpide_consent_table ) {\n    add_management_page( 'Consent Log', 'Consent Log', 'manage_options', 'wpide-consent-log', function () use ( $wpide_consent_table ) {\n        global $wpdb;\n\n        $rows = '1' === get_option( 'wpide_consent_log_db' ) ? $wpdb->get_results( 'SELECT * FROM ' . $wpide_consent_table() . ' ORDER BY id DESC LIMIT 200' ) : array();\n\n        echo '<div class=\"wrap\"><h1>Consent Log</h1><p>The latest 200 consent choices. IP addresses are stored shortened.</p>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>Date (UTC)</th><th>Choice</th><th>Categories</th><th>IP</th><th>Policy version</th></tr></thead><tbody>';\n        if ( ! $rows ) {\n            echo '<tr><td colspan=\"5\">No records yet.</td></tr>';\n        }\n        foreach ( $rows as $row ) {\n            printf( '<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $row->created ), esc_html( $row->choice ), esc_html( $row->categories ), esc_html( $row->ip ), esc_html( $row->policy ) );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Picks up choices from the WP Consent API and from the wpide:consent event of the consent banner snippet; records older than $wpide_consent_keep_days are removed daily."
        },
        {
            "id": "privacy-vimeo-do-not-track",
            "version": 1,
            "title": "Vimeo Embeds With \"Do Not Track\"",
            "description": "Adds Vimeo's dnt=1 setting to every embedded Vimeo video, which stops Vimeo from setting tracking cookies or collecting session data for your visitors.",
            "category": "privacy",
            "tags": [
                "vimeo",
                "embeds",
                "tracking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'oembed_fetch_url', function ( $provider ) {\n    return false !== strpos( $provider, 'vimeo.com' ) ? add_query_arg( 'dnt', '1', $provider ) : $provider;\n} );\n\nadd_filter( 'embed_oembed_html', function ( $html, $url ) {\n    if ( false === stripos( $url, 'vimeo.com' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $html;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $html );\n\n    while ( $tags->next_tag( 'iframe' ) ) {\n        $src = (string) $tags->get_attribute( 'src' );\n        if ( false !== strpos( $src, 'player.vimeo.com' ) ) {\n            $tags->set_attribute( 'src', add_query_arg( 'dnt', '1', $src ) );\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 10, 2 );",
            "requires": [],
            "notes": "Vimeo still sees the visitor's IP address when the player loads; combine with a click-to-load snippet if you need to avoid that too."
        },
        {
            "id": "privacy-vimeo-click-to-load",
            "version": 1,
            "title": "Load Vimeo Videos Only After a Click",
            "description": "Replaces embedded Vimeo players with a lightweight placeholder showing the video title, so nothing is loaded from Vimeo until the visitor clicks to play.",
            "category": "privacy",
            "tags": [
                "vimeo",
                "embeds",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'embed_oembed_html', function ( $html, $url ) {\n    if ( false === stripos( $url, 'vimeo.com' ) || ! preg_match( '#<iframe[^>]+src=\"([^\"]*player\\.vimeo\\.com[^\"]*)\"#i', $html, $src ) ) {\n        return $html;\n    }\n\n    $title = preg_match( '#title=\"([^\"]*)\"#i', $html, $t ) ? html_entity_decode( $t[1], ENT_QUOTES ) : 'Vimeo video';\n    $player = add_query_arg( array( 'autoplay' => 1, 'dnt' => 1 ), html_entity_decode( $src[1] ) );\n\n    return sprintf(\n        '<div class=\"wpide-vimeo\" data-src=\"%1$s\" style=\"position:relative;aspect-ratio:16/9;background:#1a2e3b;color:#fff;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;padding:16px;text-align:center;\"><p style=\"margin:0;font-weight:600\">%2$s</p><button type=\"button\" class=\"wpide-vimeo-play\" style=\"padding:10px 18px;border:0;border-radius:6px;background:#00adef;color:#fff;cursor:pointer;\">Play video from Vimeo</button><small>Vimeo will receive your IP address when the video loads.</small></div>',\n        esc_url( $player ),\n        esc_html( $title )\n    );\n}, 20, 2 );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<script>\ndocument.addEventListener('click', function (e) {\n    var btn = e.target.closest ? e.target.closest('.wpide-vimeo-play') : null;\n    if (!btn) { return; }\n    var box = btn.closest('.wpide-vimeo');\n    var frame = document.createElement('iframe');\n    frame.src = box.getAttribute('data-src');\n    frame.title = box.querySelector('p').textContent;\n    frame.allow = 'autoplay; fullscreen; picture-in-picture';\n    frame.allowFullscreen = true;\n    frame.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;border:0;';\n    box.replaceChildren(frame);\n    frame.focus();\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "The placeholder uses no image from Vimeo on purpose, so no request reaches Vimeo before the click."
        },
        {
            "id": "privacy-two-click-iframes",
            "version": 1,
            "title": "Two-Click Loading for Spotify, SoundCloud and Other Embeds",
            "description": "Replaces iframes from third-party services in your posts with a notice and a \"Load\" button, with an option to always allow that service on the visitor's device.",
            "category": "privacy",
            "tags": [
                "embeds",
                "gdpr",
                "iframes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Host fragment => service name shown to the visitor.\n$wpide_two_click_hosts = array(\n    'open.spotify.com'    => 'Spotify',\n    'w.soundcloud.com'    => 'SoundCloud',\n    'dailymotion.com'     => 'Dailymotion',\n    'player.twitch.tv'    => 'Twitch',\n    'calendar.google.com' => 'Google Calendar',\n    'docs.google.com'     => 'Google Docs',\n    'typeform.com'        => 'Typeform',\n    'bandcamp.com'        => 'Bandcamp',\n);\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_two_click_hosts ) {\n    if ( false === stripos( $content, '<iframe' ) ) {\n        return $content;\n    }\n\n    return preg_replace_callback( '#<iframe\\b[^>]*\\bsrc=[\"\\']([^\"\\']+)[\"\\'][^>]*>\\s*</iframe>#is', function ( $m ) use ( $wpide_two_click_hosts ) {\n        foreach ( $wpide_two_click_hosts as $host => $name ) {\n            if ( false === stripos( $m[1], $host ) ) {\n                continue;\n            }\n\n            $key = sanitize_key( $name );\n\n            return sprintf(\n                '<div class=\"wpide-2click\" data-service=\"%1$s\"><template>%2$s</template><div class=\"wpide-2click-note\" style=\"padding:16px;border:1px solid #c3c4c7;background:#f6f7f7;\"><p style=\"margin:0 0 8px\">%3$s</p><button type=\"button\" class=\"wpide-2click-load\">%4$s</button> <label style=\"margin-left:8px\"><input type=\"checkbox\" class=\"wpide-2click-always\"> %5$s</label></div></div>',\n                esc_attr( $key ),\n                $m[0],\n                esc_html( sprintf( 'This content is hosted by %s. Loading it sends data such as your IP address to %s.', $name, $name ) ),\n                esc_html( 'Load ' . $name ),\n                esc_html( 'Always allow ' . $name )\n            );\n        }\n\n        return $m[0];\n    }, $content );\n}, 20 );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<script>\n(function () {\n    var key = 'wpideAllowed';\n    function allowed() { try { return JSON.parse(localStorage.getItem(key) || '[]'); } catch (e) { return []; } }\n    function show(box) { var t = box.querySelector('template'); if (t) { box.replaceChildren(t.content.cloneNode(true)); } }\n    var list = allowed();\n    document.querySelectorAll('.wpide-2click').forEach(function (box) {\n        if (list.indexOf(box.getAttribute('data-service')) !== -1) { show(box); }\n    });\n    document.addEventListener('click', function (e) {\n        var btn = e.target.closest ? e.target.closest('.wpide-2click-load') : null;\n        if (!btn) { return; }\n        var box = btn.closest('.wpide-2click');\n        var service = box.getAttribute('data-service');\n        if (box.querySelector('.wpide-2click-always').checked) {\n            var l = allowed();\n            if (l.indexOf(service) === -1) { l.push(service); }\n            try { localStorage.setItem(key, JSON.stringify(l)); } catch (err) {}\n            document.querySelectorAll('.wpide-2click[data-service=\"' + service + '\"]').forEach(show);\n        } else {\n            show(box);\n        }\n    });\n})();\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "YouTube, Vimeo and Google Maps have their own click-to-load snippets; add other services to $wpide_two_click_hosts."
        },
        {
            "id": "privacy-social-embeds-click",
            "version": 1,
            "title": "Load Social Media Post Embeds Only on Click",
            "description": "Holds back embedded posts from X (Twitter), Instagram, Facebook, TikTok and Threads behind a short notice, so their scripts and trackers only load when the visitor asks for them.",
            "category": "privacy",
            "tags": [
                "embeds",
                "social-media",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Provider URL fragment => name shown to visitors.\n$wpide_social_embeds = array(\n    'twitter.com'   => 'X (Twitter)',\n    'x.com'         => 'X (Twitter)',\n    'instagram.com' => 'Instagram',\n    'facebook.com'  => 'Facebook',\n    'tiktok.com'    => 'TikTok',\n    'threads.net'   => 'Threads',\n);\n\nadd_filter( 'embed_oembed_html', function ( $html, $url ) use ( $wpide_social_embeds ) {\n    $host = (string) wp_parse_url( $url, PHP_URL_HOST );\n    $name = '';\n\n    foreach ( $wpide_social_embeds as $domain => $label ) {\n        if ( $host === $domain || substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) {\n            $name = $label;\n            break;\n        }\n    }\n\n    if ( ! $name || false === stripos( $html, '<script' ) ) {\n        return $html;\n    }\n\n    $preview = wp_trim_words( wp_strip_all_tags( preg_replace( '#<script\\b.*?</script>#is', '', $html ) ), 40 );\n\n    return sprintf(\n        '<div class=\"wpide-social-embed\"><template>%1$s</template><div style=\"padding:16px;border:1px solid #c3c4c7;border-radius:8px;\"><p style=\"margin:0 0 8px\">%2$s</p><p style=\"margin:0 0 8px;font-size:.9em\">%3$s <a href=\"%4$s\" target=\"_blank\" rel=\"noopener\">View on %5$s</a></p><button type=\"button\" class=\"wpide-social-load\">%6$s</button></div></div>',\n        $html,\n        esc_html( $preview ),\n        esc_html( sprintf( 'Loading this post sends data to %s.', $name ) ),\n        esc_url( $url ),\n        esc_html( $name ),\n        esc_html( 'Show post from ' . $name )\n    );\n}, 20, 2 );\n\nadd_action( 'wp_footer', function () {\n    ?>\n<script>\ndocument.addEventListener('click', function (e) {\n    var btn = e.target.closest ? e.target.closest('.wpide-social-load') : null;\n    if (!btn) { return; }\n    var box = btn.closest('.wpide-social-embed');\n    var tpl = box.querySelector('template');\n    var frag = tpl.content.cloneNode(true);\n    frag.querySelectorAll('script').forEach(function (old) {\n        var s = document.createElement('script');\n        Array.prototype.forEach.call(old.attributes, function (a) { s.setAttribute(a.name, a.value); });\n        s.text = old.text;\n        old.replaceWith(s);\n    });\n    box.replaceChildren(frag);\n});\n</script>\n    <?php\n} );",
            "requires": [],
            "notes": "The text of the post is still shown inside the notice when the provider includes it in the embed code."
        },
        {
            "id": "privacy-woo-mask-order-ip",
            "version": 1,
            "title": "Store Shortened IP Addresses and No Browser Details on Orders",
            "description": "Shortens the customer IP address saved with each new WooCommerce order and removes the stored browser user agent, keeping order records leaner on personal data.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "ip-address",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'woocommerce_before_order_object_save', function ( $order ) {\n    if ( ! is_a( $order, 'WC_Order' ) || is_a( $order, 'WC_Order_Refund' ) ) {\n        return;\n    }\n\n    $ip = $order->get_customer_ip_address( 'edit' );\n\n    if ( $ip && wp_privacy_anonymize_ip( $ip ) !== $ip ) {\n        $order->set_customer_ip_address( wp_privacy_anonymize_ip( $ip ) );\n    }\n\n    if ( $order->get_customer_user_agent( 'edit' ) ) {\n        $order->set_customer_user_agent( '' );\n    }\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Fraud prevention and payment plugins may read the full IP during checkout before it is saved; check your fraud tools still work."
        },
        {
            "id": "privacy-edd-mask-ip",
            "version": 1,
            "title": "Store Shortened IP Addresses for Easy Digital Downloads Orders",
            "description": "Shortens the customer IP address that Easy Digital Downloads records with orders and customer logs, so a full address is never stored.",
            "category": "privacy",
            "tags": [
                "edd",
                "ip-address",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'edd_get_ip', function ( $ip ) {\n    return $ip && function_exists( 'wp_privacy_anonymize_ip' ) ? wp_privacy_anonymize_ip( $ip ) : $ip;\n}, 20 );",
            "requires": [
                "edd"
            ],
            "notes": "EDD's fraud and file download limits that rely on IP addresses then work per network instead of per device."
        },
        {
            "id": "privacy-data-request-form",
            "version": 1,
            "title": "Personal Data Request Form Shortcode",
            "description": "Adds a [privacy_request_form] shortcode where visitors can ask for a copy of their personal data or for its deletion. Requests go into WordPress's own privacy tools and the visitor confirms by email.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "forms",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( empty( $_POST['wpide_privacy_request'] ) ) {\n        return;\n    }\n\n    $back = wp_get_referer() ? wp_get_referer() : home_url( '/' );\n\n    if ( ! isset( $_POST['_wpide_pr_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['_wpide_pr_nonce'] ), 'wpide_privacy_request' ) || ! empty( $_POST['wpide_website'] ) ) {\n        wp_safe_redirect( add_query_arg( 'privacy_request', 'error', $back ) );\n        exit;\n    }\n\n    $ip    = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $rate  = 'wpide_pr_' . md5( $ip );\n    $email = isset( $_POST['wpide_pr_email'] ) ? sanitize_email( wp_unslash( $_POST['wpide_pr_email'] ) ) : '';\n    $type  = isset( $_POST['wpide_pr_type'] ) && 'remove' === $_POST['wpide_pr_type'] ? 'remove_personal_data' : 'export_personal_data';\n\n    if ( (int) get_transient( $rate ) >= 3 || ! is_email( $email ) ) {\n        wp_safe_redirect( add_query_arg( 'privacy_request', 'error', $back ) );\n        exit;\n    }\n\n    set_transient( $rate, (int) get_transient( $rate ) + 1, HOUR_IN_SECONDS );\n    $request_id = wp_create_user_request( $email, $type );\n\n    if ( ! is_wp_error( $request_id ) ) {\n        wp_send_user_request( $request_id );\n    }\n\n    // Same message whether or not a request already existed, so the form reveals nothing.\n    wp_safe_redirect( add_query_arg( 'privacy_request', 'sent', $back ) );\n    exit;\n} );\n\nadd_shortcode( 'privacy_request_form', function () {\n    $status = isset( $_GET['privacy_request'] ) ? sanitize_key( $_GET['privacy_request'] ) : '';\n    $html   = '';\n\n    if ( 'sent' === $status ) {\n        $html .= '<p class=\"wpide-pr-msg\" role=\"status\">Thank you. Please check your inbox and click the confirmation link to complete your request.</p>';\n    } elseif ( 'error' === $status ) {\n        $html .= '<p class=\"wpide-pr-msg\" role=\"alert\">Your request could not be sent. Please check the email address and try again later.</p>';\n    }\n\n    $html .= '<form method=\"post\" class=\"wpide-privacy-request\">'\n        . wp_nonce_field( 'wpide_privacy_request', '_wpide_pr_nonce', true, false )\n        . '<p><label for=\"wpide-pr-email\">Your email address</label><br><input type=\"email\" id=\"wpide-pr-email\" name=\"wpide_pr_email\" required autocomplete=\"email\"></p>'\n        . '<fieldset><legend>What would you like us to do?</legend>'\n        . '<label><input type=\"radio\" name=\"wpide_pr_type\" value=\"export\" checked> Send me a copy of my personal data</label><br>'\n        . '<label><input type=\"radio\" name=\"wpide_pr_type\" value=\"remove\"> Delete my personal data</label></fieldset>'\n        . '<p style=\"position:absolute;left:-9999px\" aria-hidden=\"true\"><label>Website <input type=\"text\" name=\"wpide_website\" tabindex=\"-1\" autocomplete=\"off\"></label></p>'\n        . '<p><button type=\"submit\" name=\"wpide_privacy_request\" value=\"1\">Send request</button></p></form>';\n\n    return $html;\n} );",
            "requires": [],
            "notes": "Handle the confirmed requests under Tools > Export Personal Data and Tools > Erase Personal Data; submissions are limited to 3 per hour per visitor."
        },
        {
            "id": "privacy-export-link-expiry",
            "version": 1,
            "title": "Expire Personal Data Export Downloads After 24 Hours",
            "description": "Shortens how long the download link and ZIP file of a personal data export stay available, from WordPress's default of three days to one day.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "export",
                "retention"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Hours an export file remains downloadable.\n$wpide_export_hours = 24;\n\nadd_filter( 'wp_privacy_export_expiration', function () use ( $wpide_export_hours ) {\n    return (int) $wpide_export_hours * HOUR_IN_SECONDS;\n} );",
            "requires": [],
            "notes": "Change the value in hours to fit how quickly people usually download their exports."
        },
        {
            "id": "privacy-requests-menu-badge",
            "version": 1,
            "title": "Pending Privacy Requests Badge in the Admin Menu",
            "description": "Shows a count bubble next to Tools and the Export and Erase Personal Data menu items when confirmed privacy requests are waiting for you to process them.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "admin-menu",
                "notifications"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    global $menu, $submenu, $wpdb;\n\n    if ( ! current_user_can( 'export_others_personal_data' ) && ! current_user_can( 'erase_others_personal_data' ) ) {\n        return;\n    }\n\n    $counts = get_transient( 'wpide_privacy_request_counts' );\n\n    if ( false === $counts ) {\n        $counts = array( 'export_personal_data' => 0, 'remove_personal_data' => 0 );\n        $rows   = $wpdb->get_results( \"SELECT post_name, COUNT(*) AS total FROM {$wpdb->posts} WHERE post_type = 'user_request' AND post_status = 'request-confirmed' GROUP BY post_name\" );\n        foreach ( $rows as $row ) {\n            $counts[ $row->post_name ] = (int) $row->total;\n        }\n        set_transient( 'wpide_privacy_request_counts', $counts, 10 * MINUTE_IN_SECONDS );\n    }\n\n    $bubble = function ( $count ) {\n        return $count ? ' <span class=\"awaiting-mod\"><span class=\"pending-count\">' . (int) $count . '</span></span>' : '';\n    };\n    $total = array_sum( $counts );\n\n    if ( ! $total || empty( $submenu['tools.php'] ) ) {\n        return;\n    }\n\n    foreach ( $submenu['tools.php'] as &$item ) {\n        if ( 'export-personal-data.php' === $item[2] ) {\n            $item[0] .= $bubble( $counts['export_personal_data'] );\n        } elseif ( 'erase-personal-data.php' === $item[2] ) {\n            $item[0] .= $bubble( $counts['remove_personal_data'] );\n        }\n    }\n    unset( $item );\n\n    foreach ( (array) $menu as $key => $item ) {\n        if ( isset( $item[2] ) && 'tools.php' === $item[2] ) {\n            $menu[ $key ][0] .= $bubble( $total );\n        }\n    }\n}, 99 );\n\nadd_action( 'user_request_action_confirmed', function () {\n    delete_transient( 'wpide_privacy_request_counts' );\n} );\nadd_action( 'transition_post_status', function ( $new, $old, $post ) {\n    if ( 'user_request' === $post->post_type ) {\n        delete_transient( 'wpide_privacy_request_counts' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Only confirmed requests are counted, since those are the ones that need action from you."
        },
        {
            "id": "privacy-request-deadline-notice",
            "version": 1,
            "title": "Warn About Privacy Requests Close to the 30-Day Deadline",
            "description": "Shows a dashboard notice to administrators when a personal data request has been waiting for more than 25 days, since GDPR expects an answer within one month.",
            "category": "privacy",
            "tags": [
                "gdpr",
                "admin-notice",
                "deadlines"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Warn when a request is older than this many days.\n$wpide_request_warn_days = 25;\n\nadd_action( 'admin_notices', function () use ( $wpide_request_warn_days ) {\n    global $wpdb;\n\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $late = get_transient( 'wpide_late_privacy_requests' );\n\n    if ( false === $late ) {\n        $late = (int) $wpdb->get_var( $wpdb->prepare(\n            \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'user_request' AND post_status IN ('request-pending','request-confirmed') AND post_date_gmt < %s\",\n            gmdate( 'Y-m-d H:i:s', time() - $wpide_request_warn_days * DAY_IN_SECONDS )\n        ) );\n        set_transient( 'wpide_late_privacy_requests', $late, HOUR_IN_SECONDS );\n    }\n\n    if ( $late ) {\n        printf(\n            '<div class=\"notice notice-error\"><p>%s <a href=\"%s\">Export requests</a> | <a href=\"%s\">Erasure requests</a></p></div>',\n            esc_html( sprintf( '%d personal data request(s) have been open for more than %d days.', $late, $wpide_request_warn_days ) ),\n            esc_url( admin_url( 'export-personal-data.php' ) ),\n            esc_url( admin_url( 'erase-personal-data.php' ) )\n        );\n    }\n} );",
            "requires": [],
            "notes": "Change $wpide_request_warn_days to warn earlier; the check is cached for an hour."
        },
        {
            "id": "privacy-hide-commenter-ips",
            "version": 1,
            "title": "Hide Commenter IP Addresses From Non-Administrators",
            "description": "Hides commenters' IP addresses in the Comments screens for editors, moderators and authors, so only administrators can see this personal data.",
            "category": "privacy",
            "tags": [
                "comments",
                "ip-address",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'get_comment_author_IP', function ( $ip ) {\n    return current_user_can( 'manage_options' ) ? $ip : '';\n}, 20 );",
            "requires": [],
            "notes": "Administrators (users who can manage options) still see the full IP address."
        },
        {
            "id": "privacy-third-party-inspector",
            "version": 1,
            "title": "Third-Party Requests Inspector in the Admin Bar",
            "description": "Adds an admin bar item on the front of the site that counts the external domains the current page loads files from, and lists them on click, to help you keep your cookie notice and privacy policy accurate.",
            "category": "privacy",
            "tags": [
                "admin-bar",
                "audit",
                "third-party"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $bar ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-third-parties',\n        'title' => 'Third parties: ...',\n        'href'  => '#',\n    ) );\n}, 95 );\n\nadd_action( 'wp_footer', function () {\n    if ( ! is_admin_bar_showing() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n    ?>\n<script>\n(function () {\n    var link = document.querySelector('#wp-admin-bar-wpide-third-parties > a');\n    if (!link || !window.performance || !performance.getEntriesByType) { return; }\n    function hosts() {\n        var own = location.hostname, map = {};\n        performance.getEntriesByType('resource').forEach(function (r) {\n            var h;\n            try { h = new URL(r.name).hostname; } catch (e) { return; }\n            if (h && h !== own) { map[h] = (map[h] || 0) + 1; }\n        });\n        return map;\n    }\n    function label() { link.textContent = 'Third parties: ' + Object.keys(hosts()).length; }\n    window.addEventListener('load', function () { setTimeout(label, 1500); });\n    link.addEventListener('click', function (e) {\n        e.preventDefault();\n        var map = hosts();\n        label();\n        var lines = Object.keys(map).sort().map(function (h) { return h + ' (' + map[h] + ' requests)'; });\n        window.alert(lines.length ? 'This page loaded files from:\\n\\n' + lines.join('\\n') : 'This page loaded nothing from other domains.');\n    });\n})();\n</script>\n    <?php\n}, 99 );",
            "requires": [],
            "notes": "Uses the browser's resource timing list, so requests made after the click are not counted until you click again."
        },
        {
            "id": "privacy-strip-photo-metadata",
            "version": 1,
            "title": "Remove Location and Camera Data From Uploaded Photos",
            "description": "Strips EXIF metadata such as GPS location, camera serial number and date from JPEG and WebP images when they are uploaded, while keeping them correctly rotated.",
            "category": "privacy",
            "tags": [
                "images",
                "exif",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_handle_upload', function ( $upload ) {\n    if ( empty( $upload['file'] ) || empty( $upload['type'] ) || ! in_array( $upload['type'], array( 'image/jpeg', 'image/webp' ), true ) || ! is_writable( $upload['file'] ) ) {\n        return $upload;\n    }\n\n    $file = $upload['file'];\n\n    if ( class_exists( 'Imagick' ) ) {\n        try {\n            $image = new Imagick( $file );\n            switch ( $image->getImageOrientation() ) {\n                case Imagick::ORIENTATION_BOTTOMRIGHT:\n                    $image->rotateImage( '#000', 180 );\n                    break;\n                case Imagick::ORIENTATION_RIGHTTOP:\n                    $image->rotateImage( '#000', 90 );\n                    break;\n                case Imagick::ORIENTATION_LEFTBOTTOM:\n                    $image->rotateImage( '#000', -90 );\n                    break;\n            }\n            $image->setImageOrientation( Imagick::ORIENTATION_TOPLEFT );\n            $profiles = $image->getImageProfiles( 'icc', true );\n            $image->stripImage();\n            if ( ! empty( $profiles['icc'] ) ) {\n                $image->profileImage( 'icc', $profiles['icc'] );\n            }\n            $image->writeImage( $file );\n            $image->clear();\n        } catch ( Exception $e ) {\n            return $upload;\n        }\n\n        return $upload;\n    }\n\n    // GD fallback: re-saving drops all metadata.\n    if ( 'image/jpeg' === $upload['type'] && function_exists( 'imagecreatefromjpeg' ) ) {\n        $exif        = function_exists( 'exif_read_data' ) ? @exif_read_data( $file ) : false; // Corrupt EXIF blocks raise warnings.\n        $orientation = is_array( $exif ) && ! empty( $exif['Orientation'] ) ? (int) $exif['Orientation'] : 1;\n        $image       = @imagecreatefromjpeg( $file );\n        if ( $image ) {\n            $angles = array( 3 => 180, 6 => -90, 8 => 90 );\n            if ( isset( $angles[ $orientation ] ) ) {\n                $image = imagerotate( $image, $angles[ $orientation ], 0 );\n            }\n            imagejpeg( $image, $file, 90 );\n            imagedestroy( $image );\n        }\n    }\n\n    return $upload;\n} );",
            "requires": [],
            "notes": "Uses Imagick when available and falls back to re-saving with GD (which also removes color profiles); photographers who want to keep metadata should not use this."
        },
        {
            "id": "privacy-flamingo-retention",
            "version": 1,
            "title": "Delete Old Contact Form 7 Messages Stored in Flamingo",
            "description": "Permanently deletes contact form messages saved by the Flamingo add-on once they are older than 180 days, so old enquiries and their personal data are not kept forever.",
            "category": "privacy",
            "tags": [
                "contact-form-7",
                "retention",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days to keep stored messages.\n$wpide_flamingo_days = 180;\n\nadd_action( 'init', function () {\n    if ( post_type_exists( 'flamingo_inbound' ) && ! wp_next_scheduled( 'wpide_flamingo_retention' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_flamingo_retention' );\n    }\n}, 20 );\n\nadd_action( 'wpide_flamingo_retention', function () use ( $wpide_flamingo_days ) {\n    if ( ! post_type_exists( 'flamingo_inbound' ) ) {\n        return;\n    }\n\n    $ids = get_posts( array(\n        'post_type'      => 'flamingo_inbound',\n        'post_status'    => 'any',\n        'posts_per_page' => 200,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n        'date_query'     => array( array( 'before' => $wpide_flamingo_days . ' days ago' ) ),\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_delete_post( $id, true );\n    }\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Requires the Flamingo plugin alongside Contact Form 7; nothing happens if Flamingo is not active."
        },
        {
            "id": "privacy-cookie-settings-link",
            "version": 1,
            "title": "\"Cookie Settings\" Link That Reopens the Consent Banner",
            "description": "Adds a [cookie_settings_link] shortcode that lets visitors change their cookie choice at any time: it clears the stored choice and reloads the page so the consent banner shows again.",
            "category": "privacy",
            "tags": [
                "cookies",
                "consent",
                "shortcode"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_shortcode( 'cookie_settings_link', function ( $atts ) {\n    $atts = shortcode_atts( array( 'text' => 'Cookie settings' ), $atts, 'cookie_settings_link' );\n\n    return '<button type=\"button\" class=\"wpide-cookie-settings\" onclick=\"document.cookie=\\'wpide_consent=;path=/;max-age=0\\';location.reload();\" style=\"background:none;border:0;padding:0;color:inherit;text-decoration:underline;cursor:pointer;font:inherit;\">' . esc_html( $atts['text'] ) . '</button>';\n} );",
            "requires": [],
            "notes": "Made for the simple wpide_consent cookie banner; consent plugins usually provide their own settings link."
        },
        {
            "id": "privacy-policy-update-banner",
            "version": 1,
            "title": "Tell Visitors When the Privacy Policy Changes",
            "description": "Shows a small dismissible notice linking to your privacy policy whenever the policy page has been updated since the visitor last dismissed it. Logged-in users' choice is saved to their profile.",
            "category": "privacy",
            "tags": [
                "privacy-policy",
                "notice",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    $page_id = (int) get_option( 'wp_page_for_privacy_policy' );\n\n    if ( ! $page_id || 'publish' !== get_post_status( $page_id ) || is_page( $page_id ) ) {\n        return;\n    }\n\n    $version = (string) get_post_modified_time( 'U', true, $page_id );\n\n    if ( is_user_logged_in() && get_user_meta( get_current_user_id(), 'wpide_policy_seen', true ) === $version ) {\n        return;\n    }\n    ?>\n<div id=\"wpide-policy-update\" role=\"region\" aria-label=\"Privacy policy update\" hidden style=\"position:fixed;left:12px;right:12px;bottom:12px;z-index:99980;max-width:560px;margin:0 auto;padding:12px 16px;border-radius:8px;background:#fff;color:#1d2327;box-shadow:0 4px 20px rgba(0,0,0,.2);font-size:15px;\">\n    We have updated our <a href=\"<?php echo esc_url( get_permalink( $page_id ) ); ?>\">privacy policy</a>.\n    <button type=\"button\" style=\"margin-left:8px;\" data-version=\"<?php echo esc_attr( $version ); ?>\">OK</button>\n</div>\n<script>\n(function () {\n    var box = document.getElementById('wpide-policy-update');\n    var btn = box.querySelector('button');\n    var version = btn.getAttribute('data-version');\n    var seen = '';\n    try { seen = localStorage.getItem('wpidePolicySeen') || ''; } catch (e) {}\n    if (seen === version) { return; }\n    box.hidden = false;\n    btn.addEventListener('click', function () {\n        box.hidden = true;\n        try { localStorage.setItem('wpidePolicySeen', version); } catch (e) {}\n        <?php if ( is_user_logged_in() ) : ?>\n        var body = new URLSearchParams({ action: 'wpide_policy_seen', version: version, _ajax_nonce: <?php echo wp_json_encode( wp_create_nonce( 'wpide_policy_seen' ) ); ?> });\n        fetch(<?php echo wp_json_encode( admin_url( 'admin-ajax.php' ) ); ?>, { method: 'POST', body: body, credentials: 'same-origin' });\n        <?php endif; ?>\n    });\n})();\n</script>\n    <?php\n} );\n\nadd_action( 'wp_ajax_wpide_policy_seen', function () {\n    check_ajax_referer( 'wpide_policy_seen' );\n    update_user_meta( get_current_user_id(), 'wpide_policy_seen', isset( $_POST['version'] ) ? sanitize_text_field( wp_unslash( $_POST['version'] ) ) : '' );\n    wp_send_json_success();\n} );",
            "requires": [],
            "notes": "Only shown once a privacy policy page is set in Settings > Privacy; it reappears each time you update that page."
        },
        {
            "id": "privacy-woo-no-geolocation",
            "version": 1,
            "title": "Turn Off WooCommerce Customer Geolocation",
            "description": "Stops WooCommerce from looking up each visitor's location from their IP address and uses your store's base country as the default customer location instead.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "geolocation",
                "ip-address"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_woocommerce_default_customer_address', function () {\n    return 'base';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Tax and shipping estimates then start from your store's address until the customer enters their own."
        },
        {
            "id": "privacy-woo-no-usage-tracking",
            "version": 1,
            "title": "Keep WooCommerce Usage Tracking Switched Off",
            "description": "Forces WooCommerce's optional usage tracking off, so no store data is sent to WooCommerce even if someone accepts the prompt by mistake.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "tracking",
                "telemetry"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_option_woocommerce_allow_tracking', function () {\n    return 'no';\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "This does not affect WooCommerce.com connections such as extension updates."
        },
        {
            "id": "privacy-block-browser-php-checks",
            "version": 1,
            "title": "Stop Dashboard Browser and PHP Version Lookups",
            "description": "Prevents the dashboard from sending your browser's user agent and your server's PHP version to WordPress.org to decide whether to show upgrade warnings.",
            "category": "privacy",
            "tags": [
                "dashboard",
                "telemetry",
                "wordpress-org"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'pre_http_request', function ( $response, $args, $url ) {\n    if ( false !== strpos( $url, 'api.wordpress.org/core/browse-happy/' ) || false !== strpos( $url, 'api.wordpress.org/core/serve-happy/' ) ) {\n        return new WP_Error( 'wpide_blocked', 'Version check disabled.' );\n    }\n\n    return $response;\n}, 10, 3 );",
            "requires": [],
            "notes": "You will no longer see \"Your browser is out of date\" or \"PHP update required\" boxes; keep an eye on Site Health instead."
        },
        {
            "id": "privacy-cf7-mask-ip",
            "version": 1,
            "title": "Store Shortened IP Addresses for Contact Form 7 Submissions",
            "description": "Shortens the visitor IP address that Contact Form 7 records with each submission (used in mail tags and by Flamingo and spam filters), so a full address is never kept.",
            "category": "privacy",
            "tags": [
                "contact-form-7",
                "ip-address",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wpcf7_remote_ip_addr', function ( $ip ) {\n    return $ip ? wp_privacy_anonymize_ip( $ip ) : $ip;\n} );",
            "requires": [
                "cf7"
            ],
            "notes": "Return an empty string instead to drop the IP address entirely; spam protection that relies on it may become less accurate."
        },
        {
            "id": "privacy-hide-commenter-emails",
            "version": 1,
            "title": "Hide Commenter Email Addresses From Authors and Contributors",
            "description": "Hides commenters' email addresses in the comment lists of the dashboard for users who are not moderators, so authors can reply to comments without seeing readers' personal email addresses.",
            "category": "privacy",
            "tags": [
                "comments",
                "email",
                "roles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'get_comment_author_email', function ( $email ) {\n    return current_user_can( 'moderate_comments' ) ? $email : '';\n}, 20 );",
            "requires": [],
            "notes": "Editors and administrators (users who can moderate comments) still see email addresses."
        },
        {
            "id": "privacy-woo-shorter-sessions",
            "version": 1,
            "title": "Shorter WooCommerce Guest Sessions",
            "description": "Makes WooCommerce forget guest shopping sessions (cart contents and entered details) after 24 hours instead of two days, so less customer data sits in the sessions table.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "sessions",
                "retention"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Hours a guest session lasts.\n$wpide_wc_session_hours = 24;\n\nadd_filter( 'wc_session_expiration', function () use ( $wpide_wc_session_hours ) {\n    return (int) $wpide_wc_session_hours * HOUR_IN_SECONDS;\n} );\n\nadd_filter( 'wc_session_expiring', function () use ( $wpide_wc_session_hours ) {\n    return max( 1, (int) $wpide_wc_session_hours - 1 ) * HOUR_IN_SECONDS;\n} );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Carts of guests who come back after more than a day will be empty."
        },
        {
            "id": "privacy-local-avatar-for-guests",
            "version": 1,
            "title": "Don't Send Guest Commenters' Emails to Gravatar",
            "description": "Shows a built-in placeholder avatar for comments from visitors without an account, so their hashed email address is never sent to Gravatar, while registered users keep their Gravatar picture.",
            "category": "privacy",
            "tags": [
                "avatars",
                "gravatar",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_get_avatar_data', function ( $args, $id_or_email ) {\n    $is_guest = false;\n\n    if ( $id_or_email instanceof WP_Comment ) {\n        $is_guest = ! (int) $id_or_email->user_id;\n    } elseif ( is_string( $id_or_email ) && is_email( $id_or_email ) ) {\n        $is_guest = ! email_exists( $id_or_email );\n    }\n\n    if ( ! $is_guest ) {\n        return $args;\n    }\n\n    $svg = '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 64 64\"><rect width=\"64\" height=\"64\" fill=\"#c3c4c7\"/><circle cx=\"32\" cy=\"25\" r=\"12\" fill=\"#fff\"/><path d=\"M12 58c2-13 10-19 20-19s18 6 20 19z\" fill=\"#fff\"/></svg>';\n\n    $args['url']          = 'data:image/svg+xml;base64,' . base64_encode( $svg );\n    $args['found_avatar'] = true;\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the color in the SVG to match your design; registered users can still be shown with their Gravatar."
        },
        {
            "id": "privacy-policy-page-reminder",
            "version": 1,
            "title": "Remind Administrators to Publish a Privacy Policy",
            "description": "Shows a notice on the dashboard while no published privacy policy page is selected in Settings > Privacy, with a link to set one up.",
            "category": "privacy",
            "tags": [
                "privacy-policy",
                "admin-notice",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_notices', function () {\n    $screen = get_current_screen();\n\n    if ( ! $screen || 'dashboard' !== $screen->id || ! current_user_can( 'manage_privacy_options' ) ) {\n        return;\n    }\n\n    $page_id = (int) get_option( 'wp_page_for_privacy_policy' );\n\n    if ( $page_id && 'publish' === get_post_status( $page_id ) ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-warning\"><p>%s <a href=\"%s\">%s</a></p></div>',\n        esc_html( 'Your site has no published privacy policy page.' ),\n        esc_url( admin_url( 'options-privacy.php' ) ),\n        esc_html( 'Set one up now' )\n    );\n} );",
            "requires": [],
            "notes": "The notice disappears as soon as a published page is selected."
        },
        {
            "id": "privacy-age-confirmation-on-registration",
            "version": 1,
            "title": "Require Age Confirmation When Registering",
            "description": "Adds a required \"I am at least 16 years old\" checkbox to the WordPress and WooCommerce registration forms, since many privacy laws do not allow children to consent on their own.",
            "category": "privacy",
            "tags": [
                "registration",
                "gdpr",
                "consent"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum age shown in the checkbox label.\n$wpide_min_age = 16;\n\n$wpide_age_field = function () use ( $wpide_min_age ) {\n    printf(\n        '<p class=\"form-row\"><label for=\"wpide-age-ok\"><input type=\"checkbox\" name=\"wpide_age_ok\" id=\"wpide-age-ok\" value=\"1\" required> %s</label></p>',\n        esc_html( sprintf( 'I confirm that I am at least %d years old.', $wpide_min_age ) )\n    );\n};\n\n$wpide_age_check = function ( $errors ) use ( $wpide_min_age ) {\n    if ( empty( $_POST['wpide_age_ok'] ) && is_wp_error( $errors ) ) {\n        $errors->add( 'wpide_age', sprintf( '<strong>Error:</strong> You must be at least %d years old to register.', $wpide_min_age ) );\n    }\n\n    return $errors;\n};\n\nadd_action( 'register_form', $wpide_age_field );\nadd_action( 'woocommerce_register_form', $wpide_age_field );\nadd_filter( 'registration_errors', $wpide_age_check, 20 );\nadd_filter( 'woocommerce_registration_errors', $wpide_age_check, 20 );",
            "requires": [],
            "notes": "Set $wpide_min_age to the age required in your country (13 to 16 under GDPR, 13 under COPPA)."
        },
        {
            "id": "privacy-dns-prefetch-off",
            "version": 1,
            "title": "Stop Browsers Prefetching DNS for Links on Your Pages",
            "description": "Sends the X-DNS-Prefetch-Control header to tell browsers not to look up the domains of every link on your pages in advance, so visitors' DNS providers do not learn which sites you link to before they click.",
            "category": "privacy",
            "tags": [
                "headers",
                "dns",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'send_headers', function () {\n    if ( ! headers_sent() ) {\n        header( 'X-DNS-Prefetch-Control: off' );\n    }\n} );",
            "requires": [],
            "notes": "Explicit dns-prefetch and preconnect hints in your pages still work."
        },
        {
            "id": "privacy-strip-click-ids-from-links",
            "version": 1,
            "title": "Remove Ad Click IDs From Links in Your Content",
            "description": "Strips tracking parameters such as fbclid, gclid, msclkid and Mailchimp or HubSpot IDs from links in your posts (often pasted in by accident), so you do not pass your visitors' ad tracking along to other sites.",
            "category": "privacy",
            "tags": [
                "links",
                "tracking",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Query parameters removed from links.\n$wpide_click_ids = array( 'fbclid', 'gclid', 'gbraid', 'wbraid', 'dclid', 'msclkid', 'yclid', 'igshid', 'mc_cid', 'mc_eid', '_hsenc', '_hsmi', 'ttclid', 'twclid', 'li_fat_id' );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_click_ids ) {\n    if ( ! preg_match( '/[?&](' . implode( '|', array_map( 'preg_quote', $wpide_click_ids ) ) . ')=/', $content ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $content;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $content );\n\n    while ( $tags->next_tag( 'a' ) ) {\n        $href = (string) $tags->get_attribute( 'href' );\n        if ( false !== strpos( $href, '?' ) ) {\n            $clean = remove_query_arg( $wpide_click_ids, $href );\n            if ( $clean !== $href ) {\n                $tags->set_attribute( 'href', $clean );\n            }\n        }\n    }\n\n    return $tags->get_updated_html();\n}, 20 );",
            "requires": [],
            "notes": "UTM campaign tags are kept on purpose; add utm_source and friends to the list if you want those removed too."
        },
        {
            "id": "privacy-delete-stale-pending-comments",
            "version": 1,
            "title": "Delete Comments Left Unmoderated for 90 Days",
            "description": "Once a day, permanently deletes comments that have been waiting in the moderation queue for more than 90 days, along with the names, emails and IP addresses they contain.",
            "category": "privacy",
            "tags": [
                "comments",
                "retention",
                "moderation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days a comment may wait for moderation.\n$wpide_pending_days = 90;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_delete_stale_pending' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_delete_stale_pending' );\n    }\n} );\n\nadd_action( 'wpide_delete_stale_pending', function () use ( $wpide_pending_days ) {\n    $ids = get_comments( array(\n        'status'     => 'hold',\n        'number'     => 200,\n        'fields'     => 'ids',\n        'date_query' => array( array( 'column' => 'comment_date_gmt', 'before' => $wpide_pending_days . ' days ago' ) ),\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_delete_comment( (int) $id, true );\n    }\n} );",
            "requires": [],
            "notes": "Approve or reply to comments you want to keep before they reach the age limit."
        },
        {
            "id": "privacy-anonymize-deleted-user-comments",
            "version": 1,
            "title": "Anonymize the Comments of Deleted Users",
            "description": "When a user account is deleted, keeps their comments but replaces the name with \"Former member\" and removes the email address, website and IP address stored with them.",
            "category": "privacy",
            "tags": [
                "comments",
                "users",
                "gdpr"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Name shown on comments of deleted accounts.\n$wpide_former_name = 'Former member';\n\nadd_action( 'delete_user', function ( $user_id ) use ( $wpide_former_name ) {\n    global $wpdb;\n\n    $ids = $wpdb->get_col( $wpdb->prepare( \"SELECT comment_ID FROM {$wpdb->comments} WHERE user_id = %d\", $user_id ) );\n\n    if ( ! $ids ) {\n        return;\n    }\n\n    $wpdb->query( $wpdb->prepare(\n        \"UPDATE {$wpdb->comments} SET user_id = 0, comment_author = %s, comment_author_email = '', comment_author_url = '', comment_author_IP = '', comment_agent = '' WHERE user_id = %d\",\n        $wpide_former_name,\n        $user_id\n    ) );\n\n    foreach ( $ids as $id ) {\n        clean_comment_cache( (int) $id );\n    }\n} );",
            "requires": [],
            "notes": "WordPress normally keeps the commenter details of deleted accounts; change $wpide_former_name to your wording."
        },
        {
            "id": "privacy-site-health-checks",
            "version": 1,
            "title": "Site Health Checks for Privacy Settings",
            "description": "Adds Site Health tests that check whether a privacy policy page is published, whether full commenter IP addresses are being stored, and whether the comment cookie consent checkbox is turned on.",
            "category": "privacy",
            "tags": [
                "site-health",
                "gdpr",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'site_status_tests', function ( $tests ) {\n    $result = function ( $id, $ok, $good, $bad, $fix ) {\n        return array(\n            'label'       => $ok ? $good : $bad,\n            'status'      => $ok ? 'good' : 'recommended',\n            'badge'       => array( 'label' => 'Privacy', 'color' => $ok ? 'blue' : 'orange' ),\n            'description' => '<p>' . esc_html( $ok ? $good . '.' : $fix ) . '</p>',\n            'test'        => $id,\n        );\n    };\n\n    $tests['direct']['wpide_privacy_page'] = array(\n        'label' => 'Privacy policy page',\n        'test'  => function () use ( $result ) {\n            $id = (int) get_option( 'wp_page_for_privacy_policy' );\n            return $result( 'wpide_privacy_page', $id && 'publish' === get_post_status( $id ), 'A privacy policy page is published', 'No privacy policy page is published', 'Create and publish a privacy policy page, then select it under Settings > Privacy.' );\n        },\n    );\n\n    $tests['direct']['wpide_comment_ips'] = array(\n        'label' => 'Commenter IP addresses',\n        'test'  => function () use ( $result ) {\n            global $wpdb;\n            $ip = (string) $wpdb->get_var( \"SELECT comment_author_IP FROM {$wpdb->comments} WHERE comment_author_IP <> '' ORDER BY comment_ID DESC LIMIT 1\" );\n            $ok = '' === $ip || wp_privacy_anonymize_ip( $ip ) === $ip;\n            return $result( 'wpide_comment_ips', $ok, 'New comments do not store full IP addresses', 'Full IP addresses are stored with comments', 'Store shortened IP addresses for comments, or none at all, unless your spam protection needs them.' );\n        },\n    );\n\n    $tests['direct']['wpide_comment_cookies'] = array(\n        'label' => 'Comment cookie consent',\n        'test'  => function () use ( $result ) {\n            $ok = ! get_option( 'default_comment_status' ) || 'closed' === get_option( 'default_comment_status' ) || get_option( 'show_comments_cookies_opt_in' );\n            return $result( 'wpide_comment_cookies', (bool) $ok, 'Commenters are asked before their details are saved in a cookie', 'Comment cookies are set without asking', 'Turn on \"Show comments cookies opt-in checkbox\" under Settings > Discussion.' );\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Each result explains how to fix the setting; pair it with the IP shortening and consent snippets."
        },
        {
            "id": "privacy-no-referrer-on-checkout",
            "version": 1,
            "title": "Never Send the Referrer From Checkout and Account Pages",
            "description": "Sends a strict no-referrer policy on the WooCommerce checkout, order received and account pages, so order keys and other private URL details are never passed to third-party scripts or sites linked from there.",
            "category": "privacy",
            "tags": [
                "woocommerce",
                "referrer",
                "checkout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! function_exists( 'is_checkout' ) || headers_sent() ) {\n        return;\n    }\n\n    if ( is_checkout() || is_account_page() || is_wc_endpoint_url( 'order-received' ) ) {\n        header( 'Referrer-Policy: no-referrer' );\n        add_action( 'wp_head', function () {\n            echo '<meta name=\"referrer\" content=\"no-referrer\">' . \"\\n\";\n        }, 0 );\n    }\n}, 20 );",
            "requires": [
                "woocommerce"
            ],
            "notes": "Payment gateways that redirect off-site are not affected; they do not rely on the referrer."
        },
        {
            "id": "sec-csp-report-collector",
            "version": 1,
            "title": "Content Security Policy Builder With Violation Reports",
            "description": "Sends a report-only Content Security Policy and collects the browser's violation reports, then lists every blocked source per directive under Tools > CSP Reports with a suggested policy you can enforce later.",
            "category": "security",
            "tags": [
                "csp",
                "headers",
                "reports"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Starting policy (report-only). Sources reported as blocked are suggested additions.\n$wpide_csp_policy = array(\n    'default-src' => \"'self'\",\n    'script-src'  => \"'self' 'unsafe-inline'\",\n    'style-src'   => \"'self' 'unsafe-inline'\",\n    'img-src'     => \"'self' data:\",\n    'font-src'    => \"'self' data:\",\n    'connect-src' => \"'self'\",\n    'frame-src'   => \"'self'\",\n    'object-src'  => \"'none'\",\n    'base-uri'    => \"'self'\",\n);\n\nadd_action( 'send_headers', function () use ( $wpide_csp_policy ) {\n    if ( headers_sent() || is_user_logged_in() ) {\n        return;\n    }\n\n    $parts = array();\n    foreach ( $wpide_csp_policy as $directive => $sources ) {\n        $parts[] = $directive . ' ' . $sources;\n    }\n    $parts[] = 'report-uri ' . esc_url_raw( rest_url( 'wpide/v1/csp-report' ) );\n\n    header( 'Content-Security-Policy-Report-Only: ' . implode( '; ', $parts ) );\n} );\n\nadd_action( 'rest_api_init', function () {\n    register_rest_route( 'wpide/v1', '/csp-report', array(\n        'methods'             => 'POST',\n        'permission_callback' => '__return_true',\n        'callback'            => function ( WP_REST_Request $request ) {\n            $ip   = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n            $rate = 'wpide_csp_rate_' . md5( $ip );\n            if ( (int) get_transient( $rate ) > 60 ) {\n                return new WP_REST_Response( null, 204 );\n            }\n            set_transient( $rate, (int) get_transient( $rate ) + 1, MINUTE_IN_SECONDS );\n\n            $body    = json_decode( (string) $request->get_body(), true );\n            $reports = isset( $body['csp-report'] ) ? array( $body['csp-report'] ) : ( is_array( $body ) ? wp_list_pluck( array_filter( $body, 'is_array' ), 'body' ) : array() );\n            $store   = (array) get_option( 'wpide_csp_reports', array() );\n\n            foreach ( $reports as $report ) {\n                if ( ! is_array( $report ) ) {\n                    continue;\n                }\n                $directive = isset( $report['effective-directive'] ) ? $report['effective-directive'] : ( isset( $report['effectiveDirective'] ) ? $report['effectiveDirective'] : ( isset( $report['violated-directive'] ) ? $report['violated-directive'] : '' ) );\n                $blocked   = isset( $report['blocked-uri'] ) ? $report['blocked-uri'] : ( isset( $report['blockedURL'] ) ? $report['blockedURL'] : '' );\n                $directive = sanitize_key( strtok( (string) $directive, ' ' ) );\n                $host      = wp_parse_url( (string) $blocked, PHP_URL_HOST );\n                $source    = $host ? 'https://' . strtolower( $host ) : sanitize_key( (string) $blocked );\n\n                if ( ! $directive || ! $source ) {\n                    continue;\n                }\n                $key = $directive . '|' . $source;\n                if ( ! isset( $store[ $key ] ) && count( $store ) >= 200 ) {\n                    continue;\n                }\n                $store[ $key ] = array( 'count' => ( isset( $store[ $key ]['count'] ) ? (int) $store[ $key ]['count'] : 0 ) + 1, 'last' => time() );\n            }\n\n            update_option( 'wpide_csp_reports', $store, false );\n\n            return new WP_REST_Response( null, 204 );\n        },\n    ) );\n} );\n\nadd_action( 'admin_menu', function () use ( $wpide_csp_policy ) {\n    add_management_page( 'CSP Reports', 'CSP Reports', 'manage_options', 'wpide-csp-reports', function () use ( $wpide_csp_policy ) {\n        if ( isset( $_POST['wpide_csp_clear'] ) && check_admin_referer( 'wpide_csp_clear' ) ) {\n            delete_option( 'wpide_csp_reports' );\n        }\n\n        $store   = (array) get_option( 'wpide_csp_reports', array() );\n        $suggest = $wpide_csp_policy;\n\n        echo '<div class=\"wrap\"><h1>CSP Reports</h1><table class=\"widefat striped\"><thead><tr><th>Directive</th><th>Blocked source</th><th>Reports</th><th>Last seen</th></tr></thead><tbody>';\n        if ( ! $store ) {\n            echo '<tr><td colspan=\"4\">No violations reported yet.</td></tr>';\n        }\n        foreach ( $store as $key => $row ) {\n            list( $directive, $source ) = explode( '|', $key, 2 );\n            $target = isset( $suggest[ $directive ] ) ? $directive : 'default-src';\n            if ( false === strpos( $suggest[ $target ], $source ) && in_array( $source, array( 'inline', 'eval', 'data', 'blob' ), true ) === false ) {\n                $suggest[ $target ] .= ' ' . $source;\n            }\n            printf( '<tr><td><code>%s</code></td><td>%s</td><td>%d</td><td>%s</td></tr>', esc_html( $directive ), esc_html( $source ), (int) $row['count'], esc_html( human_time_diff( (int) $row['last'] ) . ' ago' ) );\n        }\n        echo '</tbody></table><h2>Suggested policy</h2><textarea readonly rows=\"6\" class=\"large-text code\">';\n        $lines = array();\n        foreach ( $suggest as $directive => $sources ) {\n            $lines[] = $directive . ' ' . $sources;\n        }\n        echo esc_textarea( implode( '; ', $lines ) );\n        echo '</textarea><form method=\"post\">';\n        wp_nonce_field( 'wpide_csp_clear' );\n        submit_button( 'Clear reports', 'secondary', 'wpide_csp_clear' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Let it run for a few days while you browse the whole site, then copy the suggested policy into your security headers; reports are stored in one capped option."
        },
        {
            "id": "sec-csp-script-nonces",
            "version": 1,
            "title": "Nonce-Based Content Security Policy for Scripts",
            "description": "Adds a fresh random nonce to every script WordPress prints and sends a strict, nonce-based script policy (report-only by default), the modern way to block injected scripts.",
            "category": "security",
            "tags": [
                "csp",
                "headers",
                "scripts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Switch to true to enforce the policy once nothing legitimate is reported.\n$wpide_nonce_enforce = false;\n\n$wpide_csp_nonce = function () {\n    static $nonce = null;\n\n    if ( null === $nonce ) {\n        $nonce = rtrim( strtr( base64_encode( random_bytes( 18 ) ), '+/', '-_' ), '=' );\n    }\n\n    return $nonce;\n};\n\n$wpide_add_nonce = function ( $attributes ) use ( $wpide_csp_nonce ) {\n    if ( is_array( $attributes ) && ! isset( $attributes['nonce'] ) ) {\n        $attributes['nonce'] = $wpide_csp_nonce();\n    }\n\n    return $attributes;\n};\n\nadd_filter( 'wp_script_attributes', $wpide_add_nonce );\nadd_filter( 'wp_inline_script_attributes', $wpide_add_nonce );\n\nadd_action( 'send_headers', function () use ( $wpide_csp_nonce, $wpide_nonce_enforce ) {\n    if ( headers_sent() ) {\n        return;\n    }\n\n    $policy = sprintf( \"script-src 'nonce-%s' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'self'\", $wpide_csp_nonce() );\n    header( ( $wpide_nonce_enforce ? 'Content-Security-Policy: ' : 'Content-Security-Policy-Report-Only: ' ) . $policy );\n} );",
            "requires": [],
            "notes": "Scripts hard-coded into theme templates do not get the nonce and full-page caches reuse it, so check the console with your cache active before setting $wpide_nonce_enforce to true."
        },
        {
            "id": "sec-admin-login-headers",
            "version": 1,
            "title": "Security Headers for wp-admin and the Login Page",
            "description": "Sends nosniff, a strict referrer policy, a restrictive permissions policy and a cross-origin opener policy on dashboard and login pages, which front-end header snippets do not cover.",
            "category": "security",
            "tags": [
                "headers",
                "admin",
                "login"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_admin_headers = function () {\n    if ( headers_sent() ) {\n        return;\n    }\n\n    header( 'X-Content-Type-Options: nosniff' );\n    header( 'Referrer-Policy: same-origin' );\n    header( 'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()' );\n    header( 'Cross-Origin-Opener-Policy: same-origin-allow-popups' );\n};\n\nadd_action( 'admin_init', $wpide_admin_headers, 1 );\nadd_action( 'login_init', $wpide_admin_headers, 1 );",
            "requires": [],
            "notes": "WordPress already sends X-Frame-Options on these screens; this adds the remaining common headers."
        },
        {
            "id": "sec-cross-origin-isolation-headers",
            "version": 1,
            "title": "Cross-Origin Opener Policy and Related Isolation Headers",
            "description": "Sends Cross-Origin-Opener-Policy, Origin-Agent-Cluster and X-Permitted-Cross-Domain-Policies headers on public pages, which stop other sites you link to or that open yours from reaching into your pages.",
            "category": "security",
            "tags": [
                "headers",
                "coop",
                "isolation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// 'same-origin-allow-popups' is safe for most sites; 'same-origin' is stricter.\n$wpide_coop = 'same-origin-allow-popups';\n\nadd_action( 'send_headers', function () use ( $wpide_coop ) {\n    if ( headers_sent() ) {\n        return;\n    }\n\n    header( 'Cross-Origin-Opener-Policy: ' . $wpide_coop );\n    header( 'Origin-Agent-Cluster: ?1' );\n    header( 'X-Permitted-Cross-Domain-Policies: none' );\n} );",
            "requires": [],
            "notes": "same-origin-allow-popups keeps payment and social login popups working; switch to same-origin only if you use none."
        },
        {
            "id": "sec-headers-site-health",
            "version": 1,
            "title": "Site Health Check for Security Headers",
            "description": "Adds a Site Health test that loads your homepage and reports which common security headers (HSTS, CSP, nosniff, framing, referrer and permissions policies) are missing.",
            "category": "security",
            "tags": [
                "site-health",
                "headers",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'site_status_tests', function ( $tests ) {\n    $tests['direct']['wpide_security_headers'] = array(\n        'label' => 'Security headers',\n        'test'  => function () {\n            $headers = get_transient( 'wpide_sh_headers' );\n\n            if ( false === $headers ) {\n                $response = wp_remote_get( home_url( '/' ), array( 'timeout' => 8, 'redirection' => 2, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ) ) );\n                $headers  = is_wp_error( $response ) ? array() : array_change_key_case( wp_remote_retrieve_headers( $response )->getAll(), CASE_LOWER );\n                set_transient( 'wpide_sh_headers', $headers, 12 * HOUR_IN_SECONDS );\n            }\n\n            $wanted = array(\n                'x-content-type-options'  => 'X-Content-Type-Options',\n                'referrer-policy'         => 'Referrer-Policy',\n                'permissions-policy'      => 'Permissions-Policy',\n                'content-security-policy' => 'Content-Security-Policy',\n            );\n            if ( is_ssl() || 0 === strpos( home_url(), 'https:' ) ) {\n                $wanted['strict-transport-security'] = 'Strict-Transport-Security';\n            }\n\n            $missing = array();\n            foreach ( $wanted as $key => $label ) {\n                if ( empty( $headers[ $key ] ) && ! ( 'content-security-policy' === $key && ! empty( $headers['content-security-policy-report-only'] ) ) ) {\n                    $missing[] = $label;\n                }\n            }\n            $csp = isset( $headers['content-security-policy'] ) ? implode( ' ', (array) $headers['content-security-policy'] ) : '';\n            if ( empty( $headers['x-frame-options'] ) && false === strpos( $csp, 'frame-ancestors' ) ) {\n                $missing[] = 'X-Frame-Options (or CSP frame-ancestors)';\n            }\n\n            return array(\n                'label'       => $missing ? 'Some security headers are missing' : 'Security headers are in place',\n                'status'      => $missing ? 'recommended' : 'good',\n                'badge'       => array( 'label' => 'Security', 'color' => 'blue' ),\n                'description' => $missing\n                    ? '<p>' . esc_html( 'Your homepage does not send: ' . implode( ', ', $missing ) . '.' ) . '</p>'\n                    : '<p>Your homepage sends the common security headers.</p>',\n                'test'        => 'wpide_security_headers',\n            );\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "The result is cached for 12 hours; headers added by your server or CDN are detected too."
        },
        {
            "id": "sec-exposed-files-site-health",
            "version": 1,
            "title": "Site Health Check for Publicly Exposed Sensitive Files",
            "description": "Adds a Site Health test that checks whether files such as .env, debug.log, .git/HEAD and wp-config.php backups can be downloaded from your site by anyone, and flags them as critical.",
            "category": "security",
            "tags": [
                "site-health",
                "files",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Paths (relative to the site root) that should never be public.\n$wpide_sensitive_paths = array( '.env', '.git/HEAD', 'wp-content/debug.log', 'wp-config.php.bak', 'wp-config.php.old', 'wp-config.php~', 'wp-config.bak', 'backup.sql', 'database.sql', '.htpasswd', 'phpinfo.php' );\n\nadd_filter( 'site_status_tests', function ( $tests ) use ( $wpide_sensitive_paths ) {\n    $tests['direct']['wpide_exposed_files'] = array(\n        'label' => 'Exposed sensitive files',\n        'test'  => function () use ( $wpide_sensitive_paths ) {\n            $exposed = get_transient( 'wpide_exposed_files' );\n\n            if ( false === $exposed ) {\n                $exposed = array();\n                foreach ( $wpide_sensitive_paths as $path ) {\n                    $response = wp_remote_get( site_url( '/' . $path ), array(\n                        'timeout'     => 4,\n                        'redirection' => 0,\n                        'sslverify'   => apply_filters( 'https_local_ssl_verify', false ),\n                        'headers'     => array( 'Range' => 'bytes=0-255' ),\n                    ) );\n                    $code = (int) wp_remote_retrieve_response_code( $response );\n                    $type = (string) wp_remote_retrieve_header( $response, 'content-type' );\n                    if ( in_array( $code, array( 200, 206 ), true ) && false === stripos( $type, 'text/html' ) && '' !== wp_remote_retrieve_body( $response ) ) {\n                        $exposed[] = $path;\n                    }\n                }\n                set_transient( 'wpide_exposed_files', $exposed, 12 * HOUR_IN_SECONDS );\n            }\n\n            return array(\n                'label'       => $exposed ? 'Sensitive files can be downloaded publicly' : 'No sensitive files are exposed',\n                'status'      => $exposed ? 'critical' : 'good',\n                'badge'       => array( 'label' => 'Security', 'color' => $exposed ? 'red' : 'blue' ),\n                'description' => $exposed\n                    ? '<p>' . esc_html( 'Anyone can download: ' . implode( ', ', $exposed ) . '. Delete these files or block access to them on your server.' ) . '</p>'\n                    : '<p>None of the common sensitive files could be downloaded.</p>',\n                'test'        => 'wpide_exposed_files',\n            );\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Fix problems by deleting the files or blocking them in your server configuration; results are cached for 12 hours."
        },
        {
            "id": "sec-weak-setup-site-health",
            "version": 1,
            "title": "Site Health Checks for Weak Security Settings",
            "description": "Adds Site Health tests for common weak spots: default or reused security keys, an account named \"admin\", the file editor left enabled, and a world-readable wp-config.php file.",
            "category": "security",
            "tags": [
                "site-health",
                "hardening",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'site_status_tests', function ( $tests ) {\n    $result = function ( $id, $ok, $good, $bad, $fix, $level = 'recommended' ) {\n        return array(\n            'label'       => $ok ? $good : $bad,\n            'status'      => $ok ? 'good' : $level,\n            'badge'       => array( 'label' => 'Security', 'color' => $ok ? 'blue' : 'orange' ),\n            'description' => '<p>' . esc_html( $ok ? $good . '.' : $fix ) . '</p>',\n            'test'        => $id,\n        );\n    };\n\n    $tests['direct']['wpide_salts'] = array(\n        'label' => 'Security keys',\n        'test'  => function () use ( $result ) {\n            $values = array();\n            foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ) as $name ) {\n                $values[] = defined( $name ) ? (string) constant( $name ) : '';\n            }\n            $weak = array_filter( $values, function ( $v ) {\n                return strlen( $v ) < 32 || false !== stripos( $v, 'put your unique phrase here' );\n            } );\n            $ok = ! $weak && count( array_unique( $values ) ) === count( $values );\n\n            return $result( 'wpide_salts', $ok, 'Security keys are unique and long', 'Security keys are weak or missing', 'Generate new keys at api.wordpress.org/secret-key/1.1/salt/ and replace the ones in wp-config.php. Everyone will be logged out once.', 'critical' );\n        },\n    );\n\n    $tests['direct']['wpide_admin_user'] = array(\n        'label' => 'Admin username',\n        'test'  => function () use ( $result ) {\n            return $result( 'wpide_admin_user', ! username_exists( 'admin' ), 'No account uses the username \"admin\"', 'An account uses the username \"admin\"', 'Attackers try \"admin\" first. Create a new administrator with a different username, log in with it and delete the old account, attributing its content to the new one.' );\n        },\n    );\n\n    $tests['direct']['wpide_file_edit'] = array(\n        'label' => 'File editor',\n        'test'  => function () use ( $result ) {\n            $ok = ( defined( 'DISALLOW_FILE_EDIT' ) && DISALLOW_FILE_EDIT ) || ( defined( 'DISALLOW_FILE_MODS' ) && DISALLOW_FILE_MODS );\n            return $result( 'wpide_file_edit', $ok, 'The theme and plugin file editor is disabled', 'The theme and plugin file editor is enabled', \"Add define( 'DISALLOW_FILE_EDIT', true ); to wp-config.php so a stolen admin login cannot be used to edit PHP files.\" );\n        },\n    );\n\n    $tests['direct']['wpide_config_perms'] = array(\n        'label' => 'wp-config.php permissions',\n        'test'  => function () use ( $result ) {\n            $file = file_exists( ABSPATH . 'wp-config.php' ) ? ABSPATH . 'wp-config.php' : dirname( ABSPATH ) . '/wp-config.php';\n            $ok   = ! file_exists( $file ) || 0 === ( fileperms( $file ) & 0007 );\n            return $result( 'wpide_config_perms', $ok, 'wp-config.php is not readable by other users on the server', 'wp-config.php can be read by any user on the server', 'Change the file permissions of wp-config.php to 640 or 600 so other accounts on a shared server cannot read your database password.' );\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "Each problem appears as its own recommendation with a short explanation of how to fix it."
        },
        {
            "id": "sec-reauth-sensitive-screens",
            "version": 1,
            "title": "Ask for the Password Again Before Sensitive Admin Screens",
            "description": "Requires administrators to log in again before opening plugin, theme, user and update screens when their login is older than 30 minutes, so a hijacked session cannot quietly install code or add users.",
            "category": "security",
            "tags": [
                "login",
                "sessions",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Maximum age of the login, and the admin screens that need a fresh one.\n$wpide_reauth_minutes = 30;\n$wpide_reauth_screens = array( 'plugins.php', 'plugin-install.php', 'plugin-editor.php', 'theme-install.php', 'theme-editor.php', 'user-new.php', 'update-core.php', 'users.php' );\n\nforeach ( $wpide_reauth_screens as $wpide_screen ) {\n    add_action( 'load-' . $wpide_screen, function () use ( $wpide_reauth_minutes ) {\n        if ( wp_doing_ajax() || 'POST' === ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : 'GET' ) ) {\n            return;\n        }\n\n        $token   = wp_get_session_token();\n        $session = $token ? WP_Session_Tokens::get_instance( get_current_user_id() )->get( $token ) : null;\n\n        if ( ! empty( $session['login'] ) && ( time() - (int) $session['login'] ) <= $wpide_reauth_minutes * MINUTE_IN_SECONDS ) {\n            return;\n        }\n\n        $here = set_url_scheme( 'http://' . ( isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '' ) . ( isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '' ) );\n        wp_safe_redirect( wp_login_url( $here, true ) );\n        exit;\n    } );\n}",
            "requires": [],
            "notes": "Adjust $wpide_reauth_minutes and the list of screens; after logging in again the user lands back on the screen they asked for."
        },
        {
            "id": "sec-log-out-everyone",
            "version": 1,
            "title": "\"Log Out Everyone\" Button for Emergencies",
            "description": "Adds a button under Tools > Log Out Everyone that ends every login session on the site except your own, useful after a suspected break-in or a staff change.",
            "category": "security",
            "tags": [
                "sessions",
                "emergency",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Log Out Everyone', 'Log Out Everyone', 'manage_options', 'wpide-logout-all', function () {\n        if ( isset( $_POST['wpide_logout_all'] ) && check_admin_referer( 'wpide_logout_all' ) && current_user_can( 'manage_options' ) ) {\n            $count = 0;\n            $ids   = get_users( array( 'meta_key' => 'session_tokens', 'fields' => 'ID', 'number' => -1 ) );\n\n            foreach ( $ids as $id ) {\n                $manager = WP_Session_Tokens::get_instance( (int) $id );\n                if ( get_current_user_id() === (int) $id ) {\n                    $manager->destroy_others( wp_get_session_token() );\n                } else {\n                    $manager->destroy_all();\n                    $count++;\n                }\n            }\n\n            printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Logged out %d user(s) and your other sessions.', $count ) ) );\n        }\n\n        echo '<div class=\"wrap\"><h1>Log Out Everyone</h1><p>End all login sessions on this site except the one you are using now.</p>';\n        echo '<form method=\"post\" onsubmit=\"return confirm(\\'Log out every user now?\\');\">';\n        wp_nonce_field( 'wpide_logout_all' );\n        submit_button( 'Log out everyone', 'primary', 'wpide_logout_all' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Users only need to log in again, so also change passwords if you suspect they were stolen."
        },
        {
            "id": "sec-active-sessions-page",
            "version": 1,
            "title": "Active Login Sessions Overview",
            "description": "Adds Users > Active Sessions, listing everyone who is currently logged in with their login time, IP address and browser, and lets you end a user's sessions with one click.",
            "category": "security",
            "tags": [
                "sessions",
                "users",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_users_page( 'Active Sessions', 'Active Sessions', 'edit_users', 'wpide-sessions', function () {\n        if ( isset( $_POST['wpide_end_user'] ) && check_admin_referer( 'wpide_end_sessions' ) && current_user_can( 'edit_users' ) ) {\n            $target = absint( $_POST['wpide_end_user'] );\n            if ( $target && $target !== get_current_user_id() && current_user_can( 'edit_user', $target ) ) {\n                WP_Session_Tokens::get_instance( $target )->destroy_all();\n                echo '<div class=\"notice notice-success\"><p>Sessions ended.</p></div>';\n            }\n        }\n\n        $rows = array();\n        foreach ( get_users( array( 'meta_key' => 'session_tokens', 'number' => 200, 'fields' => array( 'ID', 'user_login' ) ) ) as $user ) {\n            foreach ( WP_Session_Tokens::get_instance( (int) $user->ID )->get_all() as $session ) {\n                $rows[] = array( $user, $session );\n            }\n        }\n        usort( $rows, function ( $a, $b ) {\n            return (int) $b[1]['login'] - (int) $a[1]['login'];\n        } );\n\n        echo '<div class=\"wrap\"><h1>Active Sessions</h1><table class=\"widefat striped\"><thead><tr><th>User</th><th>Logged in</th><th>Expires</th><th>IP address</th><th>Browser</th><th></th></tr></thead><tbody>';\n        if ( ! $rows ) {\n            echo '<tr><td colspan=\"6\">No active sessions.</td></tr>';\n        }\n        foreach ( $rows as list( $user, $session ) ) {\n            echo '<tr><td>' . esc_html( $user->user_login ) . '</td>';\n            echo '<td>' . esc_html( human_time_diff( (int) $session['login'] ) . ' ago' ) . '</td>';\n            echo '<td>' . esc_html( wp_date( 'Y-m-d H:i', (int) $session['expiration'] ) ) . '</td>';\n            echo '<td>' . esc_html( isset( $session['ip'] ) ? $session['ip'] : '' ) . '</td>';\n            echo '<td>' . esc_html( isset( $session['ua'] ) ? wp_html_excerpt( $session['ua'], 60, '...' ) : '' ) . '</td><td>';\n            if ( (int) $user->ID !== get_current_user_id() ) {\n                echo '<form method=\"post\">';\n                wp_nonce_field( 'wpide_end_sessions' );\n                echo '<button class=\"button\" name=\"wpide_end_user\" value=\"' . (int) $user->ID . '\">End sessions</button></form>';\n            }\n            echo '</td></tr>';\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Shows up to 200 users with active sessions, most recent logins first."
        },
        {
            "id": "sec-hide-logins-in-author-urls",
            "version": 1,
            "title": "Stop Author URLs From Revealing Login Names",
            "description": "Gives new users an author URL slug based on their user ID instead of their login name, and adds a one-click fix under Users for existing accounts whose author URL shows their username.",
            "category": "security",
            "tags": [
                "user-enumeration",
                "authors",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_safe_slug = function ( $user ) {\n    $slug = sanitize_title( $user->display_name );\n\n    if ( ! $slug || $slug === sanitize_title( $user->user_login ) ) {\n        $slug = 'author';\n    }\n\n    return $slug . '-' . $user->ID;\n};\n\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_safe_slug ) {\n    $user = get_userdata( $user_id );\n\n    if ( $user && $user->user_nicename === sanitize_title( $user->user_login ) ) {\n        wp_update_user( array( 'ID' => $user_id, 'user_nicename' => $wpide_safe_slug( $user ) ) );\n    }\n}, 20 );\n\nadd_action( 'admin_notices', function () use ( $wpide_safe_slug ) {\n    global $wpdb;\n\n    $screen = get_current_screen();\n\n    if ( ! $screen || 'users' !== $screen->id || ! current_user_can( 'edit_users' ) ) {\n        return;\n    }\n\n    if ( isset( $_POST['wpide_fix_slugs'] ) && check_admin_referer( 'wpide_fix_slugs' ) ) {\n        $fixed = 0;\n        foreach ( $wpdb->get_col( \"SELECT ID FROM {$wpdb->users} WHERE user_nicename = user_login LIMIT 500\" ) as $id ) {\n            $user = get_userdata( (int) $id );\n            if ( $user && ! is_wp_error( wp_update_user( array( 'ID' => $user->ID, 'user_nicename' => $wpide_safe_slug( $user ) ) ) ) ) {\n                $fixed++;\n            }\n        }\n        printf( '<div class=\"notice notice-success\"><p>%s</p></div>', esc_html( sprintf( 'Updated the author URL of %d user(s).', $fixed ) ) );\n        return;\n    }\n\n    $count = (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->users} WHERE user_nicename = user_login\" );\n\n    if ( $count ) {\n        echo '<div class=\"notice notice-warning\"><form method=\"post\"><p>' . esc_html( sprintf( '%d user(s) have an author URL that shows their login name.', $count ) ) . ' ';\n        wp_nonce_field( 'wpide_fix_slugs' );\n        echo '<button class=\"button\" name=\"wpide_fix_slugs\" value=\"1\">Hide login names in author URLs</button></p></form></div>';\n    }\n} );",
            "requires": [],
            "notes": "Changing a slug changes that author's archive URL, so old author links will no longer work."
        },
        {
            "id": "sec-reject-uploads-with-php",
            "version": 1,
            "title": "Reject Uploads That Contain PHP Code",
            "description": "Scans every uploaded file that is not a PHP file for hidden PHP code (such as an image with a PHP payload) and refuses the upload if any is found.",
            "category": "security",
            "tags": [
                "uploads",
                "malware",
                "files"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_handle_upload_prefilter', function ( $file ) {\n    if ( ! empty( $file['error'] ) || empty( $file['tmp_name'] ) || ! is_readable( $file['tmp_name'] ) ) {\n        return $file;\n    }\n\n    $handle = fopen( $file['tmp_name'], 'rb' );\n\n    if ( ! $handle ) {\n        return $file;\n    }\n\n    $chunk = (string) fread( $handle, 2 * MB_IN_BYTES );\n    fclose( $handle );\n\n    // \"<?=\" is only checked in text files: in binary data those three bytes can occur by chance.\n    $binary  = (bool) preg_match( '/\\.(jpe?g|png|gif|webp|avif|heic|mp[34]|m4a|mov|webm|zip|pdf|docx?|xlsx?|pptx?)$/i', (string) $file['name'] );\n    $pattern = $binary ? '/<\\?php\\b|<script\\s+language\\s*=\\s*[\"\\']?php/i' : '/<\\?(php\\b|=)|<script\\s+language\\s*=\\s*[\"\\']?php/i';\n\n    if ( preg_match( $pattern, $chunk ) ) {\n        $file['error'] = 'This file contains code that is not allowed in uploads.';\n    }\n\n    return $file;\n}, 5 );",
            "requires": [],
            "notes": "Only the first 2 MB of each file is scanned to keep uploads fast."
        },
        {
            "id": "sec-block-php-in-uploads-htaccess",
            "version": 1,
            "title": "Block PHP Execution in the Uploads Folder (Apache)",
            "description": "Adds Tools > Uploads Protection with a button that writes .htaccess rules to your uploads folder so PHP files there can never be run, even if one slips through an upload.",
            "category": "security",
            "tags": [
                "uploads",
                "htaccess",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Uploads Protection', 'Uploads Protection', 'manage_options', 'wpide-uploads-protection', function () {\n        require_once ABSPATH . 'wp-admin/includes/misc.php';\n\n        $uploads = wp_get_upload_dir();\n        $file    = trailingslashit( $uploads['basedir'] ) . '.htaccess';\n        $marker  = 'WPIDE: no PHP in uploads';\n\n        if ( isset( $_POST['wpide_protect_uploads'] ) && check_admin_referer( 'wpide_protect_uploads' ) && current_user_can( 'manage_options' ) ) {\n            $ok = insert_with_markers( $file, $marker, array(\n                '<FilesMatch \"\\.(?i:php\\d?|phtml|phar|pht|phps)$\">',\n                '    Require all denied',\n                '</FilesMatch>',\n            ) );\n            printf( '<div class=\"notice notice-%s\"><p>%s</p></div>', $ok ? 'success' : 'error', $ok ? 'Rules written.' : 'The .htaccess file could not be written. Check folder permissions.' );\n        }\n\n        $active = in_array( 'Require all denied', extract_from_markers( $file, $marker ), true );\n        echo '<div class=\"wrap\"><h1>Uploads Protection</h1>';\n        echo '<p>Status: <strong>' . ( $active ? 'PHP execution is blocked in uploads.' : 'Not protected yet.' ) . '</strong></p>';\n        echo '<form method=\"post\">';\n        wp_nonce_field( 'wpide_protect_uploads' );\n        submit_button( $active ? 'Write the rules again' : 'Block PHP in uploads', 'primary', 'wpide_protect_uploads' );\n        echo '</form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Works on Apache and LiteSpeed; on Nginx add a location rule for /wp-content/uploads/ that denies .php files instead."
        },
        {
            "id": "sec-random-upload-file-names",
            "version": 1,
            "title": "Add a Random Suffix to Uploaded File Names",
            "description": "Appends a short random string to the name of every uploaded file (for example report-k3f9q2.pdf), so private documents cannot be found by guessing their URL.",
            "category": "security",
            "tags": [
                "uploads",
                "files",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extensions to rename; leave empty to rename every upload.\n$wpide_random_types = array( 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'zip', 'txt' );\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) use ( $wpide_random_types ) {\n    if ( ! empty( $file['error'] ) || empty( $file['name'] ) ) {\n        return $file;\n    }\n\n    $info = pathinfo( $file['name'] );\n    $ext  = isset( $info['extension'] ) ? strtolower( $info['extension'] ) : '';\n\n    if ( $wpide_random_types && ! in_array( $ext, $wpide_random_types, true ) ) {\n        return $file;\n    }\n\n    $file['name'] = $info['filename'] . '-' . strtolower( wp_generate_password( 6, false ) ) . ( $ext ? '.' . $ext : '' );\n\n    return $file;\n} );",
            "requires": [],
            "notes": "Set $wpide_random_types to limit it to documents if you want image file names to stay clean for SEO."
        },
        {
            "id": "sec-activity-log-table",
            "version": 1,
            "title": "Activity Log in Its Own Database Table",
            "description": "Records logins, failed logins, content changes, plugin, theme and user changes and edits to key settings in a dedicated database table, with a searchable, paginated log under Tools > Activity Log and automatic cleanup.",
            "category": "security",
            "tags": [
                "audit",
                "logging",
                "activity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days to keep log entries, and settings whose changes are logged.\n$wpide_log_days    = 90;\n$wpide_log_options = array( 'siteurl', 'home', 'admin_email', 'users_can_register', 'default_role', 'blog_public', 'permalink_structure' );\n\n$wpide_log_table = function () {\n    global $wpdb;\n    return $wpdb->prefix . 'wpide_activity';\n};\n\n$wpide_log = function ( $action, $object = '', $details = '', $user_id = null ) use ( $wpide_log_table ) {\n    global $wpdb;\n\n    if ( '1' !== get_option( 'wpide_activity_db' ) ) {\n        return;\n    }\n\n    $user_id = null === $user_id ? get_current_user_id() : (int) $user_id;\n    $user    = $user_id ? get_userdata( $user_id ) : false;\n\n    $wpdb->insert( $wpide_log_table(), array(\n        'created'    => current_time( 'mysql', true ),\n        'user_id'    => $user_id,\n        'user_login' => $user ? $user->user_login : '',\n        'ip'         => isset( $_SERVER['REMOTE_ADDR'] ) ? substr( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ), 0, 45 ) : '',\n        'action'     => substr( $action, 0, 40 ),\n        'object'     => substr( (string) $object, 0, 190 ),\n        'details'    => substr( (string) $details, 0, 1000 ),\n    ) );\n};\n\nadd_action( 'admin_init', function () use ( $wpide_log_table ) {\n    global $wpdb;\n\n    if ( '1' === get_option( 'wpide_activity_db' ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    require_once ABSPATH . 'wp-admin/includes/upgrade.php';\n    dbDelta( 'CREATE TABLE ' . $wpide_log_table() . \" (\n        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,\n        created datetime NOT NULL,\n        user_id bigint(20) unsigned NOT NULL DEFAULT 0,\n        user_login varchar(60) NOT NULL DEFAULT '',\n        ip varchar(45) NOT NULL DEFAULT '',\n        action varchar(40) NOT NULL,\n        object varchar(190) NOT NULL DEFAULT '',\n        details text NOT NULL,\n        PRIMARY KEY  (id),\n        KEY created (created),\n        KEY action (action)\n    ) \" . $wpdb->get_charset_collate() . ';' );\n    update_option( 'wpide_activity_db', '1' );\n} );\n\nadd_action( 'wp_login', function ( $login, $user ) use ( $wpide_log ) {\n    $wpide_log( 'login', $login, '', $user->ID );\n}, 10, 2 );\nadd_action( 'wp_login_failed', function ( $login ) use ( $wpide_log ) {\n    $wpide_log( 'login_failed', sanitize_user( $login ), '', 0 );\n} );\nadd_action( 'transition_post_status', function ( $new, $old, $post ) use ( $wpide_log ) {\n    if ( $new === $old || wp_is_post_revision( $post ) || in_array( $new, array( 'auto-draft', 'inherit' ), true ) || ! post_type_exists( $post->post_type ) || ! is_post_type_viewable( $post->post_type ) ) {\n        return;\n    }\n    $wpide_log( 'post_' . $new, $post->post_type . ' #' . $post->ID, $post->post_title );\n}, 10, 3 );\nadd_action( 'before_delete_post', function ( $post_id, $post ) use ( $wpide_log ) {\n    if ( $post && is_post_type_viewable( $post->post_type ) && 'revision' !== $post->post_type ) {\n        $wpide_log( 'post_deleted', $post->post_type . ' #' . $post_id, $post->post_title );\n    }\n}, 10, 2 );\nforeach ( array( 'activated_plugin' => 'plugin_activated', 'deactivated_plugin' => 'plugin_deactivated', 'deleted_plugin' => 'plugin_deleted' ) as $wpide_hook => $wpide_action ) {\n    add_action( $wpide_hook, function ( $plugin ) use ( $wpide_log, $wpide_action ) {\n        $wpide_log( $wpide_action, $plugin );\n    } );\n}\nadd_action( 'switch_theme', function ( $name ) use ( $wpide_log ) {\n    $wpide_log( 'theme_switched', $name );\n} );\nadd_action( 'upgrader_process_complete', function ( $upgrader, $extra ) use ( $wpide_log ) {\n    if ( ! empty( $extra['type'] ) && ! empty( $extra['action'] ) ) {\n        $items = isset( $extra['plugins'] ) ? $extra['plugins'] : ( isset( $extra['themes'] ) ? $extra['themes'] : array() );\n        $wpide_log( $extra['type'] . '_' . $extra['action'], implode( ', ', (array) $items ) );\n    }\n}, 10, 2 );\nadd_action( 'user_register', function ( $user_id ) use ( $wpide_log ) {\n    $user = get_userdata( $user_id );\n    $wpide_log( 'user_created', $user ? $user->user_login : '#' . $user_id, $user ? implode( ',', $user->roles ) : '' );\n} );\nadd_action( 'delete_user', function ( $user_id ) use ( $wpide_log ) {\n    $user = get_userdata( $user_id );\n    $wpide_log( 'user_deleted', $user ? $user->user_login : '#' . $user_id );\n} );\nadd_action( 'set_user_role', function ( $user_id, $role, $old ) use ( $wpide_log ) {\n    if ( $old ) {\n        $user = get_userdata( $user_id );\n        $wpide_log( 'role_changed', $user ? $user->user_login : '#' . $user_id, implode( ',', (array) $old ) . ' -> ' . $role );\n    }\n}, 10, 3 );\nadd_action( 'updated_option', function ( $option, $old, $new ) use ( $wpide_log, $wpide_log_options ) {\n    if ( in_array( $option, $wpide_log_options, true ) ) {\n        $wpide_log( 'setting_changed', $option, wp_json_encode( $old ) . ' -> ' . wp_json_encode( $new ) );\n    }\n}, 10, 3 );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_activity_cleanup' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_activity_cleanup' );\n    }\n} );\nadd_action( 'wpide_activity_cleanup', function () use ( $wpide_log_table, $wpide_log_days ) {\n    global $wpdb;\n    if ( '1' === get_option( 'wpide_activity_db' ) ) {\n        $wpdb->query( $wpdb->prepare( 'DELETE FROM ' . $wpide_log_table() . ' WHERE created < %s LIMIT 10000', gmdate( 'Y-m-d H:i:s', time() - $wpide_log_days * DAY_IN_SECONDS ) ) );\n    }\n} );\n\nadd_action( 'admin_menu', function () use ( $wpide_log_table ) {\n    add_management_page( 'Activity Log', 'Activity Log', 'manage_options', 'wpide-activity-table', function () use ( $wpide_log_table ) {\n        global $wpdb;\n\n        $per    = 50;\n        $paged  = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 );\n        $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : '';\n        $rows   = array();\n        $total  = 0;\n\n        if ( '1' === get_option( 'wpide_activity_db' ) ) {\n            $where = '1=1';\n            $args  = array();\n            if ( '' !== $search ) {\n                $like  = '%' . $wpdb->esc_like( $search ) . '%';\n                $where = '(user_login LIKE %s OR action LIKE %s OR object LIKE %s OR details LIKE %s OR ip LIKE %s)';\n                $args  = array( $like, $like, $like, $like, $like );\n            }\n            $table = $wpide_log_table();\n            $total = (int) $wpdb->get_var( $args ? $wpdb->prepare( \"SELECT COUNT(*) FROM $table WHERE $where\", $args ) : \"SELECT COUNT(*) FROM $table\" );\n            $rows  = $wpdb->get_results( $wpdb->prepare( \"SELECT * FROM $table WHERE $where ORDER BY id DESC LIMIT %d OFFSET %d\", array_merge( $args, array( $per, ( $paged - 1 ) * $per ) ) ) );\n        }\n\n        echo '<div class=\"wrap\"><h1>Activity Log</h1><form method=\"get\"><input type=\"hidden\" name=\"page\" value=\"wpide-activity-table\">';\n        echo '<p class=\"search-box\"><label class=\"screen-reader-text\" for=\"wpide-log-search\">Search the log</label><input type=\"search\" id=\"wpide-log-search\" name=\"s\" value=\"' . esc_attr( $search ) . '\"> <button class=\"button\">Search</button></p></form>';\n        echo '<table class=\"widefat striped\"><thead><tr><th>Time (UTC)</th><th>User</th><th>Action</th><th>Object</th><th>Details</th><th>IP</th></tr></thead><tbody>';\n        if ( ! $rows ) {\n            echo '<tr><td colspan=\"6\">No entries.</td></tr>';\n        }\n        foreach ( $rows as $row ) {\n            printf( '<tr><td>%s</td><td>%s</td><td><code>%s</code></td><td>%s</td><td>%s</td><td>%s</td></tr>', esc_html( $row->created ), esc_html( $row->user_login ? $row->user_login : '-' ), esc_html( $row->action ), esc_html( $row->object ), esc_html( wp_html_excerpt( $row->details, 120, '...' ) ), esc_html( $row->ip ) );\n        }\n        echo '</tbody></table>';\n        $links = paginate_links( array( 'base' => add_query_arg( 'paged', '%#%' ), 'format' => '', 'current' => $paged, 'total' => max( 1, (int) ceil( $total / $per ) ) ) );\n        if ( $links ) {\n            echo '<div class=\"tablenav\"><div class=\"tablenav-pages\">' . wp_kses_post( $links ) . '</div></div>';\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Records are kept for $wpide_log_days days; the table is created the first time an administrator opens the dashboard."
        },
        {
            "id": "sec-password-history",
            "version": 1,
            "title": "Prevent Reusing Any of the Last 5 Passwords",
            "description": "Remembers the last five password hashes of each user and refuses a new password that matches one of them, on the profile screen and on password reset.",
            "category": "security",
            "tags": [
                "passwords",
                "policy",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of previous passwords that cannot be reused.\n$wpide_history_size = 5;\n\n$wpide_is_reused = function ( $user_id, $password ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user || '' === (string) $password ) {\n        return false;\n    }\n\n    $hashes   = (array) get_user_meta( $user_id, 'wpide_password_history', true );\n    $hashes[] = $user->user_pass;\n\n    foreach ( array_filter( $hashes ) as $hash ) {\n        if ( wp_check_password( $password, $hash, $user_id ) ) {\n            return true;\n        }\n    }\n\n    return false;\n};\n\n$wpide_remember = function ( $user_id, $old_hash ) use ( $wpide_history_size ) {\n    $hashes   = array_filter( (array) get_user_meta( $user_id, 'wpide_password_history', true ) );\n    $hashes[] = $old_hash;\n    update_user_meta( $user_id, 'wpide_password_history', array_slice( array_values( array_unique( $hashes ) ), -$wpide_history_size ) );\n};\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_is_reused ) {\n    if ( $update && ! empty( $user->user_pass ) && ! empty( $user->ID ) && $wpide_is_reused( $user->ID, $user->user_pass ) ) {\n        $errors->add( 'wpide_password_reused', '<strong>Error:</strong> Please choose a password you have not used recently.' );\n    }\n}, 10, 3 );\n\nadd_action( 'validate_password_reset', function ( $errors, $user ) use ( $wpide_is_reused ) {\n    $pass = isset( $_POST['pass1'] ) ? (string) wp_unslash( $_POST['pass1'] ) : '';\n    if ( $pass && $user instanceof WP_User && $wpide_is_reused( $user->ID, $pass ) ) {\n        $errors->add( 'wpide_password_reused', 'Please choose a password you have not used recently.' );\n    }\n}, 10, 2 );\n\nadd_action( 'profile_update', function ( $user_id, $old_data ) use ( $wpide_remember ) {\n    $user = get_userdata( $user_id );\n    if ( $user && $old_data && $old_data->user_pass && $user->user_pass !== $old_data->user_pass ) {\n        $wpide_remember( $user_id, $old_data->user_pass );\n    }\n}, 10, 2 );\n\nadd_action( 'password_reset', function ( $user ) use ( $wpide_remember ) {\n    $wpide_remember( $user->ID, $user->user_pass );\n} );",
            "requires": [],
            "notes": "Only hashes are stored, never the passwords themselves; history starts building from the next password change."
        },
        {
            "id": "sec-breached-password-check",
            "version": 1,
            "title": "Block Passwords Found in Known Data Breaches",
            "description": "Checks new passwords against the Have I Been Pwned database of breached passwords when users change or reset them, and asks for a different one if it appears there.",
            "category": "security",
            "tags": [
                "passwords",
                "breaches",
                "policy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_breach_count = function ( $password ) {\n    $hash   = strtoupper( sha1( (string) $password ) );\n    $prefix = substr( $hash, 0, 5 );\n    $cache  = 'wpide_hibp_' . $prefix;\n    $body   = get_transient( $cache );\n\n    if ( false === $body ) {\n        $response = wp_remote_get( 'https://api.pwnedpasswords.com/range/' . $prefix, array(\n            'timeout' => 4,\n            'headers' => array( 'Add-Padding' => 'true', 'User-Agent' => 'WordPress password check' ),\n        ) );\n        if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {\n            return 0;\n        }\n        $body = wp_remote_retrieve_body( $response );\n        set_transient( $cache, $body, 12 * HOUR_IN_SECONDS );\n    }\n\n    if ( preg_match( '/^' . substr( $hash, 5 ) . ':(\\d+)/m', $body, $m ) ) {\n        return (int) $m[1];\n    }\n\n    return 0;\n};\n\n$wpide_breach_message = 'This password has appeared in a data breach and is unsafe. Please choose a different one.';\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_breach_count, $wpide_breach_message ) {\n    if ( ! empty( $user->user_pass ) && $wpide_breach_count( $user->user_pass ) > 0 ) {\n        $errors->add( 'wpide_pwned', '<strong>Error:</strong> ' . $wpide_breach_message );\n    }\n}, 10, 3 );\n\nadd_action( 'validate_password_reset', function ( $errors ) use ( $wpide_breach_count, $wpide_breach_message ) {\n    $pass = isset( $_POST['pass1'] ) ? (string) wp_unslash( $_POST['pass1'] ) : '';\n    if ( $pass && $wpide_breach_count( $pass ) > 0 ) {\n        $errors->add( 'wpide_pwned', $wpide_breach_message );\n    }\n} );",
            "requires": [],
            "notes": "Only the first 5 characters of the password's SHA-1 hash are sent (k-anonymity), never the password; if the service cannot be reached, the password is allowed."
        },
        {
            "id": "sec-file-change-monitor",
            "version": 1,
            "title": "Daily Plugin and Theme File Change Monitor",
            "description": "Fingerprints every PHP file in your plugins and themes once a day and emails the administrator a list of files that were added, changed or removed outside of normal updates.",
            "category": "security",
            "tags": [
                "integrity",
                "files",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Folders to watch and the maximum number of files to fingerprint.\n$wpide_watch_dirs  = array( WP_PLUGIN_DIR, get_theme_root() );\n$wpide_watch_limit = 30000;\n\n$wpide_fingerprint = function () use ( $wpide_watch_dirs, $wpide_watch_limit ) {\n    $map = array();\n\n    foreach ( $wpide_watch_dirs as $dir ) {\n        if ( ! is_dir( $dir ) ) {\n            continue;\n        }\n        $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ) );\n        foreach ( $files as $file ) {\n            if ( count( $map ) >= $wpide_watch_limit ) {\n                break 2;\n            }\n            if ( $file->isFile() && preg_match( '/\\.(php|phtml|phar)$/i', $file->getFilename() ) && false === strpos( $file->getPathname(), '/node_modules/' ) ) {\n                $map[ str_replace( WP_CONTENT_DIR, '', $file->getPathname() ) ] = substr( md5_file( $file->getPathname() ), 0, 12 );\n            }\n        }\n    }\n\n    return $map;\n};\n\n$wpide_save_baseline = function ( $map ) {\n    update_option( 'wpide_file_baseline', base64_encode( gzcompress( wp_json_encode( $map ), 6 ) ), false );\n};\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_file_change_scan' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_file_change_scan' );\n    }\n} );\n\nadd_action( 'upgrader_process_complete', function () use ( $wpide_fingerprint, $wpide_save_baseline ) {\n    $wpide_save_baseline( $wpide_fingerprint() );\n}, 99 );\n\nadd_action( 'wpide_file_change_scan', function () use ( $wpide_fingerprint, $wpide_save_baseline ) {\n    $stored = get_option( 'wpide_file_baseline' );\n    $now    = $wpide_fingerprint();\n    $wpide_save_baseline( $now );\n\n    if ( ! $stored ) {\n        return;\n    }\n\n    $old = json_decode( (string) gzuncompress( base64_decode( $stored ) ), true );\n    if ( ! is_array( $old ) ) {\n        return;\n    }\n\n    $added   = array_keys( array_diff_key( $now, $old ) );\n    $removed = array_keys( array_diff_key( $old, $now ) );\n    $changed = array_keys( array_diff_assoc( array_intersect_key( $now, $old ), $old ) );\n\n    if ( ! $added && ! $removed && ! $changed ) {\n        return;\n    }\n\n    $lines = array();\n    foreach ( array( 'Added' => $added, 'Changed' => $changed, 'Removed' => $removed ) as $label => $list ) {\n        if ( $list ) {\n            $lines[] = $label . ' (' . count( $list ) . \"):\\n  \" . implode( \"\\n  \", array_slice( $list, 0, 50 ) );\n        }\n    }\n\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Plugin or theme files changed', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        \"These PHP files changed since yesterday without a dashboard update:\\n\\n\" . implode( \"\\n\\n\", $lines ) . \"\\n\\nIf you did not make these changes, check the files for malware.\"\n    );\n} );",
            "requires": [],
            "notes": "The baseline is refreshed automatically after updates done from the dashboard; changes made over FTP or by malware are reported."
        },
        {
            "id": "sec-php-files-in-uploads-alert",
            "version": 1,
            "title": "Alert When PHP Files Appear in the Uploads Folder",
            "description": "Scans the uploads folder once a day for PHP files, which never belong there and usually mean a backdoor, and emails the administrator and shows a dashboard warning when new ones show up.",
            "category": "security",
            "tags": [
                "malware",
                "uploads",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_uploads_php_scan' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_uploads_php_scan' );\n    }\n} );\n\nadd_action( 'wpide_uploads_php_scan', function () {\n    $uploads = wp_get_upload_dir();\n    $base    = $uploads['basedir'];\n    $found   = array();\n\n    if ( ! is_dir( $base ) ) {\n        return;\n    }\n\n    $files = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $base, FilesystemIterator::SKIP_DOTS ) );\n    foreach ( $files as $file ) {\n        if ( ! $file->isFile() || ! preg_match( '/\\.(php\\d?|phtml|phar|pht)$/i', $file->getFilename() ) ) {\n            continue;\n        }\n        if ( 'index.php' === strtolower( $file->getFilename() ) && $file->getSize() < 200 ) {\n            continue;\n        }\n        $found[] = str_replace( $base, '', $file->getPathname() );\n        if ( count( $found ) >= 100 ) {\n            break;\n        }\n    }\n\n    $previous = (array) get_option( 'wpide_uploads_php_found', array() );\n    update_option( 'wpide_uploads_php_found', $found, false );\n    $new = array_diff( $found, $previous );\n\n    if ( $new ) {\n        wp_mail(\n            get_option( 'admin_email' ),\n            sprintf( '[%s] PHP files found in uploads', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n            \"These PHP files were found in your uploads folder, where PHP files should never be:\\n\\n\" . implode( \"\\n\", $new ) . \"\\n\\nCheck and remove them.\"\n        );\n    }\n} );\n\nadd_action( 'admin_notices', function () {\n    $found = (array) get_option( 'wpide_uploads_php_found', array() );\n\n    if ( $found && current_user_can( 'manage_options' ) ) {\n        printf( '<div class=\"notice notice-error\"><p>%s</p></div>', esc_html( sprintf( 'Security: %d PHP file(s) found in the uploads folder, for example %s. Check and remove them.', count( $found ), $found[0] ) ) );\n    }\n} );",
            "requires": [],
            "notes": "Small \"silence is golden\" index.php files that some plugins add are ignored."
        },
        {
            "id": "sec-trusted-admins-only-manage-code",
            "version": 1,
            "title": "Only Named Administrators Can Manage Plugins, Themes and Users",
            "description": "Limits installing, activating, editing and deleting plugins and themes, updating WordPress and managing users to the administrator accounts you list by user ID, even if other accounts have the administrator role.",
            "category": "security",
            "tags": [
                "capabilities",
                "administrators",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User IDs allowed to manage code and users.\n$wpide_trusted_admins = array( 1 );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id ) use ( $wpide_trusted_admins ) {\n    static $guarded = array( 'install_plugins', 'activate_plugins', 'deactivate_plugins', 'delete_plugins', 'update_plugins', 'edit_plugins', 'install_themes', 'switch_themes', 'delete_themes', 'update_themes', 'edit_themes', 'update_core', 'create_users', 'promote_users', 'delete_users', 'edit_users', 'remove_users' );\n\n    if ( ! in_array( $cap, $guarded, true ) || in_array( (int) $user_id, array_map( 'intval', $wpide_trusted_admins ), true ) ) {\n        return $caps;\n    }\n\n    static $any_trusted = null;\n    if ( null === $any_trusted ) {\n        $any_trusted = false;\n        foreach ( $wpide_trusted_admins as $id ) {\n            $trusted = get_userdata( (int) $id );\n            if ( $trusted && in_array( 'administrator', (array) $trusted->roles, true ) ) {\n                $any_trusted = true;\n                break;\n            }\n        }\n    }\n\n    return $any_trusted ? array( 'do_not_allow' ) : $caps;\n}, 20, 3 );",
            "requires": [],
            "notes": "Put your own user ID in the list first (find it in the address bar when editing your profile); if none of the listed IDs is an administrator, the snippet does nothing so you cannot lock yourself out."
        },
        {
            "id": "sec-rest-namespace-allowlist",
            "version": 1,
            "title": "Allow Only Chosen REST API Namespaces for Visitors",
            "description": "Blocks REST API routes for logged-out visitors except the namespaces you list (for example oEmbed, contact forms and the WooCommerce Store API), shrinking the public attack surface without breaking front-end features.",
            "category": "security",
            "tags": [
                "rest-api",
                "hardening",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Namespaces visitors may use.\n$wpide_public_namespaces = array( 'oembed/1.0', 'contact-form-7/v1', 'wc/store', 'wc/store/v1', 'wpide/v1', 'jetpack/v4' );\n\nadd_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) use ( $wpide_public_namespaces ) {\n    if ( null !== $result || is_user_logged_in() ) {\n        return $result;\n    }\n\n    $route = ltrim( $request->get_route(), '/' );\n\n    if ( '' === $route ) {\n        return new WP_Error( 'rest_forbidden', 'The REST API index is not available.', array( 'status' => 401 ) );\n    }\n\n    foreach ( $wpide_public_namespaces as $namespace ) {\n        if ( 0 === strpos( $route, trim( $namespace, '/' ) . '/' ) || trim( $namespace, '/' ) === $route ) {\n            return $result;\n        }\n    }\n\n    return new WP_Error( 'rest_forbidden', 'Authentication required.', array( 'status' => 401 ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Add the namespace of any plugin feature that stops working for visitors; the namespace is the first one or two parts of the route, such as contact-form-7/v1."
        },
        {
            "id": "sec-unknown-admin-detector",
            "version": 1,
            "title": "Detect Administrator Accounts Added Behind Your Back",
            "description": "Keeps a list of approved administrator accounts and checks it daily and on every dashboard visit; administrators that appear any other way (for example inserted directly into the database by malware) trigger an email and a warning with an approve button.",
            "category": "security",
            "tags": [
                "administrators",
                "malware",
                "alerts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_admin_ids = function () {\n    return array_map( 'intval', get_users( array( 'role' => 'administrator', 'fields' => 'ID', 'number' => -1 ) ) );\n};\n\n$wpide_approved = function () use ( $wpide_admin_ids ) {\n    $approved = get_option( 'wpide_approved_admins' );\n\n    if ( ! is_array( $approved ) ) {\n        $approved = $wpide_admin_ids();\n        update_option( 'wpide_approved_admins', $approved, false );\n    }\n\n    return array_map( 'intval', $approved );\n};\n\n$wpide_approve = function ( $user_id ) use ( $wpide_approved ) {\n    $list = $wpide_approved();\n    if ( ! in_array( (int) $user_id, $list, true ) ) {\n        $list[] = (int) $user_id;\n        update_option( 'wpide_approved_admins', $list, false );\n    }\n};\n\n$wpide_trusted_actor = function () use ( $wpide_approved ) {\n    return is_user_logged_in() && in_array( get_current_user_id(), $wpide_approved(), true );\n};\n\nadd_action( 'set_user_role', function ( $user_id, $role ) use ( $wpide_approve, $wpide_trusted_actor ) {\n    if ( 'administrator' === $role && $wpide_trusted_actor() ) {\n        $wpide_approve( $user_id );\n    }\n}, 10, 2 );\n\n$wpide_unknown = function () use ( $wpide_admin_ids, $wpide_approved ) {\n    return array_values( array_diff( $wpide_admin_ids(), $wpide_approved() ) );\n};\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_admin_check' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_admin_check' );\n    }\n} );\n\nadd_action( 'wpide_admin_check', function () use ( $wpide_unknown ) {\n    $unknown = $wpide_unknown();\n    $sig     = md5( implode( ',', $unknown ) );\n\n    if ( ! $unknown || get_option( 'wpide_admin_alert_sent' ) === $sig ) {\n        return;\n    }\n\n    $names = array_map( function ( $id ) {\n        $u = get_userdata( $id );\n        return $u ? $u->user_login . ' <' . $u->user_email . '>' : '#' . $id;\n    }, $unknown );\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] Unknown administrator accounts', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), \"These administrator accounts were not created through the dashboard:\\n\\n\" . implode( \"\\n\", $names ) . \"\\n\\nIf you do not recognise them, remove them and change all passwords.\" );\n    update_option( 'wpide_admin_alert_sent', $sig, false );\n} );\n\nadd_action( 'admin_notices', function () use ( $wpide_unknown, $wpide_approve, $wpide_trusted_actor ) {\n    if ( ! current_user_can( 'list_users' ) || ! $wpide_trusted_actor() ) {\n        return;\n    }\n\n    if ( isset( $_POST['wpide_approve_admin'] ) && check_admin_referer( 'wpide_approve_admin' ) ) {\n        $wpide_approve( absint( $_POST['wpide_approve_admin'] ) );\n    }\n\n    foreach ( $wpide_unknown() as $id ) {\n        $user = get_userdata( $id );\n        if ( ! $user ) {\n            continue;\n        }\n        echo '<div class=\"notice notice-error\"><form method=\"post\"><p><strong>Security:</strong> ' . esc_html( sprintf( 'The administrator account \"%s\" (%s) was not added through the dashboard.', $user->user_login, $user->user_email ) ) . ' ';\n        wp_nonce_field( 'wpide_approve_admin' );\n        echo '<button class=\"button\" name=\"wpide_approve_admin\" value=\"' . (int) $id . '\">I know this account</button> <a class=\"button\" href=\"' . esc_url( get_edit_user_link( $id ) ) . '\">Review</a></p></form></div>';\n    }\n} );",
            "requires": [],
            "notes": "Administrators that exist when you activate the snippet are approved automatically, as are those created or promoted from the Users screen by an approved administrator."
        },
        {
            "id": "sec-block-unusual-http-methods",
            "version": 1,
            "title": "Block TRACE, TRACK and Other Unusual HTTP Methods",
            "description": "Answers requests that use HTTP methods WordPress never needs (such as TRACE, TRACK, CONNECT or DEBUG) with a 405 error before any page is built.",
            "category": "security",
            "tags": [
                "http",
                "hardening",
                "requests"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Methods that are allowed.\n$wpide_allowed_methods = array( 'GET', 'POST', 'HEAD', 'OPTIONS', 'PUT', 'PATCH', 'DELETE' );\n\nadd_action( 'init', function () use ( $wpide_allowed_methods ) {\n    if ( ( defined( 'WP_CLI' ) && WP_CLI ) || empty( $_SERVER['REQUEST_METHOD'] ) ) {\n        return;\n    }\n\n    $method = strtoupper( sanitize_key( $_SERVER['REQUEST_METHOD'] ) );\n\n    if ( ! in_array( $method, $wpide_allowed_methods, true ) ) {\n        status_header( 405 );\n        header( 'Allow: ' . implode( ', ', $wpide_allowed_methods ) );\n        exit;\n    }\n}, 0 );",
            "requires": [],
            "notes": "GET, POST, HEAD, OPTIONS, PUT, PATCH and DELETE stay allowed because the REST API uses them."
        },
        {
            "id": "sec-validate-host-header",
            "version": 1,
            "title": "Reject Requests for Unknown Host Names",
            "description": "Returns an error for requests whose Host header is not your site's domain, which blocks host header injection tricks (such as poisoned password reset links) and junk traffic aimed at your server's IP.",
            "category": "security",
            "tags": [
                "hardening",
                "http",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra host names allowed besides the site and home URL hosts.\n$wpide_allowed_hosts = array();\n\nadd_action( 'init', function () use ( $wpide_allowed_hosts ) {\n    if ( ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() || empty( $_SERVER['HTTP_HOST'] ) ) {\n        return;\n    }\n\n    $host    = strtolower( preg_replace( '/:\\d+$/', '', sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) );\n    $allowed = array_merge( $wpide_allowed_hosts, array( wp_parse_url( home_url(), PHP_URL_HOST ), wp_parse_url( site_url(), PHP_URL_HOST ) ) );\n    $allowed = array_map( 'strtolower', array_filter( $allowed ) );\n\n    foreach ( $allowed as $name ) {\n        $allowed[] = 0 === strpos( $name, 'www.' ) ? substr( $name, 4 ) : 'www.' . $name;\n    }\n\n    if ( ! in_array( $host, $allowed, true ) ) {\n        status_header( 400 );\n        nocache_headers();\n        exit( 'Unknown host.' );\n    }\n}, 0 );",
            "requires": [],
            "notes": "Add every domain and subdomain that legitimately points at this site (staging, www, mapped domains) to $wpide_allowed_hosts."
        },
        {
            "id": "sec-login-requires-same-site-form",
            "version": 1,
            "title": "Reject Login Attempts That Don't Come From Your Login Form",
            "description": "Refuses login form submissions that are not sent from a page on your own site, which stops many brute-force bots that post straight to wp-login.php.",
            "category": "security",
            "tags": [
                "login",
                "brute-force",
                "bots"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'login_init', function () {\n    if ( 'POST' !== ( isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : '' ) || empty( $_POST['log'] ) ) {\n        return;\n    }\n\n    $own    = wp_parse_url( site_url(), PHP_URL_HOST );\n    $origin = isset( $_SERVER['HTTP_ORIGIN'] ) ? wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ), PHP_URL_HOST ) : '';\n    $ref    = isset( $_SERVER['HTTP_REFERER'] ) ? wp_parse_url( esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : '';\n    $home   = wp_parse_url( home_url(), PHP_URL_HOST );\n\n    if ( in_array( $origin ? $origin : $ref, array( $own, $home ), true ) ) {\n        return;\n    }\n\n    status_header( 403 );\n    wp_die( esc_html__( 'Please log in using the login form on this site.' ), 403 );\n}, 1 );",
            "requires": [],
            "notes": "Browsers send the needed Origin or Referer header automatically; disable the snippet if a mobile app or external form logs users in through wp-login.php."
        },
        {
            "id": "sec-bot-trap-link",
            "version": 1,
            "title": "Bot Trap Link That Blocks Misbehaving Crawlers",
            "description": "Adds an invisible link that real visitors never see and well-behaved search engines are told to skip; any client that requests it anyway is blocked from the site for 24 hours.",
            "category": "security",
            "tags": [
                "bots",
                "firewall",
                "crawlers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Hours a trapped IP stays blocked.\n$wpide_trap_hours = 24;\n\n$wpide_trap_ip = function () {\n    return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n};\n\nadd_action( 'init', function () use ( $wpide_trap_ip, $wpide_trap_hours ) {\n    if ( is_admin() || is_user_logged_in() || ( defined( 'WP_CLI' ) && WP_CLI ) || wp_doing_cron() ) {\n        return;\n    }\n\n    $key = 'wpide_trap_' . md5( $wpide_trap_ip() );\n\n    if ( isset( $_GET['wpide-trap'] ) ) {\n        set_transient( $key, 1, $wpide_trap_hours * HOUR_IN_SECONDS );\n    }\n\n    if ( get_transient( $key ) ) {\n        status_header( 403 );\n        nocache_headers();\n        exit( 'Access denied.' );\n    }\n}, 1 );\n\nadd_action( 'wp_footer', function () {\n    echo '<a href=\"' . esc_url( home_url( '/?wpide-trap=1' ) ) . '\" rel=\"nofollow\" tabindex=\"-1\" aria-hidden=\"true\" style=\"position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;\">&nbsp;</a>';\n} );\n\nadd_filter( 'robots_txt', function ( $output ) {\n    return $output . \"\\nUser-agent: *\\nDisallow: /?wpide-trap=\\n\";\n} );",
            "requires": [],
            "notes": "The trap URL is disallowed in robots.txt, so Google and Bing will not fall into it; logged-in users are never blocked."
        },
        {
            "id": "sec-block-404-scanners",
            "version": 1,
            "title": "Block IP Addresses That Trigger Many 404 Errors",
            "description": "Temporarily blocks visitors who hit more than 20 missing pages within a minute, a typical sign of vulnerability scanners probing for old plugins and backup files.",
            "category": "security",
            "tags": [
                "firewall",
                "404",
                "scanners"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// 404 errors allowed per minute, and how long offenders are blocked (minutes).\n$wpide_404_limit = 20;\n$wpide_404_block = 60;\n\n$wpide_404_key = function () {\n    return md5( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' );\n};\n\nadd_action( 'init', function () use ( $wpide_404_key ) {\n    if ( is_admin() || is_user_logged_in() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return;\n    }\n\n    if ( get_transient( 'wpide_404_ban_' . $wpide_404_key() ) ) {\n        status_header( 403 );\n        nocache_headers();\n        exit( 'Too many requests for missing pages. Try again later.' );\n    }\n}, 1 );\n\nadd_action( 'template_redirect', function () use ( $wpide_404_key, $wpide_404_limit, $wpide_404_block ) {\n    if ( ! is_404() || is_user_logged_in() ) {\n        return;\n    }\n\n    $key   = 'wpide_404_count_' . $wpide_404_key();\n    $count = (int) get_transient( $key ) + 1;\n    set_transient( $key, $count, MINUTE_IN_SECONDS );\n\n    if ( $count > $wpide_404_limit ) {\n        set_transient( 'wpide_404_ban_' . $wpide_404_key(), 1, $wpide_404_block * MINUTE_IN_SECONDS );\n    }\n}, 99 );",
            "requires": [],
            "notes": "Logged-in users are never blocked; raise $wpide_404_limit if you recently changed many URLs and expect lots of broken links."
        },
        {
            "id": "sec-admin-account-change-alert",
            "version": 1,
            "title": "Alert the Site Owner When an Administrator's Email or Password Changes",
            "description": "Emails the site's admin address whenever any administrator account changes its email address or password, a key warning sign of an account takeover.",
            "category": "security",
            "tags": [
                "administrators",
                "alerts",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'profile_update', function ( $user_id, $old_data ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user || ! $old_data || ! user_can( $user, 'manage_options' ) ) {\n        return;\n    }\n\n    $changes = array();\n    if ( $user->user_email !== $old_data->user_email ) {\n        $changes[] = sprintf( 'email changed from %s to %s', $old_data->user_email, $user->user_email );\n    }\n    if ( $user->user_pass !== $old_data->user_pass ) {\n        $changes[] = 'password changed';\n    }\n\n    if ( ! $changes ) {\n        return;\n    }\n\n    $actor = wp_get_current_user();\n    wp_mail(\n        get_option( 'admin_email' ),\n        sprintf( '[%s] Administrator account changed', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ),\n        sprintf(\n            \"The administrator account \\\"%s\\\" was changed: %s.\\n\\nChanged by: %s\\nIP address: %s\\nTime: %s\",\n            $user->user_login,\n            implode( ', ', $changes ),\n            $actor->exists() ? $actor->user_login : 'password reset or system',\n            isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n            wp_date( 'Y-m-d H:i' )\n        )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "WordPress already notifies the affected user; this adds a second notice to the site owner."
        },
        {
            "id": "sec-admin-role-email-domains",
            "version": 1,
            "title": "Allow the Administrator Role Only for Company Email Addresses",
            "description": "Refuses to give the administrator role to accounts whose email address is not on one of your approved domains, whether set from the profile screen, the Add User screen or a bulk role change.",
            "category": "security",
            "tags": [
                "administrators",
                "roles",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Email domains allowed to hold the administrator role.\n$wpide_admin_domains = array( 'example.com' );\n\n$wpide_domain_ok = function ( $email ) use ( $wpide_admin_domains ) {\n    $domain = strtolower( (string) substr( strrchr( (string) $email, '@' ), 1 ) );\n    return in_array( $domain, array_map( 'strtolower', $wpide_admin_domains ), true );\n};\n\nadd_action( 'user_profile_update_errors', function ( $errors, $update, $user ) use ( $wpide_domain_ok ) {\n    if ( isset( $user->role ) && 'administrator' === $user->role && ! $wpide_domain_ok( $user->user_email ) ) {\n        $errors->add( 'wpide_admin_domain', '<strong>Error:</strong> Administrators must use an approved company email address.' );\n    }\n}, 10, 3 );\n\nadd_action( 'set_user_role', function ( $user_id, $role, $old_roles ) use ( $wpide_domain_ok ) {\n    $user = get_userdata( $user_id );\n\n    if ( 'administrator' !== $role || ! $user || $wpide_domain_ok( $user->user_email ) ) {\n        return;\n    }\n\n    static $busy = false;\n    if ( $busy ) {\n        return;\n    }\n\n    $busy     = true;\n    $fallback = $old_roles && 'administrator' !== reset( $old_roles ) ? reset( $old_roles ) : get_option( 'default_role', 'subscriber' );\n    $user->set_role( 'administrator' === $fallback ? 'subscriber' : $fallback );\n    $busy = false;\n}, 1, 3 );",
            "requires": [],
            "notes": "Set $wpide_admin_domains to your own domains before activating; existing administrators are not changed."
        },
        {
            "id": "sec-frame-ancestors-allowlist",
            "version": 1,
            "title": "Allow Your Site to Be Embedded Only on Partner Domains",
            "description": "Replaces the usual same-site framing rule with a Content-Security-Policy frame-ancestors list, so your pages can be shown in iframes on the partner sites you name and nowhere else.",
            "category": "security",
            "tags": [
                "headers",
                "iframes",
                "clickjacking"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Sites allowed to show your pages in an iframe.\n$wpide_frame_partners = array( 'https://partner.example.com', 'https://*.example.org' );\n\nadd_action( 'send_headers', function () use ( $wpide_frame_partners ) {\n    if ( headers_sent() ) {\n        return;\n    }\n\n    header_remove( 'X-Frame-Options' );\n    $sources = array_map( function ( $source ) {\n        return preg_replace( '#[^a-z0-9.*:/-]#i', '', $source );\n    }, $wpide_frame_partners );\n\n    header( \"Content-Security-Policy: frame-ancestors 'self' \" . implode( ' ', $sources ), false );\n}, 99 );",
            "requires": [],
            "notes": "This removes any X-Frame-Options header sent by WordPress or other snippets, since that header cannot list several sites."
        },
        {
            "id": "sec-subresource-integrity",
            "version": 1,
            "title": "Subresource Integrity for Scripts and Styles From CDNs",
            "description": "Adds integrity and crossorigin attributes to the CDN-hosted scripts and stylesheets you list, so browsers refuse to run them if the CDN copy is ever tampered with.",
            "category": "security",
            "tags": [
                "sri",
                "cdn",
                "scripts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Handle => integrity hash of the exact file that handle loads.\n$wpide_sri_hashes = array(\n    'example-cdn-script' => 'sha384-REPLACE_WITH_THE_FILE_HASH',\n);\n\n$wpide_add_sri = function ( $tag, $handle ) use ( $wpide_sri_hashes ) {\n    if ( empty( $wpide_sri_hashes[ $handle ] ) || false !== strpos( $tag, 'integrity=' ) || ! class_exists( 'WP_HTML_Tag_Processor' ) ) {\n        return $tag;\n    }\n\n    $tags = new WP_HTML_Tag_Processor( $tag );\n\n    while ( $tags->next_tag() ) {\n        if ( in_array( $tags->get_tag(), array( 'SCRIPT', 'LINK' ), true ) && ( $tags->get_attribute( 'src' ) || $tags->get_attribute( 'href' ) ) ) {\n            $tags->set_attribute( 'integrity', $wpide_sri_hashes[ $handle ] );\n            $tags->set_attribute( 'crossorigin', 'anonymous' );\n            break;\n        }\n    }\n\n    return $tags->get_updated_html();\n};\n\nadd_filter( 'script_loader_tag', $wpide_add_sri, 20, 2 );\nadd_filter( 'style_loader_tag', $wpide_add_sri, 20, 2 );",
            "requires": [],
            "notes": "Get the hash for each exact file version from the CDN (for example the \"Copy SRI\" button on cdnjs) and update it whenever you change the version."
        },
        {
            "id": "sec-emergency-lockdown",
            "version": 1,
            "title": "Emergency Lockdown Switch",
            "description": "Adds a \"Lockdown\" toggle to the admin bar for administrators. While it is on, only administrators can log in, everyone else is logged out, registration and comments are closed and XML-RPC is off.",
            "category": "security",
            "tags": [
                "emergency",
                "login",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_locked = function () {\n    return '1' === get_option( 'wpide_lockdown' );\n};\n\nadd_action( 'admin_bar_menu', function ( $bar ) use ( $wpide_locked ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $bar->add_node( array(\n        'id'    => 'wpide-lockdown',\n        'title' => $wpide_locked() ? 'Lockdown: ON' : 'Lockdown: off',\n        'href'  => wp_nonce_url( admin_url( 'admin-post.php?action=wpide_lockdown' ), 'wpide_lockdown' ),\n        'meta'  => array( 'title' => 'Toggle emergency lockdown' ),\n    ) );\n}, 999 );\n\nadd_action( 'admin_post_wpide_lockdown', function () use ( $wpide_locked ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( esc_html__( 'Sorry, you are not allowed to do that.' ), 403 );\n    }\n\n    check_admin_referer( 'wpide_lockdown' );\n    update_option( 'wpide_lockdown', $wpide_locked() ? '0' : '1' );\n    wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url() );\n    exit;\n} );\n\nadd_action( 'init', function () use ( $wpide_locked ) {\n    if ( ! $wpide_locked() ) {\n        return;\n    }\n\n    add_filter( 'pre_option_users_can_register', '__return_zero' );\n    add_filter( 'comments_open', '__return_false', 99 );\n    add_filter( 'xmlrpc_enabled', '__return_false' );\n    add_filter( 'xmlrpc_methods', '__return_empty_array' );\n    add_filter( 'authenticate', function ( $user ) {\n        return ( $user instanceof WP_User && ! user_can( $user, 'manage_options' ) ) ? new WP_Error( 'wpide_lockdown', 'Logins are temporarily disabled.' ) : $user;\n    }, 99 );\n\n    if ( is_user_logged_in() && ! current_user_can( 'manage_options' ) ) {\n        wp_logout();\n    }\n}, 1 );",
            "requires": [],
            "notes": "Use it while you investigate a problem; switch it off again from the same admin bar item."
        },
        {
            "id": "sec-capability-audit",
            "version": 1,
            "title": "Capability Audit for Risky Permissions",
            "description": "Adds Users > Capability Audit, listing every role other than Administrator and every user outside the administrator role that holds powerful permissions such as unfiltered HTML, plugin installation or user management.",
            "category": "security",
            "tags": [
                "capabilities",
                "roles",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Capabilities considered risky outside the administrator role.\n$wpide_risky_caps = array( 'unfiltered_html', 'unfiltered_upload', 'edit_plugins', 'edit_themes', 'edit_files', 'install_plugins', 'activate_plugins', 'delete_plugins', 'install_themes', 'switch_themes', 'update_core', 'manage_options', 'promote_users', 'create_users', 'edit_users', 'delete_users' );\n\nadd_action( 'admin_menu', function () use ( $wpide_risky_caps ) {\n    add_users_page( 'Capability Audit', 'Capability Audit', 'promote_users', 'wpide-cap-audit', function () use ( $wpide_risky_caps ) {\n        echo '<div class=\"wrap\"><h1>Capability Audit</h1><h2>Roles</h2><table class=\"widefat striped\" style=\"max-width:900px\"><thead><tr><th>Role</th><th>Risky capabilities</th></tr></thead><tbody>';\n        $flagged_roles = 0;\n        foreach ( wp_roles()->roles as $slug => $role ) {\n            if ( 'administrator' === $slug ) {\n                continue;\n            }\n            $risky = array_keys( array_intersect_key( array_filter( (array) $role['capabilities'] ), array_flip( $wpide_risky_caps ) ) );\n            if ( $risky ) {\n                $flagged_roles++;\n                printf( '<tr><td>%s <code>%s</code></td><td>%s</td></tr>', esc_html( translate_user_role( $role['name'] ) ), esc_html( $slug ), esc_html( implode( ', ', $risky ) ) );\n            }\n        }\n        if ( ! $flagged_roles ) {\n            echo '<tr><td colspan=\"2\">No role besides Administrator has risky capabilities.</td></tr>';\n        }\n        echo '</tbody></table><h2>Users with extra capabilities</h2><table class=\"widefat striped\" style=\"max-width:900px\"><thead><tr><th>User</th><th>Roles</th><th>Risky capabilities</th></tr></thead><tbody>';\n        $flagged_users = 0;\n        foreach ( get_users( array( 'role__not_in' => array( 'administrator' ), 'number' => 500, 'fields' => 'all' ) ) as $user ) {\n            $risky = array();\n            foreach ( $wpide_risky_caps as $cap ) {\n                if ( ! empty( $user->allcaps[ $cap ] ) ) {\n                    $risky[] = $cap;\n                }\n            }\n            if ( $risky ) {\n                $flagged_users++;\n                printf( '<tr><td><a href=\"%s\">%s</a></td><td>%s</td><td>%s</td></tr>', esc_url( get_edit_user_link( $user->ID ) ), esc_html( $user->user_login ), esc_html( implode( ', ', $user->roles ) ), esc_html( implode( ', ', $risky ) ) );\n            }\n        }\n        if ( ! $flagged_users ) {\n            echo '<tr><td colspan=\"3\">No non-administrator has risky capabilities.</td></tr>';\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Plugins sometimes grant extra capabilities to editors or shop managers; review anything you did not set up yourself."
        },
        {
            "id": "sec-limit-post-password-attempts",
            "version": 1,
            "title": "Limit Guesses on Password-Protected Posts",
            "description": "Allows each visitor at most 10 password attempts on password-protected posts and pages every 15 minutes, which stops bots from brute-forcing the post password.",
            "category": "security",
            "tags": [
                "passwords",
                "brute-force",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Attempts allowed per window, and the window length in minutes.\n$wpide_postpass_limit  = 10;\n$wpide_postpass_window = 15;\n\nadd_action( 'login_form_postpass', function () use ( $wpide_postpass_limit, $wpide_postpass_window ) {\n    $ip    = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n    $key   = 'wpide_postpass_' . md5( $ip );\n    $count = (int) get_transient( $key );\n\n    if ( $count >= $wpide_postpass_limit ) {\n        wp_die( esc_html( sprintf( 'Too many password attempts. Please wait %d minutes and try again.', $wpide_postpass_window ) ), 429, array( 'back_link' => true ) );\n    }\n\n    set_transient( $key, $count + 1, $wpide_postpass_window * MINUTE_IN_SECONDS );\n} );",
            "requires": [],
            "notes": "Every submission counts, including correct ones; the limit is per IP address."
        },
        {
            "id": "sec-post-password-session-cookie",
            "version": 1,
            "title": "Forget Post Passwords When the Browser Closes",
            "description": "Makes the cookie that remembers a protected post's password last only until the browser is closed, instead of the default 10 days, which matters on shared and public computers.",
            "category": "security",
            "tags": [
                "passwords",
                "cookies",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'post_password_expires', '__return_zero' );",
            "requires": [],
            "notes": "Visitors need to enter the password again in each new browser session."
        },
        {
            "id": "sec-application-password-alerts",
            "version": 1,
            "title": "Email Alerts When Application Passwords Are Created",
            "description": "Emails the user and the site administrator whenever a new application password is created for an account, since these passwords give API access that bypasses the normal login.",
            "category": "security",
            "tags": [
                "application-passwords",
                "alerts",
                "api"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_create_application_password', function ( $user_id, $item ) {\n    $user = get_userdata( $user_id );\n\n    if ( ! $user ) {\n        return;\n    }\n\n    $site    = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES );\n    $actor   = wp_get_current_user();\n    $message = sprintf(\n        \"A new application password named \\\"%s\\\" was created for the account %s.\\n\\nCreated by: %s\\nIP address: %s\\nTime: %s\\n\\nIf this was not expected, revoke it under Users > Profile > Application Passwords and change the account password.\",\n        isset( $item['name'] ) ? $item['name'] : '',\n        $user->user_login,\n        $actor->exists() ? $actor->user_login : 'unknown',\n        isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '',\n        wp_date( 'Y-m-d H:i' )\n    );\n\n    $recipients = array_unique( array( $user->user_email, get_option( 'admin_email' ) ) );\n    wp_mail( $recipients, sprintf( '[%s] New application password', $site ), $message );\n}, 10, 2 );",
            "requires": [],
            "notes": "Application passwords created by the user themselves also trigger the email, which doubles as a receipt."
        },
        {
            "id": "sec-application-password-ip-allowlist",
            "version": 1,
            "title": "Accept Application Passwords Only From Trusted IP Addresses",
            "description": "Rejects REST API and XML-RPC logins made with application passwords unless they come from the IP addresses or networks you list, such as your office, CI server or integration partner.",
            "category": "security",
            "tags": [
                "application-passwords",
                "api",
                "ip-allowlist"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IP addresses or IPv4 CIDR ranges allowed to use application passwords.\n$wpide_app_pass_ips = array( '127.0.0.1' );\n\n$wpide_ip_allowed = function ( $ip ) use ( $wpide_app_pass_ips ) {\n    foreach ( $wpide_app_pass_ips as $rule ) {\n        if ( $rule === $ip ) {\n            return true;\n        }\n        if ( false !== strpos( $rule, '/' ) && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n            list( $subnet, $bits ) = explode( '/', $rule, 2 );\n            $mask = -1 << ( 32 - (int) $bits );\n            if ( ( ip2long( $ip ) & $mask ) === ( ip2long( $subnet ) & $mask ) ) {\n                return true;\n            }\n        }\n    }\n\n    return false;\n};\n\nadd_filter( 'wp_authenticate_application_password_errors', function ( $error ) use ( $wpide_app_pass_ips, $wpide_ip_allowed ) {\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    if ( $wpide_app_pass_ips && ! $wpide_ip_allowed( $ip ) ) {\n        $error->add( 'wpide_app_pass_ip', 'Application passwords cannot be used from this IP address.' );\n    }\n\n    return $error;\n} );",
            "requires": [],
            "notes": "Use single addresses or CIDR ranges like 198.51.100.0/24; leave the list empty to allow every address."
        },
        {
            "id": "sec-protect-wp-cron-endpoint",
            "version": 1,
            "title": "Protect wp-cron.php From Outside Requests",
            "description": "Requires a secret key for requests to wp-cron.php and adds that key automatically to WordPress's own cron calls, so outsiders cannot hammer the endpoint to slow your site down.",
            "category": "security",
            "tags": [
                "cron",
                "hardening",
                "dos"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_cron_key = function () {\n    return substr( wp_hash( 'wpide-cron-key' ), 0, 20 );\n};\n\nadd_filter( 'cron_request', function ( $request ) use ( $wpide_cron_key ) {\n    $request['url'] = add_query_arg( 'wpide_cron_key', $wpide_cron_key(), $request['url'] );\n    return $request;\n} );\n\nadd_action( 'init', function () use ( $wpide_cron_key ) {\n    if ( ! wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return;\n    }\n\n    $script = isset( $_SERVER['SCRIPT_NAME'] ) ? basename( sanitize_text_field( wp_unslash( $_SERVER['SCRIPT_NAME'] ) ) ) : '';\n    $key    = isset( $_GET['wpide_cron_key'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_cron_key'] ) ) : '';\n\n    if ( 'wp-cron.php' === $script && ! hash_equals( $wpide_cron_key(), $key ) ) {\n        status_header( 403 );\n        exit;\n    }\n}, 0 );\n\nadd_filter( 'debug_information', function ( $info ) use ( $wpide_cron_key ) {\n    $info['wpide-cron'] = array(\n        'label'  => 'WPIDE cron key',\n        'fields' => array(\n            'url' => array( 'label' => 'Cron URL for server cron jobs', 'value' => add_query_arg( 'wpide_cron_key', $wpide_cron_key(), site_url( 'wp-cron.php' ) ), 'private' => true ),\n        ),\n    );\n\n    return $info;\n} );",
            "requires": [],
            "notes": "If a server cron job calls wp-cron.php, add ?wpide_cron_key=YOUR_KEY to its URL; the key is shown under Tools > Site Health > Info > WPIDE cron key."
        },
        {
            "id": "sec-failed-login-digest",
            "version": 1,
            "title": "Daily Email Summary of Failed Logins",
            "description": "Counts failed login attempts during the day (per username and IP address) and emails the administrator a short summary each morning, so you notice brute-force attacks without a flood of alerts.",
            "category": "security",
            "tags": [
                "login",
                "alerts",
                "brute-force"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Send the summary only when at least this many attempts failed.\n$wpide_digest_minimum = 10;\n\nadd_action( 'wp_login_failed', function ( $username ) {\n    $day  = wp_date( 'Y-m-d' );\n    $data = get_option( 'wpide_failed_logins' );\n    $data = is_array( $data ) && isset( $data['day'] ) && $data['day'] === $day ? $data : array( 'day' => $day, 'total' => 0, 'users' => array(), 'ips' => array() );\n    $ip   = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown';\n    $user = substr( sanitize_user( (string) $username ), 0, 60 );\n\n    $data['total']++;\n    if ( count( $data['users'] ) < 200 || isset( $data['users'][ $user ] ) ) {\n        $data['users'][ $user ] = ( isset( $data['users'][ $user ] ) ? $data['users'][ $user ] : 0 ) + 1;\n    }\n    if ( count( $data['ips'] ) < 200 || isset( $data['ips'][ $ip ] ) ) {\n        $data['ips'][ $ip ] = ( isset( $data['ips'][ $ip ] ) ? $data['ips'][ $ip ] : 0 ) + 1;\n    }\n\n    update_option( 'wpide_failed_logins', $data, false );\n} );\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_failed_login_digest' ) ) {\n        wp_schedule_event( strtotime( 'tomorrow 07:00' ) - (int) ( get_option( 'gmt_offset' ) * HOUR_IN_SECONDS ), 'daily', 'wpide_failed_login_digest' );\n    }\n} );\n\nadd_action( 'wpide_failed_login_digest', function () use ( $wpide_digest_minimum ) {\n    $data = get_option( 'wpide_failed_logins' );\n\n    if ( ! is_array( $data ) || empty( $data['day'] ) || $data['day'] === wp_date( 'Y-m-d' ) ) {\n        return;\n    }\n\n    delete_option( 'wpide_failed_logins' );\n\n    if ( $data['total'] < $wpide_digest_minimum ) {\n        return;\n    }\n\n    arsort( $data['users'] );\n    arsort( $data['ips'] );\n    $lines = array( sprintf( '%d failed login attempts on %s.', $data['total'], $data['day'] ), '', 'Most tried usernames:' );\n    foreach ( array_slice( $data['users'], 0, 10, true ) as $name => $count ) {\n        $lines[] = sprintf( '  %s: %d%s', $name, $count, username_exists( $name ) ? ' (existing account)' : '' );\n    }\n    $lines[] = '';\n    $lines[] = 'Busiest IP addresses:';\n    foreach ( array_slice( $data['ips'], 0, 10, true ) as $ip => $count ) {\n        $lines[] = sprintf( '  %s: %d', $ip, $count );\n    }\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] Failed login summary', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), implode( \"\\n\", $lines ) );\n} );",
            "requires": [],
            "notes": "No email is sent on days with fewer failed attempts than $wpide_digest_minimum."
        },
        {
            "id": "sec-display-name-not-username",
            "version": 1,
            "title": "Never Show Login Names as Public Display Names",
            "description": "When a user's public display name is the same as their login name (the WordPress default), switches it to their first and last name, or to a neutral name, so author bylines and comments do not reveal usernames.",
            "category": "security",
            "tags": [
                "user-enumeration",
                "users",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_pre_insert_user_data', function ( $data, $update, $user_id, $userdata ) {\n    if ( empty( $data['user_login'] ) || empty( $data['display_name'] ) || strtolower( $data['display_name'] ) !== strtolower( $data['user_login'] ) ) {\n        return $data;\n    }\n\n    $first = isset( $userdata['first_name'] ) ? trim( (string) $userdata['first_name'] ) : '';\n    $last  = isset( $userdata['last_name'] ) ? trim( (string) $userdata['last_name'] ) : '';\n\n    if ( $user_id && ! $first && ! $last ) {\n        $first = (string) get_user_meta( $user_id, 'first_name', true );\n        $last  = (string) get_user_meta( $user_id, 'last_name', true );\n    }\n\n    $name = trim( $first . ' ' . $last );\n\n    $data['display_name'] = '' !== $name ? $name : 'Member ' . strtoupper( substr( md5( $data['user_login'] . wp_salt() ), 0, 5 ) );\n\n    return $data;\n}, 10, 4 );",
            "requires": [],
            "notes": "Applies when accounts are created or updated; existing accounts change the next time they are saved."
        },
        {
            "id": "sec-rest-users-hidden-from-subscribers",
            "version": 1,
            "title": "Hide the REST Users Endpoint From Subscribers and Customers",
            "description": "Blocks the REST API users endpoint for logged-in users who cannot edit posts, such as subscribers and shop customers, so a low-level account cannot list your authors and their usernames.",
            "category": "security",
            "tags": [
                "user-enumeration",
                "rest-api",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'rest_pre_dispatch', function ( $result, $server, $request ) {\n    $route = $request->get_route();\n\n    if ( null !== $result || 0 !== strpos( $route, '/wp/v2/users' ) || ! is_user_logged_in() || current_user_can( 'edit_posts' ) ) {\n        return $result;\n    }\n\n    if ( '/wp/v2/users/me' === untrailingslashit( $route ) ) {\n        return $result;\n    }\n\n    return new WP_Error( 'rest_user_cannot_view', 'Sorry, you are not allowed to list users.', array( 'status' => 403 ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Authors, editors and administrators keep access because the block editor needs it; their own /users/me request keeps working for everyone."
        },
        {
            "id": "sec-root-files-change-alert",
            "version": 1,
            "title": "Alert When wp-config.php, .htaccess or Root Files Change",
            "description": "Checks your site's root folder once a day and emails the administrator when wp-config.php, .htaccess, .user.ini or php.ini change, or when new PHP files appear next to WordPress's own files.",
            "category": "security",
            "tags": [
                "integrity",
                "files",
                "cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_root_snapshot = function () {\n    $root  = ABSPATH;\n    $files = array();\n\n    foreach ( (array) glob( $root . '{*.php,.htaccess,.user.ini,php.ini}', GLOB_BRACE ) as $path ) {\n        if ( is_file( $path ) ) {\n            $files[ basename( $path ) ] = md5_file( $path );\n        }\n    }\n\n    $config = dirname( ABSPATH ) . '/wp-config.php';\n    if ( ! isset( $files['wp-config.php'] ) && is_file( $config ) ) {\n        $files['../wp-config.php'] = md5_file( $config );\n    }\n\n    return $files;\n};\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_root_files_check' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_root_files_check' );\n    }\n} );\n\nadd_action( 'wpide_root_files_check', function () use ( $wpide_root_snapshot ) {\n    $now = $wpide_root_snapshot();\n    $old = get_option( 'wpide_root_files' );\n    update_option( 'wpide_root_files', $now, false );\n\n    if ( ! is_array( $old ) ) {\n        return;\n    }\n\n    $lines = array();\n    foreach ( array_diff_key( $now, $old ) as $file => $hash ) {\n        $lines[] = 'New file: ' . $file;\n    }\n    foreach ( array_diff_key( $old, $now ) as $file => $hash ) {\n        $lines[] = 'Removed: ' . $file;\n    }\n    foreach ( array_intersect_key( $now, $old ) as $file => $hash ) {\n        if ( $old[ $file ] !== $hash ) {\n            $lines[] = 'Changed: ' . $file;\n        }\n    }\n\n    if ( $lines ) {\n        wp_mail( get_option( 'admin_email' ), sprintf( '[%s] Files in the site root changed', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), implode( \"\\n\", $lines ) . \"\\n\\nCore updates change some of these files; if you did not update WordPress or edit them, check them now.\" );\n    }\n} );",
            "requires": [],
            "notes": "Core files are covered by WordPress checksums; this watches the configuration files and unexpected additions that those checks miss."
        },
        {
            "id": "sec-weekly-security-summary",
            "version": 1,
            "title": "Weekly Security Summary Email",
            "description": "Emails the administrator a short security summary every week: pending WordPress, plugin and theme updates, administrator accounts, new users of the week, inactive plugins and whether debug mode is on.",
            "category": "security",
            "tags": [
                "reports",
                "updates",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_weekly_security_summary' ) ) {\n        wp_schedule_event( strtotime( 'next monday 08:00' ), 'weekly', 'wpide_weekly_security_summary' );\n    }\n} );\n\nadd_action( 'wpide_weekly_security_summary', function () {\n    if ( ! function_exists( 'get_plugins' ) ) {\n        require_once ABSPATH . 'wp-admin/includes/plugin.php';\n    }\n\n    $core    = get_site_transient( 'update_core' );\n    $plugins = get_site_transient( 'update_plugins' );\n    $themes  = get_site_transient( 'update_themes' );\n    $core_up = ! empty( $core->updates[0]->response ) && 'upgrade' === $core->updates[0]->response;\n    $admins  = get_users( array( 'role' => 'administrator', 'fields' => array( 'user_login' ) ) );\n    $new     = get_users( array( 'date_query' => array( array( 'after' => '1 week ago' ) ), 'fields' => 'ID', 'number' => 500 ) );\n    $active  = (array) get_option( 'active_plugins', array() );\n\n    $lines = array(\n        'WordPress update available: ' . ( $core_up ? 'yes (' . $core->updates[0]->current . ')' : 'no' ),\n        'Plugin updates pending: ' . ( empty( $plugins->response ) ? 0 : count( $plugins->response ) ),\n        'Theme updates pending: ' . ( empty( $themes->response ) ? 0 : count( $themes->response ) ),\n        'Inactive plugins installed: ' . max( 0, count( get_plugins() ) - count( $active ) ),\n        'Administrator accounts (' . count( $admins ) . '): ' . implode( ', ', wp_list_pluck( $admins, 'user_login' ) ),\n        'New users this week: ' . count( $new ),\n        'Debug mode: ' . ( defined( 'WP_DEBUG' ) && WP_DEBUG ? 'ON - turn it off on a live site' : 'off' ),\n    );\n\n    if ( ! empty( $plugins->response ) ) {\n        $lines[] = '';\n        $lines[] = 'Plugins to update:';\n        foreach ( $plugins->response as $file => $data ) {\n            $lines[] = '  ' . $file . ' -> ' . ( isset( $data->new_version ) ? $data->new_version : '?' );\n        }\n    }\n\n    wp_mail( get_option( 'admin_email' ), sprintf( '[%s] Weekly security summary', wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ) ), implode( \"\\n\", $lines ) . \"\\n\\nManage updates: \" . admin_url( 'update-core.php' ) );\n} );",
            "requires": [],
            "notes": "Update counts come from WordPress's own update checks, so no extra requests are made."
        },
        {
            "id": "sec-auto-update-chosen-plugins",
            "version": 1,
            "title": "Automatic Updates for the Plugins You Trust Most",
            "description": "Turns on automatic background updates for the plugins you list (for example your security and backup plugins), so security fixes for them are installed within hours without waiting for you.",
            "category": "security",
            "tags": [
                "updates",
                "plugins",
                "automation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Plugin folder names (slugs) to update automatically.\n$wpide_auto_update_slugs = array( 'wordfence', 'updraftplus', 'akismet' );\n\nadd_filter( 'auto_update_plugin', function ( $update, $item ) use ( $wpide_auto_update_slugs ) {\n    $slug = isset( $item->slug ) ? $item->slug : ( isset( $item->plugin ) ? dirname( $item->plugin ) : '' );\n\n    return in_array( $slug, $wpide_auto_update_slugs, true ) ? true : $update;\n}, 20, 2 );",
            "requires": [],
            "notes": "Use plugin folder names as they appear in wp-content/plugins; all other plugins keep the setting chosen on the Plugins screen."
        },
        {
            "id": "sec-registration-email-domain-check",
            "version": 1,
            "title": "Reject Registrations From Email Domains That Cannot Receive Mail",
            "description": "Checks that the email domain of every new registration has mail (MX) records, and refuses sign-ups from made-up or mistyped domains that bots and spammers often use.",
            "category": "security",
            "tags": [
                "registration",
                "spam",
                "email"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_domain_has_mail = function ( $email ) {\n    $domain = strtolower( (string) substr( strrchr( (string) $email, '@' ), 1 ) );\n\n    if ( ! $domain || ! function_exists( 'checkdnsrr' ) ) {\n        return true;\n    }\n\n    $key    = 'wpide_mx_' . md5( $domain );\n    $cached = get_transient( $key );\n\n    if ( false !== $cached ) {\n        return 'yes' === $cached;\n    }\n\n    $ok = checkdnsrr( $domain . '.', 'MX' ) || checkdnsrr( $domain . '.', 'A' );\n    set_transient( $key, $ok ? 'yes' : 'no', DAY_IN_SECONDS );\n\n    return $ok;\n};\n\n$wpide_mx_check = function ( $errors, $username, $email ) use ( $wpide_domain_has_mail ) {\n    if ( is_wp_error( $errors ) && is_email( $email ) && ! $wpide_domain_has_mail( $email ) ) {\n        $errors->add( 'wpide_email_domain', '<strong>Error:</strong> This email domain cannot receive email. Please check your address.' );\n    }\n\n    return $errors;\n};\n\nadd_filter( 'registration_errors', $wpide_mx_check, 20, 3 );\nadd_filter( 'woocommerce_registration_errors', $wpide_mx_check, 20, 3 );",
            "requires": [],
            "notes": "Results are cached per domain for a day; if your server cannot do DNS lookups the check lets registrations through."
        },
        {
            "id": "sec-xmlrpc-ip-allowlist",
            "version": 1,
            "title": "Allow XML-RPC Only From Trusted IP Addresses",
            "description": "Keeps XML-RPC available for the services you list (such as Jetpack or a publishing app on your office network) while answering every other XML-RPC request with a 403 error.",
            "category": "security",
            "tags": [
                "xml-rpc",
                "ip-allowlist",
                "hardening"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// IP addresses or IPv4 CIDR ranges allowed to use xmlrpc.php.\n$wpide_xmlrpc_ips = array( '127.0.0.1', '192.0.64.0/18' );\n\n$wpide_xmlrpc_check = function () use ( $wpide_xmlrpc_ips ) {\n    if ( ! defined( 'XMLRPC_REQUEST' ) || ! XMLRPC_REQUEST ) {\n        return;\n    }\n\n    $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';\n\n    foreach ( $wpide_xmlrpc_ips as $rule ) {\n        if ( $rule === $ip ) {\n            return;\n        }\n        if ( false !== strpos( $rule, '/' ) && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {\n            list( $subnet, $bits ) = explode( '/', $rule, 2 );\n            $mask = -1 << ( 32 - (int) $bits );\n            if ( ( ip2long( $ip ) & $mask ) === ( ip2long( $subnet ) & $mask ) ) {\n                return;\n            }\n        }\n    }\n\n    status_header( 403 );\n    exit;\n};\n\nif ( did_action( 'init' ) ) {\n    $wpide_xmlrpc_check();\n} else {\n    add_action( 'init', $wpide_xmlrpc_check, 0 );\n}",
            "requires": [],
            "notes": "Replace the example addresses with your own; Jetpack publishes its current IP ranges in its documentation."
        },
        {
            "id": "cli-find-shortcode-usage",
            "version": 1,
            "title": "Find Posts Using a Shortcode (WP-CLI)",
            "description": "Adds a \"wp find-shortcode\" command that lists every post, page or custom post that contains a given shortcode, with its status and edit link. Handy before removing a plugin.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "shortcodes",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'find-shortcode', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $tag        = sanitize_key( $args[0] );\n    $post_types = isset( $assoc_args['post_type'] ) ? array_map( 'sanitize_key', explode( ',', $assoc_args['post_type'] ) ) : get_post_types( array( 'public' => true ) );\n    $types_in   = implode( ',', array_fill( 0, count( $post_types ), '%s' ) );\n\n    // Cheap LIKE filter first, then confirm with has_shortcode().\n    $rows = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts}\n        WHERE post_type IN ($types_in) AND post_status NOT IN ('trash','auto-draft','inherit') AND post_content LIKE %s\n        ORDER BY ID\",\n        array_merge( $post_types, array( '%[' . $wpdb->esc_like( $tag ) . '%' ) )\n    ) );\n\n    $items = array();\n    foreach ( $rows as $row ) {\n        if ( ! has_shortcode( $row->post_content, $tag ) && false === strpos( $row->post_content, '[' . $tag . ' ' ) && false === strpos( $row->post_content, '[' . $tag . ']' ) ) {\n            continue;\n        }\n        $items[] = array(\n            'ID'     => $row->ID,\n            'type'   => $row->post_type,\n            'status' => $row->post_status,\n            'title'  => $row->post_title,\n            'edit'   => admin_url( 'post.php?post=' . $row->ID . '&action=edit' ),\n        );\n    }\n\n    if ( ! $items ) {\n        WP_CLI::success( sprintf( 'No content uses [%s].', $tag ) );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'ID', 'type', 'status', 'title', 'edit' ) );\n}, array(\n    'shortdesc' => 'Lists content that contains a shortcode.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'tag', 'description' => 'Shortcode tag, without brackets.' ),\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'description' => 'Comma-separated post types. Default: all public types.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Run it as \"wp find-shortcode gallery --post_type=post,page --format=csv\"; drafts and private posts are included."
        },
        {
            "id": "cli-find-block-usage",
            "version": 1,
            "title": "Find Posts Using a Block (WP-CLI)",
            "description": "Adds a \"wp find-block\" command that lists the content using a given block, such as core/table or a plugin's block, with how many times it appears in each post.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "blocks",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'find-block', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $name = strtolower( trim( $args[0] ) );\n    if ( false === strpos( $name, '/' ) ) {\n        $name = 'core/' . $name;\n    }\n\n    // Core blocks are stored without the \"core/\" namespace in the block comment.\n    $stored = 0 === strpos( $name, 'core/' ) ? substr( $name, 5 ) : $name;\n\n    $rows = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT ID, post_title, post_type, post_status, post_content FROM {$wpdb->posts}\n        WHERE post_status NOT IN ('trash','auto-draft','inherit') AND post_type NOT IN ('revision','customize_changeset')\n        AND post_content LIKE %s ORDER BY ID\",\n        '%<!-- wp:' . $wpdb->esc_like( $stored ) . '%'\n    ) );\n\n    $items = array();\n    foreach ( $rows as $row ) {\n        $count = preg_match_all( '/<!--\\s+wp:' . preg_quote( $stored, '/' ) . '[\\s\\/]/', $row->post_content );\n        if ( ! $count ) {\n            continue;\n        }\n        $items[] = array(\n            'ID'     => $row->ID,\n            'type'   => $row->post_type,\n            'status' => $row->post_status,\n            'uses'   => $count,\n            'title'  => $row->post_title,\n        );\n    }\n\n    if ( ! $items ) {\n        WP_CLI::success( sprintf( 'No content uses %s.', $name ) );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'ID', 'type', 'status', 'uses', 'title' ) );\n}, array(\n    'shortdesc' => 'Lists content that contains a block.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'block', 'description' => 'Block name, for example core/table.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Pass the full block name, for example \"wp find-block core/image\" or \"wp find-block acme/slider --format=csv\"."
        },
        {
            "id": "cli-search-content",
            "version": 1,
            "title": "Search Text Across All Content (WP-CLI)",
            "description": "Adds a \"wp search-content\" command that finds an exact word or phrase in titles, content and excerpts of every post type, so you can locate old links, prices or names quickly.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "search",
                "content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'search-content', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $like    = '%' . $wpdb->esc_like( $args[0] ) . '%';\n    $columns = array(\n        'title'   => 'post_title',\n        'content' => 'post_content',\n        'excerpt' => 'post_excerpt',\n    );\n    $in      = isset( $assoc_args['in'], $columns[ $assoc_args['in'] ] ) ? array( $columns[ $assoc_args['in'] ] ) : array_values( $columns );\n    $where   = implode( ' OR ', array_map( function ( $column ) {\n        return \"$column LIKE %s\";\n    }, $in ) );\n\n    $rows = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT ID, post_type, post_status, post_title FROM {$wpdb->posts}\n        WHERE post_type NOT IN ('revision','nav_menu_item','customize_changeset') AND post_status NOT IN ('trash','auto-draft')\n        AND ( $where ) ORDER BY post_type, ID LIMIT 500\",\n        array_fill( 0, count( $in ), $like )\n    ), ARRAY_A );\n\n    if ( ! $rows ) {\n        WP_CLI::success( 'No matches.' );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'ID', 'post_type', 'post_status', 'post_title' ) );\n}, array(\n    'shortdesc' => 'Finds a word or phrase in post titles, content and excerpts.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'text', 'description' => 'Text to look for.' ),\n        array( 'type' => 'assoc', 'name' => 'in', 'optional' => true, 'options' => array( 'title', 'content', 'excerpt' ) ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count', 'ids' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Matching is case-insensitive and literal; add --in=title to search titles only."
        },
        {
            "id": "cli-trim-revisions",
            "version": 1,
            "title": "Trim Old Revisions, Keeping the Latest (WP-CLI)",
            "description": "Adds a \"wp trim-revisions\" command that deletes old revisions but keeps the newest few for every post. It only reports what it would delete until you add --yes.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "revisions",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'trim-revisions', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $keep    = max( 0, (int) ( $assoc_args['keep'] ?? 5 ) );\n    $confirm = ! empty( $assoc_args['yes'] );\n\n    // Only parents that have more revisions than we keep.\n    $parents = $wpdb->get_col( $wpdb->prepare(\n        \"SELECT post_parent FROM {$wpdb->posts} WHERE post_type = 'revision' GROUP BY post_parent HAVING COUNT(*) > %d\",\n        $keep\n    ) );\n\n    $total = 0;\n    foreach ( $parents as $parent_id ) {\n        $revisions = wp_get_post_revisions( (int) $parent_id, array( 'orderby' => 'date ID', 'order' => 'DESC', 'fields' => 'ids' ) );\n        $old       = array_slice( $revisions, $keep );\n        $total    += count( $old );\n\n        if ( $confirm ) {\n            foreach ( $old as $revision_id ) {\n                wp_delete_post_revision( $revision_id );\n            }\n        }\n    }\n\n    if ( $confirm ) {\n        WP_CLI::success( sprintf( 'Deleted %d revisions from %d posts, keeping %d each.', $total, count( $parents ), $keep ) );\n    } else {\n        WP_CLI::log( sprintf( '%d revisions in %d posts would be deleted (keeping %d each). Add --yes to delete them.', $total, count( $parents ), $keep ) );\n    }\n}, array(\n    'shortdesc' => 'Deletes old post revisions, keeping the newest ones.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'keep', 'optional' => true, 'default' => 5, 'description' => 'Revisions to keep per post.' ),\n        array( 'type' => 'flag', 'name' => 'yes', 'optional' => true, 'description' => 'Actually delete. Without it the command only reports.' ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp trim-revisions --keep=3 --yes\"; take a database backup first, deleted revisions cannot be restored."
        },
        {
            "id": "cli-orphaned-meta-report",
            "version": 1,
            "title": "Find and Remove Orphaned Post Meta (WP-CLI)",
            "description": "Adds a \"wp orphaned-meta\" command that counts custom field rows left behind by deleted posts, grouped by meta key, and removes them in small batches when you add --delete.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "database",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'orphaned-meta', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $rows = $wpdb->get_results(\n        \"SELECT pm.meta_key, COUNT(*) AS rows_found FROM {$wpdb->postmeta} pm\n        LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id\n        WHERE p.ID IS NULL GROUP BY pm.meta_key ORDER BY rows_found DESC\",\n        ARRAY_A\n    );\n\n    if ( ! $rows ) {\n        WP_CLI::success( 'No orphaned post meta found.' );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( 'table', $rows, array( 'meta_key', 'rows_found' ) );\n\n    if ( empty( $assoc_args['delete'] ) ) {\n        WP_CLI::log( 'Add --delete to remove these rows.' );\n        return;\n    }\n\n    $deleted = 0;\n    do {\n        // Batches keep table locks short on big sites.\n        $ids = $wpdb->get_col(\n            \"SELECT pm.meta_id FROM {$wpdb->postmeta} pm LEFT JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE p.ID IS NULL LIMIT 1000\"\n        );\n        if ( $ids ) {\n            $deleted += (int) $wpdb->query( \"DELETE FROM {$wpdb->postmeta} WHERE meta_id IN (\" . implode( ',', array_map( 'absint', $ids ) ) . ')' );\n        }\n    } while ( count( $ids ) === 1000 );\n\n    wp_cache_flush();\n    WP_CLI::success( sprintf( 'Deleted %d orphaned meta rows.', $deleted ) );\n}, array(\n    'shortdesc' => 'Reports or deletes post meta whose post no longer exists.',\n    'synopsis'  => array(\n        array( 'type' => 'flag', 'name' => 'delete', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Back up the database before running with --delete."
        },
        {
            "id": "cli-missing-alt-report",
            "version": 1,
            "title": "Report Images Missing Alt Text (WP-CLI)",
            "description": "Adds a \"wp missing-alt\" command that lists every image in the Media Library without alt text, with the post it is attached to, so you can fix accessibility gaps in order of importance.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "media",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'missing-alt', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $rows = $wpdb->get_results(\n        \"SELECT p.ID, p.post_title, p.post_parent FROM {$wpdb->posts} p\n        LEFT JOIN {$wpdb->postmeta} m ON m.post_id = p.ID AND m.meta_key = '_wp_attachment_image_alt'\n        WHERE p.post_type = 'attachment' AND p.post_mime_type LIKE 'image/%' AND ( m.meta_value IS NULL OR m.meta_value = '' )\n        ORDER BY p.post_parent DESC, p.ID DESC\"\n    );\n\n    if ( ! $rows ) {\n        WP_CLI::success( 'Every image has alt text.' );\n        return;\n    }\n\n    $items = array();\n    foreach ( $rows as $row ) {\n        $items[] = array(\n            'ID'          => $row->ID,\n            'title'       => $row->post_title,\n            'file'        => wp_basename( (string) get_attached_file( $row->ID ) ),\n            'attached_to' => $row->post_parent ? get_the_title( $row->post_parent ) . ' (#' . $row->post_parent . ')' : '-',\n        );\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'ID', 'title', 'file', 'attached_to' ) );\n}, array(\n    'shortdesc' => 'Lists images that have no alt text.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count', 'ids' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Use --format=csv > missing-alt.csv to hand the list to an editor."
        },
        {
            "id": "cli-reassign-posts",
            "version": 1,
            "title": "Move All Posts From One Author to Another (WP-CLI)",
            "description": "Adds a \"wp reassign-posts\" command that hands every post of one user to another user, for example when a writer leaves, without deleting the old account.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "authors",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'reassign-posts', function ( $args, $assoc_args ) {\n    $find = function ( $value ) {\n        $field = is_numeric( $value ) ? 'id' : ( is_email( $value ) ? 'email' : 'login' );\n        $user  = get_user_by( $field, $value );\n        if ( ! $user ) {\n            WP_CLI::error( sprintf( 'User \"%s\" not found.', $value ) );\n        }\n        return $user;\n    };\n\n    $from = $find( $args[0] );\n    $to   = $find( $args[1] );\n\n    $ids = get_posts( array(\n        'author'         => $from->ID,\n        'post_type'      => isset( $assoc_args['post_type'] ) ? array_map( 'sanitize_key', explode( ',', $assoc_args['post_type'] ) ) : 'any',\n        'post_status'    => 'any',\n        'posts_per_page' => -1,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n    ) );\n\n    if ( ! empty( $assoc_args['dry-run'] ) ) {\n        WP_CLI::log( sprintf( '%d posts would move from %s to %s.', count( $ids ), $from->user_login, $to->user_login ) );\n        return;\n    }\n\n    $progress = WP_CLI\\Utils\\make_progress_bar( 'Reassigning', count( $ids ) );\n    foreach ( $ids as $id ) {\n        wp_update_post( array( 'ID' => $id, 'post_author' => $to->ID ) );\n        $progress->tick();\n    }\n    $progress->finish();\n\n    WP_CLI::success( sprintf( 'Moved %d posts from %s to %s.', count( $ids ), $from->user_login, $to->user_login ) );\n}, array(\n    'shortdesc' => 'Moves all posts from one author to another.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'from', 'description' => 'Current author (ID, login or email).' ),\n        array( 'type' => 'positional', 'name' => 'to', 'description' => 'New author (ID, login or email).' ),\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true ),\n        array( 'type' => 'flag', 'name' => 'dry-run', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Users can be given by ID, login or email; add --post_type=page to limit it and --dry-run to preview."
        },
        {
            "id": "cli-check-internal-links",
            "version": 1,
            "title": "Find Broken Internal Links (WP-CLI)",
            "description": "Adds a \"wp check-internal-links\" command that scans published content for links to your own site that no longer point to a post, page or file, and lists them with the post they appear in.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "links",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'check-internal-links', function ( $args, $assoc_args ) {\n    $home       = untrailingslashit( home_url() );\n    $post_types = isset( $assoc_args['post_type'] ) ? array_map( 'sanitize_key', explode( ',', $assoc_args['post_type'] ) ) : array( 'post', 'page' );\n    $items      = array();\n    $paged      = 1;\n\n    do {\n        $query = new WP_Query( array(\n            'post_type'      => $post_types,\n            'post_status'    => 'publish',\n            'posts_per_page' => 100,\n            'paged'          => $paged++,\n            'no_found_rows'  => true,\n        ) );\n\n        foreach ( $query->posts as $post ) {\n            if ( ! preg_match_all( '/href=[\"\\']([^\"\\'#]+)/i', $post->post_content, $matches ) ) {\n                continue;\n            }\n            foreach ( array_unique( $matches[1] ) as $url ) {\n                if ( 0 === strpos( $url, '/' ) && 0 !== strpos( $url, '//' ) ) {\n                    $url = $home . $url;\n                }\n                if ( 0 !== strpos( $url, $home ) ) {\n                    continue; // External link.\n                }\n                $path = (string) wp_parse_url( $url, PHP_URL_PATH );\n                if ( '' === trim( $path, '/' ) || false !== strpos( $path, '/wp-' ) ) {\n                    continue; // Homepage, admin or uploads.\n                }\n                if ( url_to_postid( $url ) || file_exists( ABSPATH . ltrim( $path, '/' ) ) ) {\n                    continue;\n                }\n                $items[] = array( 'post_id' => $post->ID, 'post_title' => $post->post_title, 'link' => $url );\n            }\n        }\n    } while ( count( $query->posts ) === 100 );\n\n    if ( ! $items ) {\n        WP_CLI::success( 'No broken internal links found.' );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'post_id', 'post_title', 'link' ) );\n}, array(\n    'shortdesc' => 'Lists internal links in content that do not resolve to anything.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'description' => 'Comma-separated post types. Default: post,page.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "It checks links without making HTTP requests, so archive, search and plugin-made URLs may show up; review the list before editing."
        },
        {
            "id": "cli-meta-keys-report",
            "version": 1,
            "title": "List Custom Field Keys With Counts (WP-CLI)",
            "description": "Adds a \"wp meta-keys\" command that lists every custom field key used by a post type and how many posts use it, which helps when cleaning up after old plugins or planning a migration.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "custom-fields",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'meta-keys', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $post_type = sanitize_key( $assoc_args['post_type'] ?? 'post' );\n    $hidden    = ! empty( $assoc_args['hidden'] ) ? '' : \"AND pm.meta_key NOT LIKE '\\\\_%'\";\n\n    $rows = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT pm.meta_key, COUNT(DISTINCT pm.post_id) AS posts FROM {$wpdb->postmeta} pm\n        INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id\n        WHERE p.post_type = %s $hidden GROUP BY pm.meta_key ORDER BY posts DESC, pm.meta_key\",\n        $post_type\n    ), ARRAY_A );\n\n    if ( ! $rows ) {\n        WP_CLI::success( sprintf( 'No custom fields found for %s.', $post_type ) );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'meta_key', 'posts' ) );\n}, array(\n    'shortdesc' => 'Lists custom field keys used by a post type.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'default' => 'post' ),\n        array( 'type' => 'flag', 'name' => 'hidden', 'optional' => true, 'description' => 'Include keys starting with an underscore.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Add --hidden to include keys that start with an underscore."
        },
        {
            "id": "cli-content-audit",
            "version": 1,
            "title": "Content Audit Summary (WP-CLI)",
            "description": "Adds a \"wp content-audit\" command that shows, per post type, how many items are published, drafts, missing a featured image, missing an excerpt and not updated for two years.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "content",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'content-audit', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $stale_days = max( 1, (int) ( $assoc_args['stale-days'] ?? 730 ) );\n    $cutoff     = gmdate( 'Y-m-d H:i:s', time() - $stale_days * DAY_IN_SECONDS );\n    $items      = array();\n\n    foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $type ) {\n        if ( 'attachment' === $type->name ) {\n            continue;\n        }\n        $counts = wp_count_posts( $type->name );\n        $row    = $wpdb->get_row( $wpdb->prepare(\n            \"SELECT\n                SUM( t.meta_id IS NULL ) AS no_image,\n                SUM( p.post_excerpt = '' ) AS no_excerpt,\n                SUM( p.post_modified_gmt < %s ) AS stale\n            FROM {$wpdb->posts} p\n            LEFT JOIN {$wpdb->postmeta} t ON t.post_id = p.ID AND t.meta_key = '_thumbnail_id'\n            WHERE p.post_type = %s AND p.post_status = 'publish'\",\n            $cutoff,\n            $type->name\n        ) );\n\n        $items[] = array(\n            'post_type'  => $type->name,\n            'published'  => (int) $counts->publish,\n            'drafts'     => (int) $counts->draft,\n            'no_image'   => post_type_supports( $type->name, 'thumbnail' ) ? (int) $row->no_image : '-',\n            'no_excerpt' => (int) $row->no_excerpt,\n            'stale'      => (int) $row->stale,\n        );\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'post_type', 'published', 'drafts', 'no_image', 'no_excerpt', 'stale' ) );\n}, array(\n    'shortdesc' => 'Summarizes content health per post type.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'stale-days', 'optional' => true, 'default' => 730 ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Change the stale threshold with --stale-days=365."
        },
        {
            "id": "cli-change-post-status",
            "version": 1,
            "title": "Change the Status of Many Posts at Once (WP-CLI)",
            "description": "Adds a \"wp change-status\" command that moves posts from one status to another, for example every draft older than a date back to pending, with a preview count before anything changes.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "post-status",
                "bulk"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'change-status', function ( $args, $assoc_args ) {\n    $from     = sanitize_key( $assoc_args['from'] );\n    $to       = sanitize_key( $assoc_args['to'] );\n    $statuses = get_post_stati();\n\n    if ( ! isset( $statuses[ $from ], $statuses[ $to ] ) ) {\n        WP_CLI::error( 'Unknown post status.' );\n    }\n\n    $query_args = array(\n        'post_type'      => sanitize_key( $assoc_args['post_type'] ?? 'post' ),\n        'post_status'    => $from,\n        'posts_per_page' => -1,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n    );\n    if ( ! empty( $assoc_args['before'] ) ) {\n        $query_args['date_query'] = array( array( 'before' => sanitize_text_field( $assoc_args['before'] ) ) );\n    }\n\n    $ids = get_posts( $query_args );\n\n    if ( empty( $assoc_args['yes'] ) ) {\n        WP_CLI::log( sprintf( '%d posts would change from %s to %s. Add --yes to apply.', count( $ids ), $from, $to ) );\n        return;\n    }\n\n    foreach ( $ids as $id ) {\n        wp_update_post( array( 'ID' => $id, 'post_status' => $to ) );\n    }\n\n    WP_CLI::success( sprintf( 'Changed %d posts from %s to %s.', count( $ids ), $from, $to ) );\n}, array(\n    'shortdesc' => 'Moves posts from one status to another.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'from', 'description' => 'Current status, for example draft.' ),\n        array( 'type' => 'assoc', 'name' => 'to', 'description' => 'New status, for example pending.' ),\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'default' => 'post' ),\n        array( 'type' => 'assoc', 'name' => 'before', 'optional' => true, 'description' => 'Only posts dated before this date (Y-m-d).' ),\n        array( 'type' => 'flag', 'name' => 'yes', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp change-status --from=draft --to=pending --before=2024-01-01 --yes\"; without --yes it only counts."
        },
        {
            "id": "cli-list-all-urls",
            "version": 1,
            "title": "Export Every Public URL (WP-CLI)",
            "description": "Adds a \"wp list-urls\" command that prints the address of every published post, page, custom post and term archive, ready to build a redirect map before a migration or redesign.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "urls",
                "migration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'list-urls', function ( $args, $assoc_args ) {\n    $items = array( array( 'type' => 'home', 'id' => 0, 'url' => home_url( '/' ) ) );\n\n    foreach ( get_post_types( array( 'public' => true ) ) as $post_type ) {\n        if ( 'attachment' === $post_type ) {\n            continue;\n        }\n        $paged = 1;\n        do {\n            $ids = get_posts( array(\n                'post_type'      => $post_type,\n                'post_status'    => 'publish',\n                'posts_per_page' => 500,\n                'paged'          => $paged++,\n                'fields'         => 'ids',\n                'no_found_rows'  => true,\n                'orderby'        => 'ID',\n                'order'          => 'ASC',\n            ) );\n            foreach ( $ids as $id ) {\n                $items[] = array( 'type' => $post_type, 'id' => $id, 'url' => get_permalink( $id ) );\n            }\n        } while ( count( $ids ) === 500 );\n    }\n\n    if ( WP_CLI\\Utils\\get_flag_value( $assoc_args, 'terms', true ) ) {\n        foreach ( get_taxonomies( array( 'public' => true ) ) as $taxonomy ) {\n            foreach ( get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => true ) ) as $term ) {\n                $items[] = array( 'type' => $taxonomy, 'id' => $term->term_id, 'url' => get_term_link( $term ) );\n            }\n        }\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'type', 'id', 'url' ) );\n}, array(\n    'shortdesc' => 'Lists the URL of every published item.',\n    'synopsis'  => array(\n        array( 'type' => 'flag', 'name' => 'terms', 'optional' => true, 'default' => true, 'description' => 'Include term archives. Use --no-terms to skip.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Save the list with \"wp list-urls --format=csv > urls.csv\"; add --no-terms to skip category and tag archives."
        },
        {
            "id": "cli-empty-terms",
            "version": 1,
            "title": "Find and Delete Unused Tags and Categories (WP-CLI)",
            "description": "Adds a \"wp empty-terms\" command that lists the terms of a taxonomy that no post uses, and deletes them when you add --delete. The default category is always kept.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "taxonomies",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'empty-terms', function ( $args, $assoc_args ) {\n    $taxonomy = sanitize_key( $assoc_args['taxonomy'] ?? 'post_tag' );\n    if ( ! taxonomy_exists( $taxonomy ) ) {\n        WP_CLI::error( sprintf( 'Taxonomy \"%s\" does not exist.', $taxonomy ) );\n    }\n\n    // Recount first so the \"count\" column is trustworthy.\n    $all = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'fields' => 'tt_ids' ) );\n    wp_update_term_count_now( $all, $taxonomy );\n\n    $keep  = 'category' === $taxonomy ? (int) get_option( 'default_category' ) : 0;\n    $terms = array_filter( get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false ) ), function ( $term ) use ( $keep ) {\n        return 0 === (int) $term->count && (int) $term->term_id !== $keep;\n    } );\n\n    if ( ! $terms ) {\n        WP_CLI::success( 'No unused terms.' );\n        return;\n    }\n\n    if ( empty( $assoc_args['delete'] ) ) {\n        WP_CLI\\Utils\\format_items( 'table', array_map( function ( $term ) {\n            return array( 'term_id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug );\n        }, $terms ), array( 'term_id', 'name', 'slug' ) );\n        WP_CLI::log( 'Add --delete to remove them.' );\n        return;\n    }\n\n    foreach ( $terms as $term ) {\n        wp_delete_term( $term->term_id, $taxonomy );\n    }\n    WP_CLI::success( sprintf( 'Deleted %d unused terms.', count( $terms ) ) );\n}, array(\n    'shortdesc' => 'Lists or deletes terms with no posts.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'taxonomy', 'optional' => true, 'default' => 'post_tag' ),\n        array( 'type' => 'flag', 'name' => 'delete', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp empty-terms --taxonomy=post_tag --delete\"."
        },
        {
            "id": "rest-reading-time-field",
            "version": 1,
            "title": "Word Count and Reading Time in the REST API",
            "description": "Adds word_count and reading_time fields to posts in the REST API, so headless front ends and apps can show \"5 min read\" without counting words themselves.",
            "category": "developer",
            "tags": [
                "rest-api",
                "reading-time",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed.\n$wpide_words_per_minute = 220;\n// Post types that get the fields.\n$wpide_reading_post_types = array( 'post', 'page' );\n\nadd_action( 'rest_api_init', function () use ( $wpide_words_per_minute, $wpide_reading_post_types ) {\n    $count = function ( $object ) {\n        $post = get_post( $object['id'] );\n        return $post ? str_word_count( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) ) : 0;\n    };\n\n    register_rest_field( $wpide_reading_post_types, 'word_count', array(\n        'get_callback' => $count,\n        'schema'       => array( 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true ),\n    ) );\n\n    register_rest_field( $wpide_reading_post_types, 'reading_time', array(\n        'get_callback' => function ( $object ) use ( $count, $wpide_words_per_minute ) {\n            return max( 1, (int) ceil( $count( $object ) / max( 1, $wpide_words_per_minute ) ) );\n        },\n        'schema'       => array( 'description' => 'Estimated reading time in minutes.', 'type' => 'integer', 'context' => array( 'view', 'edit', 'embed' ), 'readonly' => true ),\n    ) );\n} );",
            "requires": [],
            "notes": "Change $wpide_words_per_minute to match your audience; add more post types to $wpide_reading_post_types."
        },
        {
            "id": "rest-author-details-field",
            "version": 1,
            "title": "Author Name and Avatar in REST API Posts",
            "description": "Adds an author_details field with the author's display name, profile link and avatar URL to every post in the REST API, saving apps a second request or a heavy _embed call.",
            "category": "developer",
            "tags": [
                "rest-api",
                "authors",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_field( 'post', 'author_details', array(\n        'get_callback' => function ( $object ) {\n            $author_id = (int) get_post_field( 'post_author', $object['id'] );\n            if ( ! $author_id ) {\n                return null;\n            }\n\n            return array(\n                'id'     => $author_id,\n                'name'   => get_the_author_meta( 'display_name', $author_id ),\n                'url'    => get_author_posts_url( $author_id ),\n                'avatar' => get_avatar_url( $author_id, array( 'size' => 96 ) ),\n            );\n        },\n        'schema'       => array(\n            'type'     => array( 'object', 'null' ),\n            'context'  => array( 'view', 'embed' ),\n            'readonly' => true,\n        ),\n    ) );\n} );",
            "requires": [],
            "notes": "Only public profile data is exposed; the author's email and login are never included."
        },
        {
            "id": "rest-menu-endpoint",
            "version": 1,
            "title": "Navigation Menus as a REST API Endpoint",
            "description": "Adds a public /wp-json/site/v1/menu/{location} endpoint that returns the menu assigned to a theme location as a nested list, ready for a headless or app front end.",
            "category": "developer",
            "tags": [
                "rest-api",
                "menus",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/menu/(?P<location>[a-z0-9_-]+)', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true', // Menus are public on the site anyway.\n        'callback'            => function ( WP_REST_Request $request ) {\n            $locations = get_nav_menu_locations();\n            $location  = $request['location'];\n\n            if ( empty( $locations[ $location ] ) ) {\n                return new WP_Error( 'no_menu', 'No menu is assigned to this location.', array( 'status' => 404 ) );\n            }\n\n            $items = wp_get_nav_menu_items( $locations[ $location ] );\n            if ( ! $items ) {\n                return rest_ensure_response( array() );\n            }\n\n            $by_parent = array();\n            foreach ( $items as $item ) {\n                $by_parent[ (int) $item->menu_item_parent ][] = $item;\n            }\n\n            $build = function ( $parent_id ) use ( &$build, $by_parent ) {\n                $out = array();\n                foreach ( $by_parent[ $parent_id ] ?? array() as $item ) {\n                    $out[] = array(\n                        'id'       => (int) $item->ID,\n                        'title'    => wp_strip_all_tags( $item->title ),\n                        'url'      => $item->url,\n                        'target'   => $item->target,\n                        'classes'  => array_values( array_filter( (array) $item->classes ) ),\n                        'children' => $build( (int) $item->ID ),\n                    );\n                }\n                return $out;\n            };\n\n            return rest_ensure_response( $build( 0 ) );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Works with classic menu locations (Appearance > Menus); block themes that only use the Navigation block have no locations to return."
        },
        {
            "id": "rest-site-stats-endpoint",
            "version": 1,
            "title": "Private Site Statistics REST Endpoint",
            "description": "Adds a /wp-json/site/v1/stats endpoint that returns post, page, comment and user counts to logged-in editors or application-password clients, for reporting tools and status boards.",
            "category": "developer",
            "tags": [
                "rest-api",
                "statistics",
                "reporting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/stats', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => function () {\n            return current_user_can( 'edit_others_posts' );\n        },\n        'callback'            => function () {\n            $stats = get_transient( 'wpide_rest_site_stats' );\n\n            if ( false === $stats ) {\n                $comments = wp_count_comments();\n                $users    = count_users();\n                $stats    = array(\n                    'posts'            => (int) wp_count_posts( 'post' )->publish,\n                    'pages'            => (int) wp_count_posts( 'page' )->publish,\n                    'drafts'           => (int) wp_count_posts( 'post' )->draft,\n                    'scheduled'        => (int) wp_count_posts( 'post' )->future,\n                    'comments'         => (int) $comments->approved,\n                    'comments_pending' => (int) $comments->moderated,\n                    'users'            => (int) $users['total_users'],\n                    'generated'        => gmdate( 'c' ),\n                );\n                set_transient( 'wpide_rest_site_stats', $stats, 10 * MINUTE_IN_SECONDS );\n            }\n\n            return rest_ensure_response( $stats );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Access needs the edit_others_posts capability; use an application password to call it from another server."
        },
        {
            "id": "rest-random-post-endpoint",
            "version": 1,
            "title": "Random Post REST Endpoint",
            "description": "Adds a public /wp-json/site/v1/random endpoint that returns one random published post with its title, link and excerpt, for \"surprise me\" buttons or widgets.",
            "category": "developer",
            "tags": [
                "rest-api",
                "random",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/random', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true',\n        'callback'            => function () {\n            $ids = get_transient( 'wpide_random_post_ids' );\n\n            if ( false === $ids ) {\n                $ids = get_posts( array(\n                    'post_type'      => 'post',\n                    'post_status'    => 'publish',\n                    'posts_per_page' => 500,\n                    'fields'         => 'ids',\n                    'no_found_rows'  => true,\n                    'has_password'   => false,\n                ) );\n                set_transient( 'wpide_random_post_ids', $ids, HOUR_IN_SECONDS );\n            }\n\n            if ( ! $ids ) {\n                return new WP_Error( 'no_posts', 'No posts found.', array( 'status' => 404 ) );\n            }\n\n            $post = get_post( $ids[ array_rand( $ids ) ] );\n\n            return rest_ensure_response( array(\n                'id'      => $post->ID,\n                'title'   => get_the_title( $post ),\n                'link'    => get_permalink( $post ),\n                'excerpt' => wp_strip_all_tags( get_the_excerpt( $post ) ),\n            ) );\n        },\n    ) );\n} );\n\nadd_action( 'save_post_post', function () {\n    delete_transient( 'wpide_random_post_ids' );\n} );",
            "requires": [],
            "notes": "The list of candidate IDs is cached for an hour and refreshed when you publish or update a post."
        },
        {
            "id": "rest-uptime-ping-endpoint",
            "version": 1,
            "title": "Uptime Check Endpoint That Tests the Database",
            "description": "Adds a lightweight /wp-json/site/v1/ping endpoint for uptime monitors. It answers 200 only when WordPress can reach the database, and 503 otherwise, without exposing any versions.",
            "category": "developer",
            "tags": [
                "rest-api",
                "uptime",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/ping', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true',\n        'callback'            => function () {\n            global $wpdb;\n\n            $db_ok    = '1' === (string) $wpdb->get_var( 'SELECT 1' );\n            $response = new WP_REST_Response( array(\n                'status' => $db_ok ? 'ok' : 'database_error',\n                'time'   => time(),\n            ), $db_ok ? 200 : 503 );\n\n            $response->header( 'Cache-Control', 'no-store, max-age=0' );\n\n            return $response;\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Point your monitor at https://example.com/wp-json/site/v1/ping and alert on any status other than 200."
        },
        {
            "id": "rest-allow-random-order",
            "version": 1,
            "title": "Allow Random Order in REST API Post Lists",
            "description": "Lets REST API clients request posts with orderby=rand, for example to show a few random articles in an app, which WordPress does not allow by default.",
            "category": "developer",
            "tags": [
                "rest-api",
                "random",
                "query"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'rest_post_collection_params', function ( $params ) {\n    if ( isset( $params['orderby']['enum'] ) && ! in_array( 'rand', $params['orderby']['enum'], true ) ) {\n        $params['orderby']['enum'][] = 'rand';\n    }\n\n    return $params;\n} );\n\nadd_filter( 'rest_post_query', function ( $args, $request ) {\n    if ( 'rand' === $request->get_param( 'orderby' ) ) {\n        $args['orderby'] = 'rand';\n    }\n\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Random ordering cannot be cached well by CDNs; keep per_page small."
        },
        {
            "id": "rest-noindex-header",
            "version": 1,
            "title": "Keep REST API Responses Out of Search Results",
            "description": "Sends an X-Robots-Tag: noindex header with every REST API response so search engines do not index raw JSON from /wp-json/ next to your real pages.",
            "category": "developer",
            "tags": [
                "rest-api",
                "seo",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'rest_post_dispatch', function ( $response ) {\n    if ( $response instanceof WP_REST_Response ) {\n        $response->header( 'X-Robots-Tag', 'noindex, nofollow' );\n    }\n\n    return $response;\n} );",
            "requires": [],
            "notes": "This does not block access to the API; it only asks search engines not to index the responses."
        },
        {
            "id": "rest-error-log",
            "version": 1,
            "title": "REST API Error Log Under Tools",
            "description": "Records REST API requests that fail with a 4xx or 5xx status, with the route, method, user and error code, and lists the latest 100 under Tools > REST Errors for debugging integrations.",
            "category": "developer",
            "tags": [
                "rest-api",
                "logging",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Keep this many entries.\n$wpide_rest_log_size = 100;\n// Log 404 responses too.\n$wpide_rest_log_404 = true;\n\nadd_filter( 'rest_request_after_callbacks', function ( $response, $handler, $request ) use ( $wpide_rest_log_size, $wpide_rest_log_404 ) {\n    if ( is_wp_error( $response ) ) {\n        $data   = $response->get_error_data();\n        $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;\n        $code   = $response->get_error_code();\n    } elseif ( $response instanceof WP_HTTP_Response ) {\n        $data   = $response->get_data();\n        $status = $response->get_status();\n        $code   = is_array( $data ) && isset( $data['code'] ) ? $data['code'] : '';\n    } else {\n        return $response;\n    }\n\n    if ( $status < 400 || ( 404 === $status && ! $wpide_rest_log_404 ) ) {\n        return $response;\n    }\n\n    $log   = (array) get_option( 'wpide_rest_error_log', array() );\n    $log[] = array(\n        'time'   => time(),\n        'method' => $request->get_method(),\n        'route'  => $request->get_route(),\n        'status' => $status,\n        'code'   => (string) $code,\n        'user'   => get_current_user_id(),\n    );\n\n    update_option( 'wpide_rest_error_log', array_slice( $log, -$wpide_rest_log_size ), false );\n\n    return $response;\n}, 10, 3 );\n\nadd_action( 'admin_menu', function () {\n    add_management_page( 'REST Errors', 'REST Errors', 'manage_options', 'wpide-rest-errors', function () {\n        $log = array_reverse( (array) get_option( 'wpide_rest_error_log', array() ) );\n\n        echo '<div class=\"wrap\"><h1>REST API Errors</h1>';\n        if ( ! $log ) {\n            echo '<p>No errors recorded yet.</p></div>';\n            return;\n        }\n        echo '<table class=\"widefat striped\"><thead><tr><th>When</th><th>Status</th><th>Method</th><th>Route</th><th>Code</th><th>User</th></tr></thead><tbody>';\n        foreach ( $log as $row ) {\n            $user = $row['user'] ? get_userdata( $row['user'] ) : false;\n            printf(\n                '<tr><td>%s</td><td>%d</td><td>%s</td><td><code>%s</code></td><td>%s</td><td>%s</td></tr>',\n                esc_html( wp_date( 'Y-m-d H:i:s', $row['time'] ) ),\n                (int) $row['status'],\n                esc_html( $row['method'] ),\n                esc_html( $row['route'] ),\n                esc_html( $row['code'] ),\n                esc_html( $user ? $user->user_login : 'visitor' )\n            );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Set $wpide_rest_log_404 to false to skip \"not found\" errors from bots; the log is stored in one non-autoloaded option."
        },
        {
            "id": "rest-adjacent-posts-field",
            "version": 1,
            "title": "Previous and Next Post Links in the REST API",
            "description": "Adds a navigation field to single posts in the REST API with the ID, title and link of the previous and next posts, so apps can build \"read next\" buttons.",
            "category": "developer",
            "tags": [
                "rest-api",
                "navigation",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_field( 'post', 'navigation', array(\n        'get_callback' => function ( $object, $field, $request ) {\n            // Skip collection requests: adjacent-post lookups are one query each.\n            if ( ! $request->get_param( 'id' ) ) {\n                return null;\n            }\n\n            $current = get_post( $object['id'] );\n            if ( ! $current ) {\n                return null;\n            }\n\n            $GLOBALS['post'] = $current;\n            $format          = function ( $post ) {\n                return $post ? array( 'id' => $post->ID, 'title' => get_the_title( $post ), 'link' => get_permalink( $post ) ) : null;\n            };\n            $result = array(\n                'previous' => $format( get_previous_post() ),\n                'next'     => $format( get_next_post() ),\n            );\n            wp_reset_postdata();\n\n            return $result;\n        },\n        'schema'       => array( 'type' => array( 'object', 'null' ), 'context' => array( 'view' ), 'readonly' => true ),\n    ) );\n} );",
            "requires": [],
            "notes": "The field is only computed for single-post requests, so post lists stay fast."
        },
        {
            "id": "rest-webhook-on-publish",
            "version": 1,
            "title": "Send a Webhook When a Post Is Published",
            "description": "Posts a small JSON payload (ID, title, link, author and date) to a URL of your choice whenever a post goes live, to trigger a static site build, a Zapier flow or a chat notification.",
            "category": "developer",
            "tags": [
                "webhook",
                "publishing",
                "integrations"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Where to send the webhook (leave empty to disable).\n$wpide_webhook_url = 'https://example.com/hooks/wordpress';\n// Post types that trigger it.\n$wpide_webhook_post_types = array( 'post' );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) use ( $wpide_webhook_url, $wpide_webhook_post_types ) {\n    if ( '' === $wpide_webhook_url || 'publish' !== $new_status || 'publish' === $old_status || ! in_array( $post->post_type, $wpide_webhook_post_types, true ) ) {\n        return;\n    }\n    if ( wp_is_post_revision( $post ) || wp_is_post_autosave( $post ) ) {\n        return;\n    }\n\n    wp_remote_post( $wpide_webhook_url, array(\n        'timeout'  => 3,\n        'blocking' => false,\n        'headers'  => array( 'Content-Type' => 'application/json' ),\n        'body'     => wp_json_encode( array(\n            'event'  => 'post_published',\n            'id'     => $post->ID,\n            'title'  => get_the_title( $post ),\n            'link'   => get_permalink( $post ),\n            'author' => get_the_author_meta( 'display_name', $post->post_author ),\n            'date'   => get_post_time( 'c', true, $post ),\n            'site'   => home_url( '/' ),\n        ) ),\n    ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Set $wpide_webhook_url; the request is non-blocking with a short timeout so publishing never waits for the receiving service."
        },
        {
            "id": "cron-weekly-monthly-schedules",
            "version": 1,
            "title": "Weekly and Monthly WP-Cron Schedules",
            "description": "Adds \"Once Weekly\" and \"Once Monthly\" recurrences to WP-Cron so your own scheduled tasks, or plugins that offer a schedule picker, can run less often than daily.",
            "category": "developer",
            "tags": [
                "cron",
                "schedules",
                "wp-cron"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'cron_schedules', function ( $schedules ) {\n    $schedules['wpide_weekly'] = array(\n        'interval' => WEEK_IN_SECONDS,\n        'display'  => __( 'Once Weekly' ),\n    );\n    $schedules['wpide_monthly'] = array(\n        'interval' => MONTH_IN_SECONDS,\n        'display'  => __( 'Once Monthly' ),\n    );\n\n    return $schedules;\n} );",
            "requires": [],
            "notes": "Use the keys \"wpide_weekly\" and \"wpide_monthly\" with wp_schedule_event(); WordPress 5.4+ already has \"weekly\", which this keeps untouched."
        },
        {
            "id": "cron-run-later-helper",
            "version": 1,
            "title": "Run a Task Later Helper Function",
            "description": "Adds a wpide_run_later() helper that schedules any hook to run once after a delay, without creating duplicates, so slow work like sending a report or calling an API can leave the page request.",
            "category": "developer",
            "tags": [
                "cron",
                "helper",
                "background"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_run_later' ) ) {\n    /**\n     * Schedules $hook to run once after $delay seconds with $args.\n     * Returns true when scheduled, false if the same task is already queued.\n     */\n    function wpide_run_later( $hook, $delay = 60, $args = array() ) {\n        $args = array_values( (array) $args );\n\n        if ( wp_next_scheduled( $hook, $args ) ) {\n            return false;\n        }\n\n        return true === wp_schedule_single_event( time() + max( 1, (int) $delay ), $hook, $args, true );\n    }\n}",
            "requires": [],
            "notes": "Example wpide_run_later( 'my_send_report', 5 * MINUTE_IN_SECONDS, array( $order_id ) ), then add_action( 'my_send_report', ... ) to do the work."
        },
        {
            "id": "cron-pause-outside-production",
            "version": 1,
            "title": "Pause WP-Cron Outside Production",
            "description": "Stops scheduled tasks from running on local, development and staging copies of the site, so a cloned database does not send newsletters, sync orders or run backups twice.",
            "category": "developer",
            "tags": [
                "cron",
                "staging",
                "environment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_get_ready_cron_jobs', function ( $jobs ) {\n    if ( 'production' === wp_get_environment_type() || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return $jobs;\n    }\n\n    return array(); // Nothing is due, so wp-cron.php does nothing.\n} );",
            "requires": [],
            "notes": "It relies on WP_ENVIRONMENT_TYPE in wp-config.php; tasks still run if you trigger them manually with WP-CLI."
        },
        {
            "id": "debug-conditional-tags-admin-bar",
            "version": 1,
            "title": "Show Which Conditional Tags Are True in the Admin Bar",
            "description": "Adds a \"Conditionals\" menu to the front-end admin bar for administrators listing which template conditions are true on the current page, such as is_singular(), is_archive() or is_front_page().",
            "category": "developer",
            "tags": [
                "debugging",
                "admin-bar",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Conditional tags to check.\n$wpide_conditionals = array( 'is_front_page', 'is_home', 'is_singular', 'is_single', 'is_page', 'is_attachment', 'is_archive', 'is_category', 'is_tag', 'is_tax', 'is_author', 'is_date', 'is_post_type_archive', 'is_search', 'is_404', 'is_paged', 'is_preview', 'is_feed', 'is_embed' );\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) use ( $wpide_conditionals ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $true = array_filter( $wpide_conditionals, function ( $tag ) {\n        return function_exists( $tag ) && call_user_func( $tag );\n    } );\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-conditionals',\n        'title' => 'Conditionals (' . count( $true ) . ')',\n    ) );\n\n    foreach ( $true as $tag ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-cond-' . $tag,\n            'parent' => 'wpide-conditionals',\n            'title'  => esc_html( $tag . '()' ),\n        ) );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Add your own checks, such as is_woocommerce, to $wpide_conditionals; functions that do not exist are skipped."
        },
        {
            "id": "debug-list-fired-hooks",
            "version": 1,
            "title": "List Every Hook Fired on a Page",
            "description": "Lets administrators add ?wpide_hooks=1 to any front-end URL to see, at the bottom of the page, every action and filter that ran and how many times, in firing order.",
            "category": "developer",
            "tags": [
                "debugging",
                "hooks",
                "actions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "if ( isset( $_GET['wpide_hooks'] ) ) {\n    $wpide_hook_counts = array();\n\n    add_action( 'all', function ( $hook ) use ( &$wpide_hook_counts ) {\n        $wpide_hook_counts[ $hook ] = ( $wpide_hook_counts[ $hook ] ?? 0 ) + 1;\n    } );\n\n    add_action( 'wp_footer', function () use ( &$wpide_hook_counts ) {\n        if ( ! current_user_can( 'manage_options' ) ) {\n            return;\n        }\n\n        echo '<details open style=\"margin:2rem;padding:1rem;background:#fff;color:#1d2327;border:1px solid #c3c4c7;font:13px/1.5 monospace;position:relative;z-index:99999\">';\n        echo '<summary><strong>' . count( $wpide_hook_counts ) . ' hooks fired</strong></summary><ol style=\"columns:2;margin:1rem 0 0 1.5rem\">';\n        foreach ( $wpide_hook_counts as $hook => $count ) {\n            echo '<li>' . esc_html( $hook ) . ( $count > 1 ? ' <em>&times;' . (int) $count . '</em>' : '' ) . '</li>';\n        }\n        echo '</ol></details>';\n    }, PHP_INT_MAX );\n}",
            "requires": [],
            "notes": "Tracking only starts when the parameter is present, so normal page loads are not slowed down."
        },
        {
            "id": "debug-template-parts-admin-bar",
            "version": 1,
            "title": "Show the Template Parts Used on a Page",
            "description": "Adds a \"Template parts\" menu to the front-end admin bar for administrators listing every template part loaded on the page, for both classic themes (get_template_part) and block themes.",
            "category": "developer",
            "tags": [
                "debugging",
                "templates",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_template_parts = array();\n\nadd_action( 'get_template_part', function ( $slug, $name ) use ( &$wpide_template_parts ) {\n    $wpide_template_parts[] = $name ? \"$slug-$name\" : $slug;\n}, 10, 2 );\n\nadd_filter( 'render_block_core/template-part', function ( $content, $block ) use ( &$wpide_template_parts ) {\n    if ( ! empty( $block['attrs']['slug'] ) ) {\n        $wpide_template_parts[] = 'block: ' . $block['attrs']['slug'];\n    }\n    return $content;\n}, 10, 2 );\n\n// The admin bar renders in the footer, after the template parts have loaded.\nadd_action( 'admin_bar_menu', function ( $admin_bar ) use ( &$wpide_template_parts ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $parts = array_values( array_unique( $wpide_template_parts ) );\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-template-parts',\n        'title' => 'Template parts (' . count( $parts ) . ')',\n    ) );\n\n    foreach ( $parts as $i => $part ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-tpart-' . $i,\n            'parent' => 'wpide-template-parts',\n            'title'  => esc_html( $part ),\n        ) );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Pair it with a template viewer to see the full picture of which files build a page."
        },
        {
            "id": "debug-main-query-inspector",
            "version": 1,
            "title": "Inspect the Main Query and Its SQL",
            "description": "Lets administrators add ?wpide_query=1 to a front-end URL to print the main query's variables, the SQL it ran and the number of posts found at the bottom of the page.",
            "category": "developer",
            "tags": [
                "debugging",
                "wp-query",
                "sql"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    if ( ! isset( $_GET['wpide_query'] ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    global $wp_query;\n    $vars = array_filter( (array) $wp_query->query_vars, function ( $value ) {\n        return '' !== $value && array() !== $value && null !== $value && false !== $value;\n    } );\n\n    echo '<div style=\"margin:2rem;padding:1rem;background:#fff;color:#1d2327;border:1px solid #c3c4c7;font:13px/1.5 monospace;position:relative;z-index:99999;overflow:auto\">';\n    printf( '<p><strong>Found posts:</strong> %d &middot; <strong>Max pages:</strong> %d &middot; <strong>Queried object:</strong> %s</p>', (int) $wp_query->found_posts, (int) $wp_query->max_num_pages, esc_html( is_object( get_queried_object() ) ? get_class( get_queried_object() ) . ' #' . get_queried_object_id() : 'none' ) );\n    echo '<p><strong>SQL</strong></p><pre style=\"white-space:pre-wrap\">' . esc_html( (string) $wp_query->request ) . '</pre>';\n    echo '<p><strong>Query vars</strong></p><pre style=\"white-space:pre-wrap\">' . esc_html( print_r( $vars, true ) ) . '</pre></div>';\n}, PHP_INT_MAX );",
            "requires": [],
            "notes": "Useful when a pre_get_posts change does not behave as expected."
        },
        {
            "id": "debug-log-deprecated-notices",
            "version": 1,
            "title": "Log Deprecated and \"Doing It Wrong\" Notices With Their Source",
            "description": "Writes every deprecated function, argument, hook or \"doing it wrong\" notice to the PHP error log with the plugin or theme file that caused it, even when WP_DEBUG is off.",
            "category": "developer",
            "tags": [
                "debugging",
                "deprecated",
                "logging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_log_notice = function ( $kind, $message ) {\n    static $seen = array();\n    $key = md5( $kind . $message );\n    if ( isset( $seen[ $key ] ) ) {\n        return;\n    }\n    $seen[ $key ] = true;\n\n    // Find the first frame outside WordPress core.\n    $source = '';\n    foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 25 ) as $frame ) {\n        if ( empty( $frame['file'] ) || false !== strpos( $frame['file'], ABSPATH . WPINC ) || false !== strpos( $frame['file'], ABSPATH . 'wp-admin' ) ) {\n            continue;\n        }\n        $source = str_replace( ABSPATH, '', $frame['file'] ) . ':' . ( $frame['line'] ?? 0 );\n        break;\n    }\n\n    error_log( sprintf( '[WP %s] %s%s', $kind, $message, $source ? ' (from ' . $source . ')' : '' ) );\n};\n\nadd_action( 'deprecated_function_run', function ( $function, $replacement, $version ) use ( $wpide_log_notice ) {\n    $wpide_log_notice( 'deprecated function', \"$function() since $version\" . ( $replacement ? \", use $replacement\" : '' ) );\n}, 10, 3 );\n\nadd_action( 'deprecated_argument_run', function ( $function, $message, $version ) use ( $wpide_log_notice ) {\n    $wpide_log_notice( 'deprecated argument', \"$function() since $version: $message\" );\n}, 10, 3 );\n\nadd_action( 'deprecated_hook_run', function ( $hook, $replacement, $version ) use ( $wpide_log_notice ) {\n    $wpide_log_notice( 'deprecated hook', \"$hook since $version\" . ( $replacement ? \", use $replacement\" : '' ) );\n}, 10, 3 );\n\nadd_action( 'doing_it_wrong_run', function ( $function, $message, $version ) use ( $wpide_log_notice ) {\n    $wpide_log_notice( 'doing it wrong', \"$function(): \" . wp_strip_all_tags( $message ) );\n}, 10, 3 );",
            "requires": [],
            "notes": "Turn it on before upgrading WordPress or PHP, then check the error log; each distinct message is logged once per request."
        },
        {
            "id": "debug-console-log-helper",
            "version": 1,
            "title": "Send PHP Values to the Browser Console",
            "description": "Adds a wpide_console() helper that prints any PHP variable to the browser's developer console for administrators, so you can inspect data without breaking the page layout.",
            "category": "developer",
            "tags": [
                "debugging",
                "helper",
                "console"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_console' ) ) {\n    function wpide_console( $value, $label = '' ) {\n        if ( ! current_user_can( 'manage_options' ) ) {\n            return;\n        }\n        $GLOBALS['wpide_console_queue'][] = array( (string) $label, $value );\n    }\n}\n\n$wpide_console_flush = function () {\n    if ( empty( $GLOBALS['wpide_console_queue'] ) ) {\n        return;\n    }\n    echo '<script>';\n    foreach ( $GLOBALS['wpide_console_queue'] as $entry ) {\n        list( $label, $value ) = $entry;\n        printf( 'console.log(%s, %s);', wp_json_encode( $label ? \"PHP: $label\" : 'PHP:' ), wp_json_encode( $value, JSON_PARTIAL_OUTPUT_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP ) );\n    }\n    echo '</script>';\n    $GLOBALS['wpide_console_queue'] = array();\n};\n\nadd_action( 'wp_footer', $wpide_console_flush, PHP_INT_MAX );\nadd_action( 'admin_footer', $wpide_console_flush, PHP_INT_MAX );",
            "requires": [],
            "notes": "Example wpide_console( $post, 'Current post' ); values are queued and printed in the footer of front-end and admin pages."
        },
        {
            "id": "debug-file-logger-helper",
            "version": 1,
            "title": "Private Log File Helper Function",
            "description": "Adds a wpide_log() helper that writes timestamped messages and data to a daily log file in a protected folder inside uploads, so custom code can leave a trail without touching debug.log.",
            "category": "developer",
            "tags": [
                "logging",
                "helper",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_log' ) ) {\n    function wpide_log( $message, $context = array(), $level = 'info' ) {\n        $folder = get_option( 'wpide_log_dir' );\n        if ( ! $folder ) {\n            $folder = 'wpide-logs-' . wp_generate_password( 12, false );\n            update_option( 'wpide_log_dir', $folder, false );\n        }\n\n        $uploads = wp_upload_dir( null, false );\n        $dir     = trailingslashit( $uploads['basedir'] ) . $folder;\n\n        if ( ! is_dir( $dir ) ) {\n            if ( ! wp_mkdir_p( $dir ) ) {\n                return false;\n            }\n            file_put_contents( $dir . '/.htaccess', \"Require all denied\\nDeny from all\\n\" );\n            file_put_contents( $dir . '/index.php', \"<?php\\n// Silence is golden.\\n\" );\n        }\n\n        $line = sprintf(\n            \"[%s] %s: %s%s\\n\",\n            wp_date( 'Y-m-d H:i:s' ),\n            strtoupper( sanitize_key( $level ) ),\n            is_scalar( $message ) ? (string) $message : wp_json_encode( $message ),\n            $context ? ' ' . wp_json_encode( $context, JSON_PARTIAL_OUTPUT_ON_ERROR ) : ''\n        );\n\n        return false !== file_put_contents( $dir . '/log-' . wp_date( 'Y-m-d' ) . '.log', $line, FILE_APPEND | LOCK_EX );\n    }\n}",
            "requires": [],
            "notes": "Example wpide_log( 'Order synced', array( 'id' => $order_id ), 'info' ); the folder name contains a random suffix and blocks direct web access on Apache."
        },
        {
            "id": "debug-pretty-dump-helper",
            "version": 1,
            "title": "Readable Variable Dump for Administrators",
            "description": "Adds a wpide_dump() helper that shows any variable in a readable, collapsible box only to administrators, so debug output never reaches visitors even if you forget to remove it.",
            "category": "developer",
            "tags": [
                "debugging",
                "helper",
                "var-dump"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_dump' ) ) {\n    function wpide_dump( $value, $stop = false ) {\n        if ( ! current_user_can( 'manage_options' ) ) {\n            return;\n        }\n\n        $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 1 );\n        $where = isset( $trace[0]['file'] ) ? str_replace( ABSPATH, '', $trace[0]['file'] ) . ':' . $trace[0]['line'] : '';\n\n        printf(\n            '<details open style=\"margin:1rem 0;padding:.75rem 1rem;background:#1d2327;color:#f0f0f1;border-radius:4px;font:12px/1.5 monospace;text-align:left;position:relative;z-index:99999\"><summary>%s</summary><pre style=\"white-space:pre-wrap;margin:.5rem 0 0;color:inherit;background:none\">%s</pre></details>',\n            esc_html( 'Dump ' . $where ),\n            esc_html( print_r( $value, true ) )\n        );\n\n        if ( $stop ) {\n            exit;\n        }\n    }\n}",
            "requires": [],
            "notes": "Example wpide_dump( get_queried_object() ); pass true as the second argument to stop the page after the dump."
        },
        {
            "id": "debug-outgoing-http-requests",
            "version": 1,
            "title": "Show Outgoing HTTP Requests in the Admin Bar",
            "description": "Counts the external HTTP requests WordPress, plugins and themes make while building a page, and lists each URL, status and time in an admin bar menu for administrators.",
            "category": "developer",
            "tags": [
                "debugging",
                "http",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_http_log = array();\n\nadd_filter( 'pre_http_request', function ( $pre, $args, $url ) {\n    $GLOBALS['wpide_http_started'][ md5( $url ) ] = microtime( true );\n    return $pre;\n}, 1, 3 );\n\nadd_action( 'http_api_debug', function ( $response, $context, $class, $args, $url ) use ( &$wpide_http_log ) {\n    $started          = $GLOBALS['wpide_http_started'][ md5( $url ) ] ?? microtime( true );\n    $wpide_http_log[] = array(\n        'url'    => $url,\n        'method' => $args['method'] ?? 'GET',\n        'status' => is_wp_error( $response ) ? $response->get_error_code() : wp_remote_retrieve_response_code( $response ),\n        'time'   => round( ( microtime( true ) - $started ) * 1000 ),\n    );\n}, 10, 5 );\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) use ( &$wpide_http_log ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-http',\n        'title' => 'HTTP: ' . count( $wpide_http_log ),\n    ) );\n\n    foreach ( $wpide_http_log as $i => $request ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-http-' . $i,\n            'parent' => 'wpide-http',\n            'title'  => esc_html( sprintf( '%s %s - %s (%d ms)', $request['method'], wp_parse_url( $request['url'], PHP_URL_HOST ) . wp_parse_url( $request['url'], PHP_URL_PATH ), $request['status'], $request['time'] ) ),\n        ) );\n    }\n}, 999 );",
            "requires": [],
            "notes": "Slow pages often come from plugins calling remote APIs on every load; this shows them at a glance."
        },
        {
            "id": "debug-screen-id-in-footer",
            "version": 1,
            "title": "Show the Admin Screen ID in the Footer",
            "description": "Displays the current screen ID, base, post type and hook suffix in the admin footer for administrators, which is exactly what you need when targeting a screen in your own code.",
            "category": "developer",
            "tags": [
                "debugging",
                "admin",
                "screen"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'update_footer', function ( $text ) {\n    $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;\n    if ( ! $screen || ! current_user_can( 'manage_options' ) ) {\n        return $text;\n    }\n\n    return sprintf(\n        '<code>id: %s</code> <code>base: %s</code>%s <code>hook: %s</code> &middot; %s',\n        esc_html( $screen->id ),\n        esc_html( $screen->base ),\n        $screen->post_type ? ' <code>post_type: ' . esc_html( $screen->post_type ) . '</code>' : '',\n        esc_html( $GLOBALS['hook_suffix'] ?? '' ),\n        $text\n    );\n}, 99 );",
            "requires": [],
            "notes": "Use the values with get_current_screen() checks or the admin_enqueue_scripts hook suffix."
        },
        {
            "id": "debug-shortcodes-list-page",
            "version": 1,
            "title": "List All Registered Shortcodes Under Tools",
            "description": "Adds a Tools > Shortcodes screen listing every shortcode available on the site and the plugin, theme or function that provides it, so you know what can be used in content.",
            "category": "developer",
            "tags": [
                "shortcodes",
                "debugging",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Shortcodes', 'Shortcodes', 'manage_options', 'wpide-shortcodes', function () {\n        global $shortcode_tags;\n\n        $source = function ( $callback ) {\n            try {\n                if ( is_string( $callback ) && false !== strpos( $callback, '::' ) ) {\n                    $callback = explode( '::', $callback );\n                }\n                $reflection = is_array( $callback ) ? new ReflectionMethod( $callback[0], $callback[1] ) : new ReflectionFunction( $callback );\n                $file       = (string) $reflection->getFileName();\n            } catch ( ReflectionException $e ) {\n                return '';\n            }\n            if ( false !== strpos( $file, WP_PLUGIN_DIR ) ) {\n                return 'Plugin: ' . strtok( str_replace( WP_PLUGIN_DIR . '/', '', $file ), '/' );\n            }\n            if ( false !== strpos( $file, get_theme_root() ) ) {\n                return 'Theme: ' . strtok( str_replace( get_theme_root() . '/', '', $file ), '/' );\n            }\n            return false !== strpos( $file, ABSPATH . WPINC ) ? 'WordPress' : str_replace( ABSPATH, '', $file );\n        };\n\n        $tags = $shortcode_tags;\n        ksort( $tags );\n\n        echo '<div class=\"wrap\"><h1>Shortcodes (' . count( $tags ) . ')</h1><table class=\"widefat striped\"><thead><tr><th>Shortcode</th><th>Provided by</th></tr></thead><tbody>';\n        foreach ( $tags as $tag => $callback ) {\n            printf( '<tr><td><code>[%s]</code></td><td>%s</td></tr>', esc_html( $tag ), esc_html( $source( $callback ) ) );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Shortcodes registered only on the front end will not appear here."
        },
        {
            "id": "debug-image-sizes-page",
            "version": 1,
            "title": "Registered Image Sizes Under Tools",
            "description": "Adds a Tools > Image Sizes screen listing every image size WordPress creates on upload, with its width, height and crop mode, so you can see what themes and plugins have added.",
            "category": "developer",
            "tags": [
                "image-sizes",
                "media",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Image Sizes', 'Image Sizes', 'manage_options', 'wpide-image-sizes', function () {\n        $sizes = wp_get_registered_image_subsizes();\n\n        echo '<div class=\"wrap\"><h1>Image Sizes (' . count( $sizes ) . ')</h1><table class=\"widefat striped\"><thead><tr><th>Name</th><th>Width</th><th>Height</th><th>Crop</th></tr></thead><tbody>';\n        foreach ( $sizes as $name => $size ) {\n            $crop = $size['crop'];\n            if ( is_array( $crop ) ) {\n                $crop = 'yes (' . implode( ' ', $crop ) . ')';\n            } else {\n                $crop = $crop ? 'yes' : 'no (scaled)';\n            }\n            printf(\n                '<tr><td><code>%s</code></td><td>%s</td><td>%s</td><td>%s</td></tr>',\n                esc_html( $name ),\n                $size['width'] ? (int) $size['width'] . 'px' : 'any',\n                $size['height'] ? (int) $size['height'] . 'px' : 'any',\n                esc_html( $crop )\n            );\n        }\n        echo '</tbody></table>';\n        printf( '<p>Big image threshold: %s</p></div>', esc_html( ( $threshold = apply_filters( 'big_image_size_threshold', 2560, array( 0, 0 ), '', 0 ) ) ? $threshold . 'px' : 'off' ) );\n    } );\n} );",
            "requires": [],
            "notes": "Each listed size is generated for every uploaded image; remove the ones you do not use to save disk space."
        },
        {
            "id": "debug-flush-permalinks-admin-bar",
            "version": 1,
            "title": "Flush Permalinks From the Admin Bar",
            "description": "Adds a \"Flush permalinks\" link to the admin bar for administrators that rebuilds the rewrite rules in one click, handy after registering post types or custom rewrite rules.",
            "category": "developer",
            "tags": [
                "permalinks",
                "rewrite-rules",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $admin_bar->add_node( array(\n        'id'     => 'wpide-flush-rewrites',\n        'parent' => 'site-name',\n        'title'  => 'Flush permalinks',\n        'href'   => wp_nonce_url( admin_url( 'admin-post.php?action=wpide_flush_rewrites' ), 'wpide_flush_rewrites' ),\n    ) );\n}, 100 );\n\nadd_action( 'admin_post_wpide_flush_rewrites', function () {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n    check_admin_referer( 'wpide_flush_rewrites' );\n\n    flush_rewrite_rules();\n\n    wp_safe_redirect( add_query_arg( 'wpide_flushed', 1, wp_get_referer() ? wp_get_referer() : admin_url() ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_flushed'] ) && current_user_can( 'manage_options' ) ) {\n        echo '<div class=\"notice notice-success is-dismissible\"><p>Permalinks flushed.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Same effect as saving Settings > Permalinks; the link is protected with a nonce."
        },
        {
            "id": "site-health-required-plugins",
            "version": 1,
            "title": "Site Health Check for Must-Have Plugins",
            "description": "Adds a test to Tools > Site Health that turns critical when any plugin you list as essential, such as your backup, security or SEO plugin, is deactivated or missing.",
            "category": "developer",
            "tags": [
                "site-health",
                "plugins",
                "monitoring"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Plugins that must stay active (folder/main-file.php => name).\n$wpide_required_plugins = array(\n    'wordfence/wordfence.php'         => 'Wordfence Security',\n    'updraftplus/updraftplus.php'     => 'UpdraftPlus Backups',\n);\n\nadd_filter( 'site_status_tests', function ( $tests ) use ( $wpide_required_plugins ) {\n    $tests['direct']['wpide_required_plugins'] = array(\n        'label' => 'Essential plugins are active',\n        'test'  => function () use ( $wpide_required_plugins ) {\n            if ( ! function_exists( 'is_plugin_active' ) ) {\n                require_once ABSPATH . 'wp-admin/includes/plugin.php';\n            }\n\n            $missing = array();\n            foreach ( $wpide_required_plugins as $file => $name ) {\n                if ( ! is_plugin_active( $file ) ) {\n                    $missing[] = $name;\n                }\n            }\n\n            $result = array(\n                'label'       => 'All essential plugins are active',\n                'status'      => 'good',\n                'badge'       => array( 'label' => 'Maintenance', 'color' => 'blue' ),\n                'description' => '<p>The plugins this site depends on are installed and active.</p>',\n                'test'        => 'wpide_required_plugins',\n            );\n\n            if ( $missing ) {\n                $result['label']       = 'Essential plugins are inactive';\n                $result['status']      = 'critical';\n                $result['badge']['color'] = 'red';\n                $result['description'] = '<p>These plugins should always be active: ' . esc_html( implode( ', ', $missing ) ) . '.</p>';\n                $result['actions']     = '<a href=\"' . esc_url( admin_url( 'plugins.php' ) ) . '\">Go to Plugins</a>';\n            }\n\n            return $result;\n        },\n    );\n\n    return $tests;\n} );",
            "requires": [],
            "notes": "List plugin paths as folder/main-file.php in $wpide_required_plugins."
        },
        {
            "id": "site-health-maintainer-info",
            "version": 1,
            "title": "Maintainer Details in Site Health Info",
            "description": "Adds a \"Site maintainer\" section to Tools > Site Health > Info with your agency's contact details, support plan and notes, so they travel with every copied debug report.",
            "category": "developer",
            "tags": [
                "site-health",
                "support",
                "agency"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Shown in Site Health > Info.\n$wpide_maintainer = array(\n    'Company'       => 'Your Agency Ltd',\n    'Support email' => 'support@example.com',\n    'Phone'         => '+1 555 0100',\n    'Support plan'  => 'Care plan, monthly updates',\n    'Notes'         => 'Please contact us before installing new plugins.',\n);\n\nadd_filter( 'debug_information', function ( $info ) use ( $wpide_maintainer ) {\n    $fields = array();\n    foreach ( $wpide_maintainer as $label => $value ) {\n        $fields[ sanitize_key( $label ) ] = array(\n            'label'   => $label,\n            'value'   => $value,\n            'private' => false,\n        );\n    }\n\n    $info['wpide_maintainer'] = array(\n        'label'       => 'Site maintainer',\n        'description' => 'Who looks after this website.',\n        'fields'      => $fields,\n    );\n\n    return $info;\n} );",
            "requires": [],
            "notes": "Edit the $wpide_maintainer values; nothing is shown on the front end."
        },
        {
            "id": "env-admin-color-by-environment",
            "version": 1,
            "title": "Different Admin Color Scheme per Environment",
            "description": "Switches the admin color scheme automatically on local, development and staging sites, so nobody mistakes a test copy for the live site while editing.",
            "category": "developer",
            "tags": [
                "environment",
                "staging",
                "admin-colors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Admin color scheme per environment type.\n$wpide_env_colors = array(\n    'local'       => 'ocean',\n    'development' => 'ectoplasm',\n    'staging'     => 'sunrise',\n);\n\nadd_filter( 'get_user_option_admin_color', function ( $color ) use ( $wpide_env_colors ) {\n    $environment = wp_get_environment_type();\n\n    return isset( $wpide_env_colors[ $environment ] ) ? $wpide_env_colors[ $environment ] : $color;\n} );",
            "requires": [],
            "notes": "Production keeps each user's own choice; change the schemes in $wpide_env_colors (fresh, light, modern, blue, coffee, ectoplasm, midnight, ocean, sunrise)."
        },
        {
            "id": "env-browser-tab-prefix",
            "version": 1,
            "title": "Environment Name in the Browser Tab Title",
            "description": "Prefixes browser tab titles with [LOCAL], [DEV] or [STAGING] for logged-in users on non-production sites, in the admin and on the front end, so open tabs are easy to tell apart.",
            "category": "developer",
            "tags": [
                "environment",
                "staging",
                "titles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_env_label = function () {\n    $labels      = array( 'local' => 'LOCAL', 'development' => 'DEV', 'staging' => 'STAGING' );\n    $environment = wp_get_environment_type();\n\n    return isset( $labels[ $environment ] ) && is_user_logged_in() ? '[' . $labels[ $environment ] . '] ' : '';\n};\n\nadd_filter( 'admin_title', function ( $title ) use ( $wpide_env_label ) {\n    return $wpide_env_label() . $title;\n}, 99 );\n\nadd_filter( 'document_title_parts', function ( $parts ) use ( $wpide_env_label ) {\n    if ( $wpide_env_label() && isset( $parts['title'] ) ) {\n        $parts['title'] = $wpide_env_label() . $parts['title'];\n    }\n    return $parts;\n}, 99 );",
            "requires": [],
            "notes": "Visitors and the production site see normal titles."
        },
        {
            "id": "debug-object-cache-stats",
            "version": 1,
            "title": "Object Cache Hit Rate in the Admin Bar",
            "description": "Shows administrators the object cache hit rate, hits and misses for the current page in the admin bar, to check whether Redis or Memcached is actually helping.",
            "category": "developer",
            "tags": [
                "object-cache",
                "performance",
                "admin-bar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Added just before the bar renders, so most of the page's cache calls are counted.\nadd_action( 'wp_before_admin_bar_render', function () {\n    global $wp_object_cache, $wp_admin_bar;\n\n    if ( ! current_user_can( 'manage_options' ) || ! is_object( $wp_object_cache ) || ! $wp_admin_bar ) {\n        return;\n    }\n\n    $hits   = isset( $wp_object_cache->cache_hits ) ? (int) $wp_object_cache->cache_hits : 0;\n    $misses = isset( $wp_object_cache->cache_misses ) ? (int) $wp_object_cache->cache_misses : 0;\n    $total  = $hits + $misses;\n    $rate   = $total ? round( $hits / $total * 100, 1 ) : 0;\n\n    $wp_admin_bar->add_node( array(\n        'id'     => 'wpide-object-cache',\n        'parent' => 'top-secondary',\n        'title'  => esc_html( sprintf( 'Cache %s%% %s', $rate, wp_using_ext_object_cache() ? '(persistent)' : '(runtime)' ) ),\n        'meta'   => array( 'title' => sprintf( '%d hits, %d misses', $hits, $misses ) ),\n    ) );\n} );",
            "requires": [],
            "notes": "Without a persistent object cache the numbers only cover the current request."
        },
        {
            "id": "debug-user-meta-inspector",
            "version": 1,
            "title": "User Meta Inspector on Profile Screens",
            "description": "Shows administrators a read-only table of every stored user meta value at the bottom of user profile screens, with session tokens and other secrets masked.",
            "category": "developer",
            "tags": [
                "users",
                "debugging",
                "user-meta"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Meta keys whose values are never shown.\n$wpide_masked_meta = array( 'session_tokens', '_application_passwords', 'wp_user-settings', 'default_password_nag' );\n\n$wpide_user_meta_table = function ( $user ) use ( $wpide_masked_meta ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $meta = get_user_meta( $user->ID );\n    ksort( $meta );\n\n    echo '<h2>User meta</h2><table class=\"widefat striped\" style=\"table-layout:fixed;max-width:1000px\"><thead><tr><th style=\"width:30%\">Key</th><th>Value</th></tr></thead><tbody>';\n    foreach ( $meta as $key => $values ) {\n        $masked = in_array( $key, $wpide_masked_meta, true ) || false !== stripos( $key, 'token' ) || false !== stripos( $key, 'secret' ) || false !== stripos( $key, 'pass' );\n        foreach ( $values as $value ) {\n            $display = $masked ? '(hidden)' : ( is_serialized( $value ) ? print_r( maybe_unserialize( $value ), true ) : $value );\n            printf( '<tr><td><code>%s</code></td><td><code style=\"white-space:pre-wrap;word-break:break-all\">%s</code></td></tr>', esc_html( $key ), esc_html( mb_strimwidth( (string) $display, 0, 400, '...' ) ) );\n        }\n    }\n    echo '</tbody></table>';\n};\n\nadd_action( 'show_user_profile', $wpide_user_meta_table, 99 );\nadd_action( 'edit_user_profile', $wpide_user_meta_table, 99 );",
            "requires": [],
            "notes": "Add other sensitive keys to $wpide_masked_meta to keep them hidden."
        },
        {
            "id": "debug-rest-link-admin-bar",
            "version": 1,
            "title": "REST API Link for the Current Item in the Admin Bar",
            "description": "Adds a \"REST\" link to the front-end admin bar for administrators that opens the JSON for the post, page, term or author being viewed, which speeds up headless and integration work.",
            "category": "developer",
            "tags": [
                "rest-api",
                "admin-bar",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( is_admin() || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    $object = get_queried_object();\n    $route  = '';\n\n    if ( $object instanceof WP_Post ) {\n        $route = rest_get_route_for_post( $object );\n    } elseif ( $object instanceof WP_Term ) {\n        $route = rest_get_route_for_term( $object );\n    } elseif ( $object instanceof WP_User ) {\n        $route = '/wp/v2/users/' . $object->ID;\n    }\n\n    if ( ! $route ) {\n        return;\n    }\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-rest-link',\n        'title' => 'REST',\n        'href'  => rest_url( ltrim( $route, '/' ) ),\n        'meta'  => array( 'target' => '_blank', 'rel' => 'noopener' ),\n    ) );\n}, 999 );",
            "requires": [],
            "notes": "Only items exposed in the REST API get a link."
        },
        {
            "id": "debug-timer-helper",
            "version": 1,
            "title": "Measure How Long Code Takes",
            "description": "Adds wpide_timer_start() and wpide_timer_stop() helpers that measure the time and memory a piece of code uses and write the result to the PHP error log, to find slow functions.",
            "category": "developer",
            "tags": [
                "performance",
                "helper",
                "profiling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_timer_start' ) ) {\n    function wpide_timer_start( $name ) {\n        $GLOBALS['wpide_timers'][ $name ] = array( microtime( true ), memory_get_usage() );\n    }\n\n    function wpide_timer_stop( $name, $log = true ) {\n        if ( empty( $GLOBALS['wpide_timers'][ $name ] ) ) {\n            return null;\n        }\n\n        list( $started, $memory ) = $GLOBALS['wpide_timers'][ $name ];\n        unset( $GLOBALS['wpide_timers'][ $name ] );\n\n        $ms = round( ( microtime( true ) - $started ) * 1000, 2 );\n\n        if ( $log ) {\n            error_log( sprintf( '[timer] %s: %s ms, %s memory', $name, $ms, size_format( max( 0, memory_get_usage() - $memory ), 1 ) ) );\n        }\n\n        return $ms;\n    }\n}",
            "requires": [],
            "notes": "Example wpide_timer_start( 'menu' ); ... wpide_timer_stop( 'menu' ); the stop call also returns the milliseconds."
        },
        {
            "id": "dev-admin-flash-notice-helper",
            "version": 1,
            "title": "Admin Notice Helper That Survives Redirects",
            "description": "Adds a wpide_flash_notice() helper that queues a success, warning or error message for the current user and shows it on the next admin screen, even after a redirect.",
            "category": "developer",
            "tags": [
                "admin-notices",
                "helper",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_flash_notice' ) ) {\n    function wpide_flash_notice( $message, $type = 'success' ) {\n        $user_id = get_current_user_id();\n        if ( ! $user_id ) {\n            return;\n        }\n        $queue   = (array) get_transient( 'wpide_flash_' . $user_id );\n        $queue[] = array( 'message' => (string) $message, 'type' => in_array( $type, array( 'success', 'warning', 'error', 'info' ), true ) ? $type : 'info' );\n        set_transient( 'wpide_flash_' . $user_id, array_filter( $queue ), 5 * MINUTE_IN_SECONDS );\n    }\n}\n\nadd_action( 'admin_notices', function () {\n    $key   = 'wpide_flash_' . get_current_user_id();\n    $queue = get_transient( $key );\n    if ( ! $queue ) {\n        return;\n    }\n    delete_transient( $key );\n\n    foreach ( (array) $queue as $notice ) {\n        printf( '<div class=\"notice notice-%s is-dismissible\"><p>%s</p></div>', esc_attr( $notice['type'] ), wp_kses_post( $notice['message'] ) );\n    }\n} );",
            "requires": [],
            "notes": "Example wpide_flash_notice( 'Import finished.', 'success' ); before wp_safe_redirect(); messages are stored per user for five minutes."
        },
        {
            "id": "rewrite-author-base-slug",
            "version": 1,
            "title": "Change the Author URL Base From /author/ to /team/",
            "description": "Changes author archive addresses from /author/name/ to a word of your choice, such as /team/name/ or /writers/name/, to match how your site talks about its people.",
            "category": "developer",
            "tags": [
                "rewrite-rules",
                "authors",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// New base for author archive URLs.\n$wpide_author_base = 'team';\n\nadd_action( 'init', function () use ( $wpide_author_base ) {\n    global $wp_rewrite;\n    $wp_rewrite->author_base = sanitize_title( $wpide_author_base );\n} );",
            "requires": [],
            "notes": "Visit Settings > Permalinks and click Save once after activating or changing $wpide_author_base, so the new rules are built."
        },
        {
            "id": "rewrite-humans-txt",
            "version": 1,
            "title": "Serve a humans.txt File",
            "description": "Answers /humans.txt with a plain-text file crediting the people and tools behind the site, generated by WordPress so you do not need to upload anything to the server.",
            "category": "developer",
            "tags": [
                "rewrite-rules",
                "humans-txt",
                "credits"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// File contents.\n$wpide_humans_txt = \"/* TEAM */\\nDeveloper: Jane Doe\\nContact: hello@example.com\\nLocation: Lisbon, Portugal\\n\\n/* SITE */\\nStandards: HTML5, CSS3\\nSoftware: WordPress\";\n\nadd_action( 'init', function () {\n    add_rewrite_rule( '^humans\\.txt$', 'index.php?wpide_humans=1', 'top' );\n} );\n\nadd_filter( 'query_vars', function ( $vars ) {\n    $vars[] = 'wpide_humans';\n    return $vars;\n} );\n\nadd_action( 'template_redirect', function () use ( $wpide_humans_txt ) {\n    if ( ! get_query_var( 'wpide_humans' ) ) {\n        return;\n    }\n\n    header( 'Content-Type: text/plain; charset=utf-8' );\n    header( 'X-Robots-Tag: noindex' );\n    echo wp_strip_all_tags( $wpide_humans_txt ) . \"\\n\\n/* UPDATED */\\nLast update: \" . esc_html( mysql2date( 'Y/m/d', get_lastpostmodified( 'blog' ) ) ) . \"\\n\";\n    exit;\n} );",
            "requires": [],
            "notes": "Edit $wpide_humans_txt; save Settings > Permalinks once so WordPress learns the new address. A real humans.txt file on the server takes priority."
        },
        {
            "id": "template-single-by-category",
            "version": 1,
            "title": "Different Single Post Template per Category",
            "description": "Lets a classic theme use single-cat-{slug}.php for posts in a given category, for example a recipe layout for posts filed under Recipes, falling back to the normal template.",
            "category": "developer",
            "tags": [
                "templates",
                "categories",
                "themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'single_template', function ( $template ) {\n    $post = get_queried_object();\n    if ( ! $post instanceof WP_Post || 'post' !== $post->post_type ) {\n        return $template;\n    }\n\n    $candidates = array();\n    foreach ( (array) get_the_category( $post->ID ) as $category ) {\n        $candidates[] = 'single-cat-' . $category->slug . '.php';\n    }\n\n    $found = $candidates ? locate_template( $candidates ) : '';\n\n    return $found ? $found : $template;\n} );",
            "requires": [],
            "notes": "Create the file in your child theme, such as single-cat-recipes.php; block themes can use the template picker in the editor instead."
        },
        {
            "id": "template-page-template-from-folder",
            "version": 1,
            "title": "Register Page Templates From a Folder Outside the Theme",
            "description": "Adds every PHP file in wp-content/page-templates/ to the page Template menu of classic themes, so custom layouts survive theme switches and updates.",
            "category": "developer",
            "tags": [
                "templates",
                "pages",
                "themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Folder that holds the templates.\n$wpide_template_dir = WP_CONTENT_DIR . '/page-templates';\n\n$wpide_find_templates = function () use ( $wpide_template_dir ) {\n    static $found = null;\n    if ( null !== $found ) {\n        return $found;\n    }\n    $found = array();\n    foreach ( (array) glob( trailingslashit( $wpide_template_dir ) . '*.php' ) as $file ) {\n        $data = get_file_data( $file, array( 'name' => 'Template Name' ) );\n        if ( $data['name'] ) {\n            $found[ 'wpide/' . basename( $file ) ] = $data['name'];\n        }\n    }\n    return $found;\n};\n\nadd_filter( 'theme_page_templates', function ( $templates ) use ( $wpide_find_templates ) {\n    return array_merge( $templates, $wpide_find_templates() );\n} );\n\nadd_filter( 'template_include', function ( $template ) use ( $wpide_template_dir ) {\n    if ( ! is_page() ) {\n        return $template;\n    }\n\n    $chosen = get_page_template_slug( get_queried_object_id() );\n    if ( 0 !== strpos( (string) $chosen, 'wpide/' ) ) {\n        return $template;\n    }\n\n    $file = trailingslashit( $wpide_template_dir ) . basename( $chosen );\n\n    return is_readable( $file ) ? $file : $template;\n}, 99 );",
            "requires": [],
            "notes": "Each file needs a \"Template Name: ...\" header comment like a theme template; block themes use the Site Editor for templates instead."
        },
        {
            "id": "debug-wp-config-constants",
            "version": 1,
            "title": "wp-config.php Settings Overview Under Tools",
            "description": "Adds a Tools > Config Constants screen that shows the value of important wp-config.php settings, such as debug mode, memory limits, cron, caching and file editing, without opening the file.",
            "category": "developer",
            "tags": [
                "wp-config",
                "debugging",
                "constants"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Constants to display.\n$wpide_config_constants = array( 'WP_ENVIRONMENT_TYPE', 'WP_DEBUG', 'WP_DEBUG_LOG', 'WP_DEBUG_DISPLAY', 'SCRIPT_DEBUG', 'SAVEQUERIES', 'WP_MEMORY_LIMIT', 'WP_MAX_MEMORY_LIMIT', 'WP_CACHE', 'DISABLE_WP_CRON', 'ALTERNATE_WP_CRON', 'WP_CRON_LOCK_TIMEOUT', 'WP_POST_REVISIONS', 'AUTOSAVE_INTERVAL', 'EMPTY_TRASH_DAYS', 'DISALLOW_FILE_EDIT', 'DISALLOW_FILE_MODS', 'AUTOMATIC_UPDATER_DISABLED', 'WP_AUTO_UPDATE_CORE', 'FORCE_SSL_ADMIN', 'CONCATENATE_SCRIPTS', 'COMPRESS_SCRIPTS', 'WP_HOME', 'WP_SITEURL', 'WP_CONTENT_DIR', 'UPLOADS', 'DB_CHARSET', 'DB_COLLATE', 'WP_ALLOW_REPAIR', 'MULTISITE' );\n\nadd_action( 'admin_menu', function () use ( $wpide_config_constants ) {\n    add_management_page( 'Config Constants', 'Config Constants', 'manage_options', 'wpide-config-constants', function () use ( $wpide_config_constants ) {\n        echo '<div class=\"wrap\"><h1>wp-config.php Constants</h1><table class=\"widefat striped\" style=\"max-width:900px\"><thead><tr><th>Constant</th><th>Value</th></tr></thead><tbody>';\n        foreach ( $wpide_config_constants as $name ) {\n            if ( preg_match( '/KEY|SALT|PASSWORD|SECRET|TOKEN/', $name ) ) {\n                continue;\n            }\n            if ( ! defined( $name ) ) {\n                $value = '<em>not set</em>';\n            } else {\n                $raw   = constant( $name );\n                $value = '<code>' . esc_html( is_bool( $raw ) ? ( $raw ? 'true' : 'false' ) : var_export( $raw, true ) ) . '</code>';\n            }\n            printf( '<tr><td><code>%s</code></td><td>%s</td></tr>', esc_html( $name ), $value );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Secret keys, salts and database passwords are never shown; add more names to $wpide_config_constants."
        },
        {
            "id": "debug-transients-viewer",
            "version": 1,
            "title": "Transients Viewer Under Tools",
            "description": "Adds a Tools > Transients screen listing the cached transients stored in the database with their size and expiry time, and lets administrators delete a single one to force a refresh.",
            "category": "developer",
            "tags": [
                "transients",
                "caching",
                "debugging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Transients', 'Transients', 'manage_options', 'wpide-transients', function () {\n        global $wpdb;\n\n        if ( isset( $_POST['wpide_delete_transient'] ) && current_user_can( 'manage_options' ) && check_admin_referer( 'wpide_transients' ) ) {\n            $name = sanitize_text_field( wp_unslash( $_POST['wpide_delete_transient'] ) );\n            delete_transient( $name );\n            echo '<div class=\"notice notice-success\"><p>' . esc_html( sprintf( 'Deleted transient %s.', $name ) ) . '</p></div>';\n        }\n\n        $rows = $wpdb->get_results(\n            \"SELECT o.option_name, LENGTH(o.option_value) AS bytes, t.option_value AS expires\n            FROM {$wpdb->options} o\n            LEFT JOIN {$wpdb->options} t ON t.option_name = CONCAT('_transient_timeout_', SUBSTRING(o.option_name, 12))\n            WHERE o.option_name LIKE '\\\\_transient\\\\_%' AND o.option_name NOT LIKE '\\\\_transient\\\\_timeout\\\\_%'\n            ORDER BY bytes DESC LIMIT 200\"\n        );\n\n        echo '<div class=\"wrap\"><h1>Transients</h1>';\n        if ( ! $rows ) {\n            echo '<p>No transients are stored in the database.</p></div>';\n            return;\n        }\n        echo '<form method=\"post\"><table class=\"widefat striped\"><thead><tr><th>Name</th><th>Size</th><th>Expires</th><th></th></tr></thead><tbody>';\n        wp_nonce_field( 'wpide_transients' );\n        foreach ( $rows as $row ) {\n            $name    = substr( $row->option_name, strlen( '_transient_' ) );\n            $expires = $row->expires ? ( $row->expires < time() ? 'expired' : 'in ' . human_time_diff( (int) $row->expires ) ) : 'never';\n            printf(\n                '<tr><td><code>%1$s</code></td><td>%2$s</td><td>%3$s</td><td><button class=\"button button-small\" name=\"wpide_delete_transient\" value=\"%1$s\">Delete</button></td></tr>',\n                esc_attr( $name ),\n                esc_html( size_format( (int) $row->bytes ) ),\n                esc_html( $expires )\n            );\n        }\n        echo '</tbody></table></form></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Sites with Redis or Memcached keep transients in the object cache, so the list will be short or empty there."
        },
        {
            "id": "admin-excerpt-column",
            "version": 1,
            "title": "Excerpt Column in the Posts List",
            "description": "Adds an Excerpt column to the Posts screen showing the start of each manual excerpt, and a clear \"missing\" label where none was written, so editors can fill the gaps.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "excerpts",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types that get the column.\n$wpide_excerpt_column_types = array( 'post' );\n\nforeach ( $wpide_excerpt_column_types as $wpide_type ) {\n    add_filter( \"manage_{$wpide_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_excerpt'] = 'Excerpt';\n        return $columns;\n    } );\n\n    add_action( \"manage_{$wpide_type}_posts_custom_column\", function ( $column, $post_id ) {\n        if ( 'wpide_excerpt' !== $column ) {\n            return;\n        }\n        $excerpt = trim( (string) get_post_field( 'post_excerpt', $post_id, 'raw' ) );\n        echo '' === $excerpt\n            ? '<span style=\"color:#b32d2e\">missing</span>'\n            : esc_html( wp_trim_words( $excerpt, 14 ) );\n    }, 10, 2 );\n}",
            "requires": [],
            "notes": "Add more post types to $wpide_excerpt_column_types if they support excerpts."
        },
        {
            "id": "admin-custom-field-column",
            "version": 1,
            "title": "Sortable Custom Field Column in Admin Lists",
            "description": "Adds a column showing the value of a custom field, such as a price, event date or SKU, to an admin list and lets you sort the list by it.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "custom-fields",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Custom field to show.\n$wpide_meta_column_key   = 'price';\n$wpide_meta_column_label = 'Price';\n$wpide_meta_column_type  = 'post';\n// true to sort as numbers, false to sort as text.\n$wpide_meta_column_numeric = true;\n\nadd_filter( \"manage_{$wpide_meta_column_type}_posts_columns\", function ( $columns ) use ( $wpide_meta_column_label ) {\n    $columns['wpide_meta_column'] = $wpide_meta_column_label;\n    return $columns;\n} );\n\nadd_action( \"manage_{$wpide_meta_column_type}_posts_custom_column\", function ( $column, $post_id ) use ( $wpide_meta_column_key ) {\n    if ( 'wpide_meta_column' === $column ) {\n        $value = get_post_meta( $post_id, $wpide_meta_column_key, true );\n        echo '' === $value ? '&ndash;' : esc_html( is_scalar( $value ) ? $value : wp_json_encode( $value ) );\n    }\n}, 10, 2 );\n\nadd_filter( \"manage_edit-{$wpide_meta_column_type}_sortable_columns\", function ( $columns ) use ( $wpide_meta_column_key ) {\n    $columns['wpide_meta_column'] = $wpide_meta_column_key;\n    return $columns;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_meta_column_key, $wpide_meta_column_numeric ) {\n    if ( ! is_admin() || ! $query->is_main_query() || $query->get( 'orderby' ) !== $wpide_meta_column_key ) {\n        return;\n    }\n    $query->set( 'meta_key', $wpide_meta_column_key );\n    $query->set( 'orderby', $wpide_meta_column_numeric ? 'meta_value_num' : 'meta_value' );\n} );",
            "requires": [],
            "notes": "Set the key, label, post type and whether values are numbers; sorting a large list by a meta value is slower than by date."
        },
        {
            "id": "admin-attached-media-count-column",
            "version": 1,
            "title": "Attached Media Count Column",
            "description": "Adds a Media column to the Posts and Pages lists with the number of files uploaded to each item, linking to them in the Media Library, to spot heavy or image-less content.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "media",
                "attachments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_media_counts = null;\n\n$wpide_media_count_cell = function ( $column, $post_id ) use ( &$wpide_media_counts ) {\n    if ( 'wpide_media_count' !== $column ) {\n        return;\n    }\n\n    // One query for every row on the screen.\n    if ( null === $wpide_media_counts ) {\n        global $wpdb, $wp_query;\n        $ids               = $wp_query && $wp_query->posts ? wp_list_pluck( $wp_query->posts, 'ID' ) : array( $post_id );\n        $ids               = array_map( 'absint', $ids );\n        $wpide_media_counts = wp_list_pluck( $wpdb->get_results(\n            \"SELECT post_parent, COUNT(*) AS total FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_parent IN (\" . implode( ',', $ids ? $ids : array( 0 ) ) . ') GROUP BY post_parent'\n        ), 'total', 'post_parent' );\n    }\n\n    $count = (int) ( $wpide_media_counts[ $post_id ] ?? 0 );\n    echo $count\n        ? '<a href=\"' . esc_url( admin_url( 'upload.php?mode=list&attachment-filter=all&post_parent=' . $post_id ) ) . '\">' . (int) $count . '</a>'\n        : '0';\n};\n\nforeach ( array( 'post', 'page' ) as $wpide_type ) {\n    add_filter( \"manage_{$wpide_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_media_count'] = '<span class=\"dashicons dashicons-format-gallery\" aria-hidden=\"true\"></span><span class=\"screen-reader-text\">Media</span>';\n        return $columns;\n    } );\n    add_action( \"manage_{$wpide_type}_posts_custom_column\", $wpide_media_count_cell, 10, 2 );\n}\n\nadd_action( 'admin_head-edit.php', function () {\n    echo '<style>.column-wpide_media_count{width:4em;text-align:center}</style>';\n} );",
            "requires": [],
            "notes": "Counts files attached through \"Uploaded to\"; images picked from the library after upload are not attached."
        },
        {
            "id": "admin-parent-page-column",
            "version": 1,
            "title": "Parent Page Column for Pages",
            "description": "Adds a Parent column to the Pages list with a link to each page's parent, so the structure of a large site is clear even when the list is sorted or searched.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "pages",
                "hierarchy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_page_posts_columns', function ( $columns ) {\n    $columns['wpide_parent'] = 'Parent';\n    return $columns;\n} );\n\nadd_action( 'manage_page_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_parent' !== $column ) {\n        return;\n    }\n    $parent = (int) wp_get_post_parent_id( $post_id );\n    if ( ! $parent ) {\n        echo '&ndash;';\n        return;\n    }\n    printf( '<a href=\"%s\">%s</a>', esc_url( get_edit_post_link( $parent ) ), esc_html( get_the_title( $parent ) ) );\n}, 10, 2 );",
            "requires": [],
            "notes": "Top-level pages show a dash."
        },
        {
            "id": "admin-scheduled-countdown",
            "version": 1,
            "title": "Countdown for Scheduled Posts in Admin Lists",
            "description": "Shows how long until each scheduled post goes live, such as \"in 3 hours\", next to the date in admin lists, so the publishing queue is easy to read.",
            "category": "admin",
            "tags": [
                "scheduling",
                "admin-lists",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'post_date_column_status', function ( $status, $post ) {\n    if ( 'future' !== $post->post_status ) {\n        return $status;\n    }\n\n    $time = get_post_time( 'U', true, $post );\n\n    if ( $time <= time() ) {\n        return $status . '<br><span style=\"color:#b32d2e\">missed schedule</span>';\n    }\n\n    return $status . '<br><span style=\"color:#2271b1\">in ' . esc_html( human_time_diff( $time ) ) . '</span>';\n}, 10, 2 );",
            "requires": [],
            "notes": "Posts whose scheduled time has already passed get a red \"missed schedule\" label."
        },
        {
            "id": "admin-revisions-count-column",
            "version": 1,
            "title": "Revisions Count Column",
            "description": "Adds a Revisions column to the Posts and Pages lists showing how many saved revisions each item has, linking to the revision browser, to find heavily edited content.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "revisions",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_revision_counts = null;\n\n$wpide_revisions_cell = function ( $column, $post_id ) use ( &$wpide_revision_counts ) {\n    if ( 'wpide_revisions' !== $column ) {\n        return;\n    }\n\n    if ( null === $wpide_revision_counts ) {\n        global $wpdb, $wp_query;\n        $ids                   = $wp_query && $wp_query->posts ? array_map( 'absint', wp_list_pluck( $wp_query->posts, 'ID' ) ) : array( (int) $post_id );\n        $wpide_revision_counts = wp_list_pluck( $wpdb->get_results(\n            \"SELECT post_parent, COUNT(*) AS total FROM {$wpdb->posts} WHERE post_type = 'revision' AND post_parent IN (\" . implode( ',', $ids ? $ids : array( 0 ) ) . ') GROUP BY post_parent'\n        ), 'total', 'post_parent' );\n    }\n\n    $count = (int) ( $wpide_revision_counts[ $post_id ] ?? 0 );\n    if ( ! $count ) {\n        echo '0';\n        return;\n    }\n    $latest = wp_get_post_revisions( $post_id, array( 'posts_per_page' => 1, 'fields' => 'ids' ) );\n    printf( '<a href=\"%s\">%d</a>', esc_url( admin_url( 'revision.php?revision=' . (int) reset( $latest ) ) ), $count );\n};\n\nforeach ( array( 'post', 'page' ) as $wpide_type ) {\n    add_filter( \"manage_{$wpide_type}_posts_columns\", function ( $columns ) {\n        $columns['wpide_revisions'] = 'Revisions';\n        return $columns;\n    } );\n    add_action( \"manage_{$wpide_type}_posts_custom_column\", $wpide_revisions_cell, 10, 2 );\n}",
            "requires": [],
            "notes": "Counts come from one grouped query per screen."
        },
        {
            "id": "admin-filter-pages-by-template",
            "version": 1,
            "title": "Filter Pages by Template",
            "description": "Adds a template dropdown above the Pages list so you can show only the pages that use a given page template, such as every landing page or full-width page.",
            "category": "admin",
            "tags": [
                "admin-filters",
                "pages",
                "templates"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'restrict_manage_posts', function ( $post_type ) {\n    if ( 'page' !== $post_type ) {\n        return;\n    }\n\n    $current   = isset( $_GET['wpide_template'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_template'] ) ) : '';\n    $templates = wp_get_theme()->get_page_templates( null, 'page' );\n\n    echo '<label class=\"screen-reader-text\" for=\"wpide-template-filter\">Filter by template</label>';\n    echo '<select name=\"wpide_template\" id=\"wpide-template-filter\"><option value=\"\">All templates</option>';\n    printf( '<option value=\"default\"%s>Default template</option>', selected( $current, 'default', false ) );\n    foreach ( $templates as $file => $name ) {\n        printf( '<option value=\"%s\"%s>%s</option>', esc_attr( $file ), selected( $current, $file, false ), esc_html( $name ) );\n    }\n    echo '</select>';\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || empty( $_GET['wpide_template'] ) || 'page' !== $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $template = sanitize_text_field( wp_unslash( $_GET['wpide_template'] ) );\n\n    if ( 'default' === $template ) {\n        $query->set( 'meta_query', array(\n            'relation' => 'OR',\n            array( 'key' => '_wp_page_template', 'compare' => 'NOT EXISTS' ),\n            array( 'key' => '_wp_page_template', 'value' => array( '', 'default' ), 'compare' => 'IN' ),\n        ) );\n        return;\n    }\n\n    $query->set( 'meta_query', array( array( 'key' => '_wp_page_template', 'value' => $template ) ) );\n} );",
            "requires": [],
            "notes": "\"Default template\" shows pages that do not use a custom template."
        },
        {
            "id": "admin-filter-by-featured-image",
            "version": 1,
            "title": "Filter Posts With or Without a Featured Image",
            "description": "Adds a \"Featured image\" dropdown above admin post lists to show only posts that have one or only those that are missing one, which makes fixing gaps quick.",
            "category": "admin",
            "tags": [
                "admin-filters",
                "featured-image",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'restrict_manage_posts', function ( $post_type ) {\n    if ( ! post_type_supports( $post_type, 'thumbnail' ) ) {\n        return;\n    }\n\n    $current = isset( $_GET['wpide_thumb'] ) ? sanitize_key( $_GET['wpide_thumb'] ) : '';\n    ?>\n    <label class=\"screen-reader-text\" for=\"wpide-thumb-filter\">Filter by featured image</label>\n    <select name=\"wpide_thumb\" id=\"wpide-thumb-filter\">\n        <option value=\"\">Featured image: any</option>\n        <option value=\"with\" <?php selected( $current, 'with' ); ?>>With featured image</option>\n        <option value=\"without\" <?php selected( $current, 'without' ); ?>>Without featured image</option>\n    </select>\n    <?php\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || empty( $_GET['wpide_thumb'] ) ) {\n        return;\n    }\n\n    $compare = 'with' === $_GET['wpide_thumb'] ? 'EXISTS' : 'NOT EXISTS';\n    $meta    = is_array( $query->get( 'meta_query' ) ) ? $query->get( 'meta_query' ) : array();\n    $meta[]  = array( 'key' => '_thumbnail_id', 'compare' => $compare );\n    $query->set( 'meta_query', $meta );\n} );",
            "requires": [],
            "notes": "Works on every post type that supports featured images."
        },
        {
            "id": "admin-media-document-type-filters",
            "version": 1,
            "title": "PDF, Document and Spreadsheet Filters in the Media Library",
            "description": "Adds PDFs, Documents, Spreadsheets and Archives to the Media Library type filter next to Images and Video, so non-image files are easy to find.",
            "category": "admin",
            "tags": [
                "media",
                "admin-filters",
                "documents"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'post_mime_types', function ( $types ) {\n    $types['application/pdf'] = array( 'PDFs', 'Manage PDFs', _n_noop( 'PDF <span class=\"count\">(%s)</span>', 'PDFs <span class=\"count\">(%s)</span>' ) );\n\n    $types['application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,application/rtf,text/plain'] = array(\n        'Documents',\n        'Manage Documents',\n        _n_noop( 'Document <span class=\"count\">(%s)</span>', 'Documents <span class=\"count\">(%s)</span>' ),\n    );\n\n    $types['application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.oasis.opendocument.spreadsheet,text/csv'] = array(\n        'Spreadsheets',\n        'Manage Spreadsheets',\n        _n_noop( 'Spreadsheet <span class=\"count\">(%s)</span>', 'Spreadsheets <span class=\"count\">(%s)</span>' ),\n    );\n\n    $types['application/zip,application/x-zip-compressed,application/x-rar-compressed,application/x-7z-compressed,application/gzip'] = array(\n        'Archives',\n        'Manage Archives',\n        _n_noop( 'Archive <span class=\"count\">(%s)</span>', 'Archives <span class=\"count\">(%s)</span>' ),\n    );\n\n    return $types;\n} );",
            "requires": [],
            "notes": "Each filter only appears once at least one file of that type has been uploaded."
        },
        {
            "id": "admin-bulk-stick-unstick",
            "version": 1,
            "title": "Bulk Make Posts Sticky or Not Sticky",
            "description": "Adds \"Make sticky\" and \"Remove sticky\" to the bulk actions on the Posts screen, so you can pin or unpin many posts at once instead of editing each one.",
            "category": "admin",
            "tags": [
                "bulk-actions",
                "sticky-posts",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'bulk_actions-edit-post', function ( $actions ) {\n    if ( current_user_can( 'edit_others_posts' ) ) {\n        $actions['wpide_stick']   = 'Make sticky';\n        $actions['wpide_unstick'] = 'Remove sticky';\n    }\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-edit-post', function ( $redirect, $action, $post_ids ) {\n    if ( ! in_array( $action, array( 'wpide_stick', 'wpide_unstick' ), true ) || ! current_user_can( 'edit_others_posts' ) ) {\n        return $redirect;\n    }\n\n    foreach ( $post_ids as $post_id ) {\n        if ( current_user_can( 'edit_post', $post_id ) ) {\n            'wpide_stick' === $action ? stick_post( $post_id ) : unstick_post( $post_id );\n        }\n    }\n\n    return add_query_arg( 'wpide_sticky_done', count( $post_ids ), $redirect );\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    if ( ! empty( $_GET['wpide_sticky_done'] ) ) {\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>Updated sticky status of %d posts.</p></div>', absint( $_GET['wpide_sticky_done'] ) );\n    }\n} );",
            "requires": [],
            "notes": "Sticky posts show first on the blog page in most themes."
        },
        {
            "id": "admin-bulk-duplicate-posts",
            "version": 1,
            "title": "Bulk Duplicate Posts and Pages as Drafts",
            "description": "Adds a \"Duplicate as draft\" bulk action to post and page lists that copies the selected items with their content, categories, tags and custom fields into new drafts.",
            "category": "admin",
            "tags": [
                "bulk-actions",
                "duplicate",
                "drafts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_bulk_dup_types = array( 'post', 'page' );\n\nforeach ( $wpide_bulk_dup_types as $wpide_type ) {\n    add_filter( \"bulk_actions-edit-{$wpide_type}\", function ( $actions ) {\n        $actions['wpide_duplicate'] = 'Duplicate as draft';\n        return $actions;\n    } );\n\n    add_filter( \"handle_bulk_actions-edit-{$wpide_type}\", function ( $redirect, $action, $post_ids ) {\n        if ( 'wpide_duplicate' !== $action ) {\n            return $redirect;\n        }\n\n        $done = 0;\n        foreach ( $post_ids as $post_id ) {\n            $post = get_post( $post_id );\n            if ( ! $post || ! current_user_can( 'edit_post', $post_id ) ) {\n                continue;\n            }\n\n            $new_id = wp_insert_post( wp_slash( array(\n                'post_type'      => $post->post_type,\n                'post_status'    => 'draft',\n                'post_title'     => $post->post_title . ' (Copy)',\n                'post_content'   => $post->post_content,\n                'post_excerpt'   => $post->post_excerpt,\n                'post_parent'    => $post->post_parent,\n                'menu_order'     => $post->menu_order,\n                'post_password'  => $post->post_password,\n                'comment_status' => $post->comment_status,\n                'ping_status'    => $post->ping_status,\n                'post_author'    => get_current_user_id(),\n            ) ), true );\n\n            if ( is_wp_error( $new_id ) ) {\n                continue;\n            }\n\n            foreach ( get_object_taxonomies( $post->post_type ) as $taxonomy ) {\n                wp_set_object_terms( $new_id, wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) ), $taxonomy );\n            }\n            foreach ( get_post_meta( $post_id ) as $key => $values ) {\n                if ( in_array( $key, array( '_edit_lock', '_edit_last', '_wp_old_slug' ), true ) ) {\n                    continue;\n                }\n                foreach ( $values as $value ) {\n                    add_post_meta( $new_id, $key, wp_slash( maybe_unserialize( $value ) ) );\n                }\n            }\n            $done++;\n        }\n\n        return add_query_arg( 'wpide_duplicated', $done, $redirect );\n    }, 10, 3 );\n}\n\nadd_action( 'admin_notices', function () {\n    if ( ! empty( $_GET['wpide_duplicated'] ) ) {\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>Created %d draft copies.</p></div>', absint( $_GET['wpide_duplicated'] ) );\n    }\n} );",
            "requires": [],
            "notes": "Copies are owned by you and titled \"(Copy)\"; comments and revisions are not copied."
        },
        {
            "id": "admin-bulk-export-csv",
            "version": 1,
            "title": "Export Selected Posts to CSV",
            "description": "Adds an \"Export to CSV\" bulk action to the Posts and Pages screens that downloads the selected items with ID, title, status, author, dates, categories, link and word count.",
            "category": "admin",
            "tags": [
                "bulk-actions",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "foreach ( array( 'post', 'page' ) as $wpide_type ) {\n    add_filter( \"bulk_actions-edit-{$wpide_type}\", function ( $actions ) {\n        $actions['wpide_export_csv'] = 'Export to CSV';\n        return $actions;\n    } );\n\n    add_filter( \"handle_bulk_actions-edit-{$wpide_type}\", function ( $redirect, $action, $post_ids ) {\n        if ( 'wpide_export_csv' !== $action || ! current_user_can( 'edit_posts' ) ) {\n            return $redirect;\n        }\n\n        $rows = array( array( 'ID', 'Title', 'Status', 'Author', 'Published', 'Modified', 'Categories', 'URL', 'Words' ) );\n        foreach ( $post_ids as $post_id ) {\n            $post = get_post( $post_id );\n            if ( ! $post || ! current_user_can( 'read_post', $post_id ) ) {\n                continue;\n            }\n            $rows[] = array(\n                $post->ID,\n                $post->post_title,\n                $post->post_status,\n                get_the_author_meta( 'display_name', $post->post_author ),\n                $post->post_date,\n                $post->post_modified,\n                implode( ', ', wp_list_pluck( (array) get_the_category( $post->ID ), 'name' ) ),\n                get_permalink( $post ),\n                str_word_count( wp_strip_all_tags( $post->post_content ) ),\n            );\n        }\n\n        if ( defined( 'WP_CLI' ) && WP_CLI ) {\n            return $redirect; // Nothing to download from the command line.\n        }\n\n        nocache_headers();\n        header( 'Content-Type: text/csv; charset=utf-8' );\n        header( 'Content-Disposition: attachment; filename=posts-' . gmdate( 'Y-m-d' ) . '.csv' );\n        $out = fopen( 'php://output', 'w' );\n        fwrite( $out, \"\\xEF\\xBB\\xBF\" ); // UTF-8 BOM for Excel.\n        foreach ( $rows as $row ) {\n            // Prevent spreadsheet formula injection.\n            fputcsv( $out, array_map( function ( $cell ) {\n                return is_string( $cell ) && preg_match( '/^[=+\\-@]/', $cell ) ? \"'\" . $cell : $cell;\n            }, $row ) );\n        }\n        fclose( $out );\n        exit;\n    }, 10, 3 );\n}",
            "requires": [],
            "notes": "Opens directly in Excel or Google Sheets; add columns in the $row array."
        },
        {
            "id": "admin-bulk-regenerate-slugs",
            "version": 1,
            "title": "Bulk Rebuild Slugs From Titles",
            "description": "Adds a \"Rebuild slug from title\" bulk action that regenerates the URL slug of the selected drafts or posts from their current titles, handy after importing content with messy slugs.",
            "category": "admin",
            "tags": [
                "bulk-actions",
                "slugs",
                "permalinks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'bulk_actions-edit-post', function ( $actions ) {\n    $actions['wpide_rebuild_slug'] = 'Rebuild slug from title';\n    return $actions;\n} );\nadd_filter( 'bulk_actions-edit-page', function ( $actions ) {\n    $actions['wpide_rebuild_slug'] = 'Rebuild slug from title';\n    return $actions;\n} );\n\n$wpide_rebuild_slugs = function ( $redirect, $action, $post_ids ) {\n    if ( 'wpide_rebuild_slug' !== $action ) {\n        return $redirect;\n    }\n\n    $done = 0;\n    foreach ( $post_ids as $post_id ) {\n        $post = get_post( $post_id );\n        if ( ! $post || ! current_user_can( 'edit_post', $post_id ) || '' === trim( $post->post_title ) ) {\n            continue;\n        }\n        $slug = wp_unique_post_slug( sanitize_title( $post->post_title ), $post->ID, $post->post_status, $post->post_type, $post->post_parent );\n        if ( $slug !== $post->post_name ) {\n            wp_update_post( array( 'ID' => $post->ID, 'post_name' => $slug ) );\n            $done++;\n        }\n    }\n\n    return add_query_arg( 'wpide_slugs_rebuilt', $done, $redirect );\n};\nadd_filter( 'handle_bulk_actions-edit-post', $wpide_rebuild_slugs, 10, 3 );\nadd_filter( 'handle_bulk_actions-edit-page', $wpide_rebuild_slugs, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_slugs_rebuilt'] ) ) {\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>Rebuilt %d slugs.</p></div>', absint( $_GET['wpide_slugs_rebuilt'] ) );\n    }\n} );",
            "requires": [],
            "notes": "Changing the slug of a published post changes its URL; WordPress remembers the old slug and redirects it, but update internal links too."
        },
        {
            "id": "admin-quick-edit-custom-field",
            "version": 1,
            "title": "Quick Edit and Bulk Edit for a Custom Field",
            "description": "Adds a custom field, such as a subtitle or price, to the Quick Edit and Bulk Edit panels of the Posts screen, with a matching column, so values can be changed without opening each post.",
            "category": "admin",
            "tags": [
                "quick-edit",
                "custom-fields",
                "bulk-edit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Custom field and its label.\n$wpide_qe_key   = 'subtitle';\n$wpide_qe_label = 'Subtitle';\n\nadd_filter( 'manage_post_posts_columns', function ( $columns ) use ( $wpide_qe_label ) {\n    $columns['wpide_qe'] = $wpide_qe_label;\n    return $columns;\n} );\n\nadd_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) use ( $wpide_qe_key ) {\n    if ( 'wpide_qe' === $column ) {\n        // The hidden span feeds the Quick Edit box.\n        $value = (string) get_post_meta( $post_id, $wpide_qe_key, true );\n        printf( '<span class=\"wpide-qe-value\">%s</span>', esc_html( $value ) );\n    }\n}, 10, 2 );\n\n$wpide_qe_box = function ( $column, $post_type ) use ( $wpide_qe_label ) {\n    static $printed = array();\n    if ( 'wpide_qe' !== $column || 'post' !== $post_type || isset( $printed[ current_action() ] ) ) {\n        return;\n    }\n    $printed[ current_action() ] = true;\n    printf( '<input type=\"hidden\" name=\"wpide_qe_nonce\" value=\"%s\">', esc_attr( wp_create_nonce( 'wpide_qe_save' ) ) );\n    printf(\n        '<fieldset class=\"inline-edit-col-right\"><div class=\"inline-edit-col\"><label><span class=\"title\">%s</span><span class=\"input-text-wrap\"><input type=\"text\" name=\"wpide_qe_value\" value=\"\"></span></label></div></fieldset>',\n        esc_html( $wpide_qe_label )\n    );\n};\nadd_action( 'quick_edit_custom_box', $wpide_qe_box, 10, 2 );\nadd_action( 'bulk_edit_custom_box', $wpide_qe_box, 10, 2 );\n\nadd_action( 'save_post', function ( $post_id ) use ( $wpide_qe_key ) {\n    if ( ! isset( $_POST['wpide_qe_nonce'], $_POST['wpide_qe_value'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_qe_nonce'] ), 'wpide_qe_save' ) || ! current_user_can( 'edit_post', $post_id ) ) {\n        return;\n    }\n    $value = sanitize_text_field( wp_unslash( $_POST['wpide_qe_value'] ) );\n    // Bulk edit with an empty box keeps existing values.\n    if ( '' === $value && isset( $_REQUEST['bulk_edit'] ) ) {\n        return;\n    }\n    update_post_meta( $post_id, $wpide_qe_key, $value );\n} );\n\nadd_action( 'admin_footer-edit.php', function () {\n    ?>\n    <script>\n    jQuery( function ( $ ) {\n        if ( ! window.inlineEditPost ) { return; }\n        var original = inlineEditPost.edit;\n        inlineEditPost.edit = function ( id ) {\n            original.apply( this, arguments );\n            var postId = typeof id === 'object' ? parseInt( this.getId( id ), 10 ) : id;\n            var value = $( '#post-' + postId ).find( '.wpide-qe-value' ).text();\n            $( '#edit-' + postId ).find( 'input[name=\"wpide_qe_value\"]' ).val( value );\n        };\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Change the key and label; in Bulk Edit an empty box leaves each post's value unchanged."
        },
        {
            "id": "admin-featured-star-toggle",
            "version": 1,
            "title": "One-Click \"Featured\" Star in the Posts List",
            "description": "Adds a star column to the Posts screen that marks or unmarks a post as featured with one click, saving a \"featured\" custom field your theme or queries can use.",
            "category": "admin",
            "tags": [
                "featured-posts",
                "admin-columns",
                "ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Custom field that stores the flag.\n$wpide_featured_key = '_wpide_featured';\n\nadd_filter( 'manage_post_posts_columns', function ( $columns ) {\n    return array_slice( $columns, 0, 1, true ) + array( 'wpide_featured' => '<span class=\"dashicons dashicons-star-filled\" aria-hidden=\"true\"></span><span class=\"screen-reader-text\">Featured</span>' ) + array_slice( $columns, 1, null, true );\n} );\n\nadd_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) use ( $wpide_featured_key ) {\n    if ( 'wpide_featured' !== $column ) {\n        return;\n    }\n    $on = '1' === get_post_meta( $post_id, $wpide_featured_key, true );\n    printf(\n        '<button type=\"button\" class=\"wpide-star button-link\" data-id=\"%d\" aria-pressed=\"%s\" title=\"Toggle featured\"><span class=\"dashicons dashicons-star-%s\" aria-hidden=\"true\"></span><span class=\"screen-reader-text\">Featured</span></button>',\n        (int) $post_id,\n        $on ? 'true' : 'false',\n        $on ? 'filled' : 'empty'\n    );\n}, 10, 2 );\n\nadd_action( 'wp_ajax_wpide_toggle_featured', function () use ( $wpide_featured_key ) {\n    check_ajax_referer( 'wpide_featured_toggle', 'nonce' );\n    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;\n    if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {\n        wp_send_json_error( null, 403 );\n    }\n    $on = '1' !== get_post_meta( $post_id, $wpide_featured_key, true );\n    $on ? update_post_meta( $post_id, $wpide_featured_key, '1' ) : delete_post_meta( $post_id, $wpide_featured_key );\n    wp_send_json_success( array( 'featured' => $on ) );\n} );\n\nadd_action( 'admin_footer-edit.php', function () {\n    if ( 'post' !== get_current_screen()->post_type ) {\n        return;\n    }\n    ?>\n    <style>.column-wpide_featured{width:2.2em}.wpide-star .dashicons{color:#dba617}</style>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var button = event.target.closest( '.wpide-star' );\n        if ( ! button ) { return; }\n        var body = new FormData();\n        body.append( 'action', 'wpide_toggle_featured' );\n        body.append( 'post_id', button.dataset.id );\n        body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( 'wpide_featured_toggle' ) ); ?> );\n        fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } )\n            .then( function ( response ) { return response.json(); } )\n            .then( function ( result ) {\n                if ( ! result.success ) { return; }\n                button.setAttribute( 'aria-pressed', result.data.featured ? 'true' : 'false' );\n                button.querySelector( '.dashicons' ).className = 'dashicons dashicons-star-' + ( result.data.featured ? 'filled' : 'empty' );\n            } );\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Query featured posts with 'meta_key' => '_wpide_featured', 'meta_value' => '1'; change $wpide_featured_key to reuse an existing field."
        },
        {
            "id": "admin-warning-on-key-pages",
            "version": 1,
            "title": "Warning Banner When Editing Key Pages",
            "description": "Shows a prominent warning at the top of the editor when someone opens an important page, such as the homepage, checkout or a landing page, reminding them to be careful.",
            "category": "admin",
            "tags": [
                "notices",
                "pages",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Page ID => message.\n$wpide_key_pages = array(\n    // 123 => 'This page is linked from every email we send. Check links after editing.',\n);\n\n$wpide_key_page_message = function () use ( $wpide_key_pages ) {\n    $post = get_post();\n    if ( ! $post || 'page' !== $post->post_type ) {\n        return '';\n    }\n    $pages = $wpide_key_pages;\n    if ( (int) get_option( 'page_on_front' ) ) {\n        $pages[ (int) get_option( 'page_on_front' ) ] = $pages[ (int) get_option( 'page_on_front' ) ] ?? 'This is the homepage. Changes are visible to every visitor immediately after updating.';\n    }\n    if ( (int) get_option( 'wp_page_for_privacy_policy' ) ) {\n        $pages[ (int) get_option( 'wp_page_for_privacy_policy' ) ] = $pages[ (int) get_option( 'wp_page_for_privacy_policy' ) ] ?? 'This is the privacy policy. Legal review may be required for changes.';\n    }\n    return $pages[ $post->ID ] ?? '';\n};\n\n// Classic editor.\nadd_action( 'admin_notices', function () use ( $wpide_key_page_message ) {\n    $screen  = get_current_screen();\n    $message = $screen && 'post' === $screen->base ? $wpide_key_page_message() : '';\n    if ( $message ) {\n        printf( '<div class=\"notice notice-warning\"><p><strong>Careful:</strong> %s</p></div>', esc_html( $message ) );\n    }\n} );\n\n// Block editor.\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_key_page_message ) {\n    $message = $wpide_key_page_message();\n    if ( ! $message ) {\n        return;\n    }\n    wp_add_inline_script(\n        'wp-notices',\n        sprintf(\n            'wp.domReady(function(){wp.data.dispatch(\"core/notices\").createNotice(\"warning\",%s,{isDismissible:false,id:\"wpide-key-page\"});});',\n            wp_json_encode( 'Careful: ' . $message )\n        ),\n        'after'\n    );\n    wp_enqueue_script( 'wp-notices' );\n    wp_enqueue_script( 'wp-dom-ready' );\n} );",
            "requires": [],
            "notes": "The homepage and privacy page are included automatically; add more page IDs and messages to $wpide_key_pages."
        },
        {
            "id": "admin-notice-editing-others-post",
            "version": 1,
            "title": "Notice When Editing Someone Else's Post",
            "description": "Reminds editors with a notice in the block editor when the post they opened was written by another author, to avoid unintended changes to a colleague's work.",
            "category": "admin",
            "tags": [
                "notices",
                "editor",
                "authors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $post = get_post();\n    if ( ! $post || ! $post->post_author || (int) $post->post_author === get_current_user_id() || 'auto-draft' === $post->post_status ) {\n        return;\n    }\n\n    $message = sprintf( 'You are editing a %s written by %s.', get_post_type_object( $post->post_type )->labels->singular_name, get_the_author_meta( 'display_name', $post->post_author ) );\n\n    wp_add_inline_script(\n        'wp-notices',\n        sprintf( 'wp.domReady(function(){wp.data.dispatch(\"core/notices\").createInfoNotice(%s,{id:\"wpide-other-author\"});});', wp_json_encode( $message ) ),\n        'after'\n    );\n    wp_enqueue_script( 'wp-notices' );\n    wp_enqueue_script( 'wp-dom-ready' );\n} );",
            "requires": [],
            "notes": "Shows the original author's name; it does not lock the post."
        },
        {
            "id": "admin-collapsible-notices",
            "version": 1,
            "title": "Tuck Admin Notices Into a Collapsible Panel",
            "description": "Gathers the banners plugins print at the top of admin screens into one \"Notices\" panel that stays closed until you open it, while errors and your own save messages stay visible.",
            "category": "admin",
            "tags": [
                "notices",
                "admin",
                "declutter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_footer', function () {\n    ?>\n    <style>\n    #wpide-notices-panel{margin:12px 20px 0 2px}\n    #wpide-notices-panel summary{cursor:pointer;color:#2271b1;font-weight:600}\n    #wpide-notices-panel[open] summary{margin-bottom:8px}\n    </style>\n    <script>\n    ( function () {\n        var wrap = document.querySelector( '#wpbody-content .wrap' );\n        if ( ! wrap ) { return; }\n        var notices = Array.prototype.filter.call(\n            document.querySelectorAll( '#wpbody-content > .notice, #wpbody-content > .updated, #wpbody-content > .update-nag, #wpbody-content .wrap > .notice, #wpbody-content .wrap > .updated' ),\n            function ( el ) {\n                return ! el.classList.contains( 'notice-error' ) && ! el.classList.contains( 'error' ) && ! el.classList.contains( 'notice-success' ) && ! el.classList.contains( 'inline' ) && ! el.id.match( /^(message|setting-error-settings_updated)$/ );\n            }\n        );\n        if ( notices.length < 2 ) { return; }\n        var panel = document.createElement( 'details' );\n        panel.id = 'wpide-notices-panel';\n        var summary = document.createElement( 'summary' );\n        summary.textContent = 'Notices (' + notices.length + ')';\n        panel.appendChild( summary );\n        notices.forEach( function ( el ) { panel.appendChild( el ); } );\n        wrap.parentNode.insertBefore( panel, wrap );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Success messages after saving and notices marked as errors are never hidden."
        },
        {
            "id": "admin-bar-site-logo",
            "version": 1,
            "title": "Your Site Icon in Place of the WordPress Logo in the Admin Bar",
            "description": "Replaces the WordPress logo menu in the admin bar with your Site Icon linking to the dashboard, a simple branding touch for client sites.",
            "category": "admin",
            "tags": [
                "branding",
                "admin-bar",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    $admin_bar->remove_node( 'wp-logo' );\n\n    $icon = get_site_icon_url( 32 );\n    $name = get_bloginfo( 'name' );\n    $mark = $icon\n        ? '<img src=\"' . esc_url( $icon ) . '\" alt=\"\" width=\"20\" height=\"20\" style=\"width:20px;height:20px;border-radius:3px;vertical-align:middle;margin-top:-3px\">'\n        : '<span style=\"display:inline-block;width:20px;height:20px;line-height:20px;text-align:center;border-radius:3px;background:#2271b1;color:#fff;font-weight:600\">' . esc_html( strtoupper( mb_substr( $name, 0, 1 ) ) ) . '</span>';\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-site-logo',\n        'title' => $mark . '<span class=\"screen-reader-text\">' . esc_html( $name ) . '</span>',\n        'href'  => admin_url(),\n    ) );\n}, 11 );",
            "requires": [],
            "notes": "Set a Site Icon under Settings > General first; without one the site name's first letter is shown."
        },
        {
            "id": "admin-bar-help-menu",
            "version": 1,
            "title": "\"Get Help\" Menu in the Admin Bar",
            "description": "Adds a \"Help\" menu to the admin bar with links to your support desk, guides and contact details, so clients always know where to turn when something is unclear.",
            "category": "admin",
            "tags": [
                "branding",
                "admin-bar",
                "support"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label => URL.\n$wpide_help_links = array(\n    'Open a support ticket' => 'https://example.com/support',\n    'Editing guide'         => 'https://example.com/guide',\n    'Email us'              => 'mailto:help@example.com',\n);\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) use ( $wpide_help_links ) {\n    if ( ! is_user_logged_in() || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $admin_bar->add_node( array(\n        'id'     => 'wpide-help',\n        'parent' => 'top-secondary',\n        'title'  => '<span class=\"ab-icon dashicons dashicons-sos\" style=\"margin-top:2px\" aria-hidden=\"true\"></span><span class=\"ab-label\">Help</span>',\n    ) );\n\n    foreach ( $wpide_help_links as $label => $url ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-help-' . sanitize_title( $label ),\n            'parent' => 'wpide-help',\n            'title'  => esc_html( $label ),\n            'href'   => esc_url( $url ),\n            'meta'   => array( 'target' => 0 === strpos( $url, 'mailto:' ) ? '' : '_blank', 'rel' => 'noopener' ),\n        ) );\n    }\n}, 5 );",
            "requires": [],
            "notes": "Edit the links in $wpide_help_links; they open in a new tab."
        },
        {
            "id": "admin-rename-menu-labels",
            "version": 1,
            "title": "Rename Any Admin Menu Item",
            "description": "Renames admin menu entries to words your team understands, such as \"Appearance\" to \"Design\" or a plugin's menu to \"Bookings\", without touching the plugin.",
            "category": "admin",
            "tags": [
                "admin-menu",
                "labels",
                "white-label"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Current label => new label.\n$wpide_menu_labels = array(\n    'Appearance' => 'Design',\n    'Comments'   => 'Feedback',\n    'Tools'      => 'Utilities',\n);\n\nadd_action( 'admin_menu', function () use ( $wpide_menu_labels ) {\n    global $menu, $submenu;\n\n    $rename = function ( $label ) use ( $wpide_menu_labels ) {\n        // Keep counters such as \"Comments <span class=\"awaiting-mod\">3</span>\".\n        $plain = trim( wp_strip_all_tags( preg_replace( '/<span.*$/s', '', $label ) ) );\n        return isset( $wpide_menu_labels[ $plain ] ) ? str_replace( $plain, $wpide_menu_labels[ $plain ], $label ) : $label;\n    };\n\n    foreach ( (array) $menu as $i => $item ) {\n        $menu[ $i ][0] = $rename( $item[0] );\n    }\n    foreach ( (array) $submenu as $parent => $items ) {\n        foreach ( $items as $i => $item ) {\n            $submenu[ $parent ][ $i ][0] = $rename( $item[0] );\n        }\n    }\n}, 999 );",
            "requires": [],
            "notes": "Use the exact current label as the key; submenu items are renamed too. Screen titles inside plugins keep their own wording."
        },
        {
            "id": "admin-dark-mode-per-user",
            "version": 1,
            "title": "Dark Mode for wp-admin (Per User)",
            "description": "Adds a \"Dark mode\" checkbox to each user's profile that switches the whole admin area to a dark look, with images and videos kept in their real colors.",
            "category": "admin",
            "tags": [
                "dark-mode",
                "admin",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'personal_options', function ( $user ) {\n    wp_nonce_field( 'wpide_dark_mode', 'wpide_dark_nonce' );\n    printf(\n        '<tr><th scope=\"row\">Dark mode</th><td><label><input type=\"checkbox\" name=\"wpide_dark_mode\" value=\"1\" %s> Use a dark admin area</label></td></tr>',\n        checked( get_user_meta( $user->ID, 'wpide_dark_mode', true ), '1', false )\n    );\n} );\n\n$wpide_save_dark_mode = function ( $user_id ) {\n    if ( ! current_user_can( 'edit_user', $user_id ) || ! isset( $_POST['wpide_dark_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_dark_nonce'] ), 'wpide_dark_mode' ) ) {\n        return;\n    }\n    update_user_meta( $user_id, 'wpide_dark_mode', empty( $_POST['wpide_dark_mode'] ) ? '0' : '1' );\n};\nadd_action( 'personal_options_update', $wpide_save_dark_mode );\nadd_action( 'edit_user_profile_update', $wpide_save_dark_mode );\n\nadd_action( 'admin_head', function () {\n    if ( '1' !== get_user_meta( get_current_user_id(), 'wpide_dark_mode', true ) ) {\n        return;\n    }\n    ?>\n    <style>\n    html { filter: invert(.92) hue-rotate(180deg); background: #fff; }\n    img, video, iframe, picture, canvas, .wp-color-result, .theme-screenshot, .attachment-preview .thumbnail, .editor-styles-wrapper, .block-editor-iframe__container { filter: invert(1) hue-rotate(180deg); }\n    .editor-styles-wrapper img, .editor-styles-wrapper video { filter: none; }\n    </style>\n    <?php\n} );",
            "requires": [],
            "notes": "The theme uses a CSS filter, so a few plugin screens may look slightly off; the block editor canvas keeps your theme's real colors."
        },
        {
            "id": "admin-plugin-private-notes",
            "version": 1,
            "title": "Private Notes on the Plugins Screen",
            "description": "Adds a \"Note\" link under each plugin on the Plugins screen where administrators can record why it is installed, who requested it or what to check after updates.",
            "category": "admin",
            "tags": [
                "plugins",
                "notes",
                "documentation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'plugin_row_meta', function ( $meta, $file ) {\n    if ( ! current_user_can( 'activate_plugins' ) ) {\n        return $meta;\n    }\n    $notes = (array) get_option( 'wpide_plugin_notes', array() );\n    $note  = $notes[ $file ] ?? '';\n\n    $meta[] = sprintf(\n        '<span class=\"wpide-plugin-note\" data-plugin=\"%s\" data-note=\"%s\">%s<a href=\"#\" class=\"wpide-edit-note\">%s</a></span>',\n        esc_attr( $file ),\n        esc_attr( $note ),\n        $note ? '<em style=\"color:#3c434a\">' . esc_html( $note ) . '</em> &middot; ' : '',\n        $note ? 'Edit note' : 'Add note'\n    );\n    return $meta;\n}, 10, 2 );\n\nadd_action( 'wp_ajax_wpide_plugin_note', function () {\n    check_ajax_referer( 'wpide_plugin_note', 'nonce' );\n    if ( ! current_user_can( 'activate_plugins' ) ) {\n        wp_send_json_error( null, 403 );\n    }\n    $plugin = isset( $_POST['plugin'] ) ? sanitize_text_field( wp_unslash( $_POST['plugin'] ) ) : '';\n    $note   = isset( $_POST['note'] ) ? sanitize_text_field( wp_unslash( $_POST['note'] ) ) : '';\n    $notes  = (array) get_option( 'wpide_plugin_notes', array() );\n\n    if ( '' === $note ) {\n        unset( $notes[ $plugin ] );\n    } else {\n        $notes[ $plugin ] = $note;\n    }\n    update_option( 'wpide_plugin_notes', $notes, false );\n    wp_send_json_success();\n} );\n\nadd_action( 'admin_footer-plugins.php', function () {\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var link = event.target.closest( '.wpide-edit-note' );\n        if ( ! link ) { return; }\n        event.preventDefault();\n        var wrap = link.closest( '.wpide-plugin-note' );\n        var note = window.prompt( 'Note for this plugin (leave empty to remove):', wrap.dataset.note );\n        if ( null === note ) { return; }\n        var body = new FormData();\n        body.append( 'action', 'wpide_plugin_note' );\n        body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( 'wpide_plugin_note' ) ); ?> );\n        body.append( 'plugin', wrap.dataset.plugin );\n        body.append( 'note', note );\n        fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } ).then( function () { window.location.reload(); } );\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Notes are saved in one option and only visible to users who can manage plugins."
        },
        {
            "id": "admin-search-by-slug",
            "version": 1,
            "title": "Look Up Posts by URL Slug From Admin Lists",
            "description": "Lets you type slug:my-post-name into the search box of any admin post list to find the item with that URL slug, which is handy when you only have a link.",
            "category": "admin",
            "tags": [
                "admin-search",
                "slugs",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $search = trim( (string) $query->get( 's' ) );\n    if ( 0 !== stripos( $search, 'slug:' ) ) {\n        return;\n    }\n\n    $slug = trim( substr( $search, 5 ) );\n    $path = wp_parse_url( $slug, PHP_URL_PATH );\n    $slug = sanitize_title( basename( untrailingslashit( $path ? $path : $slug ) ) );\n\n    $query->set( 's', '' );\n    $query->set( 'name', $slug );\n    $query->is_search = false;\n} );",
            "requires": [],
            "notes": "You can also paste a full URL after \"slug:\"; only the last part of the path is used."
        },
        {
            "id": "admin-bar-quick-find",
            "version": 1,
            "title": "Quick Find Box in the Admin Bar",
            "description": "Adds a small search box to the admin bar on every admin screen that searches posts, pages or users, and jumps straight to the editor when you type a post ID.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "admin-search",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( ! is_admin() || ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $form = '<form action=\"' . esc_url( admin_url( 'admin-post.php' ) ) . '\" method=\"get\" style=\"display:flex;gap:4px;align-items:center;height:32px\">'\n        . '<input type=\"hidden\" name=\"action\" value=\"wpide_quick_find\">'\n        . '<input type=\"hidden\" name=\"_wpnonce\" value=\"' . esc_attr( wp_create_nonce( 'wpide_quick_find' ) ) . '\">'\n        . '<label class=\"screen-reader-text\" for=\"wpide-quick-find\">Find</label>'\n        . '<input id=\"wpide-quick-find\" name=\"wpide_q\" type=\"search\" placeholder=\"Find or ID...\" style=\"height:24px;min-height:24px;line-height:1;padding:0 6px;width:150px;font-size:13px\">'\n        . '<select name=\"wpide_in\" aria-label=\"Where\" style=\"height:24px;min-height:24px;line-height:1;font-size:12px;padding:0 20px 0 4px\">'\n        . '<option value=\"post\">Posts</option><option value=\"page\">Pages</option>' . ( current_user_can( 'list_users' ) ? '<option value=\"users\">Users</option>' : '' )\n        . '</select></form>';\n\n    $admin_bar->add_node( array(\n        'id'     => 'wpide-quick-find',\n        'parent' => 'top-secondary',\n        'title'  => $form,\n        'meta'   => array( 'class' => 'wpide-quick-find' ),\n    ) );\n}, 100 );\n\nadd_action( 'admin_post_wpide_quick_find', function () {\n    check_admin_referer( 'wpide_quick_find' );\n    $q  = isset( $_GET['wpide_q'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_q'] ) ) : '';\n    $in = isset( $_GET['wpide_in'] ) ? sanitize_key( $_GET['wpide_in'] ) : 'post';\n\n    if ( ctype_digit( $q ) && get_post( (int) $q ) && current_user_can( 'edit_post', (int) $q ) ) {\n        wp_safe_redirect( get_edit_post_link( (int) $q, 'url' ) );\n        exit;\n    }\n\n    $url = 'users' === $in && current_user_can( 'list_users' )\n        ? admin_url( 'users.php?s=' . rawurlencode( $q ) )\n        : admin_url( 'edit.php?post_type=' . ( 'page' === $in ? 'page' : 'post' ) . '&s=' . rawurlencode( $q ) );\n\n    wp_safe_redirect( $url );\n    exit;\n} );",
            "requires": [],
            "notes": "Type a number to open that post or page for editing; anything else searches the chosen list."
        },
        {
            "id": "admin-label-special-pages",
            "version": 1,
            "title": "Label Special Pages in the Pages List",
            "description": "Adds labels such as \"Contact form\" or \"Landing page\" next to chosen pages in the Pages list, the same way WordPress marks the front page, so editors know which pages matter.",
            "category": "admin",
            "tags": [
                "pages",
                "post-states",
                "admin-lists"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Page ID => label.\n$wpide_page_labels = array(\n    2 => 'Contact form',\n    // 45 => 'Landing page',\n);\n\nadd_filter( 'display_post_states', function ( $states, $post ) use ( $wpide_page_labels ) {\n    if ( isset( $wpide_page_labels[ $post->ID ] ) ) {\n        $states[ 'wpide_' . $post->ID ] = esc_html( $wpide_page_labels[ $post->ID ] );\n    }\n    return $states;\n}, 10, 2 );",
            "requires": [],
            "notes": "Map page IDs to labels in $wpide_page_labels; find an ID by hovering the page's Edit link."
        },
        {
            "id": "admin-protect-categories-with-posts",
            "version": 1,
            "title": "Prevent Deleting Categories That Still Have Posts",
            "description": "Stops anyone from deleting a category or tag while posts still use it, which avoids accidentally emptying sections of the site or breaking menus and links.",
            "category": "admin",
            "tags": [
                "categories",
                "taxonomies",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomies to protect.\n$wpide_protected_taxonomies = array( 'category', 'post_tag' );\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_protected_taxonomies ) {\n    if ( 'delete_term' !== $cap || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $term = get_term( (int) $args[0] );\n    if ( $term && ! is_wp_error( $term ) && in_array( $term->taxonomy, $wpide_protected_taxonomies, true ) && $term->count > 0 ) {\n        $caps[] = 'do_not_allow';\n    }\n\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Move or remove the posts first, then the term can be deleted; the default category is already protected by WordPress."
        },
        {
            "id": "admin-limit-to-one-category",
            "version": 1,
            "title": "Limit Posts to a Single Category",
            "description": "Keeps every post in exactly one category: when several are ticked, only the most specific one is kept on save, which keeps URLs, breadcrumbs and archives tidy.",
            "category": "admin",
            "tags": [
                "categories",
                "editorial",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'set_object_terms', function ( $object_id, $terms, $tt_ids, $taxonomy ) {\n    static $running = false;\n\n    if ( 'category' !== $taxonomy || count( $tt_ids ) < 2 || $running || 'post' !== get_post_type( $object_id ) ) {\n        return;\n    }\n\n    $chosen = null;\n    $depth  = -1;\n    foreach ( wp_get_object_terms( $object_id, 'category' ) as $term ) {\n        $d = count( get_ancestors( $term->term_id, 'category', 'taxonomy' ) );\n        if ( $d > $depth || ( $d === $depth && $term->term_id < $chosen ) ) {\n            $depth  = $d;\n            $chosen = $term->term_id;\n        }\n    }\n\n    if ( $chosen ) {\n        $running = true;\n        wp_set_object_terms( $object_id, array( (int) $chosen ), 'category' );\n        $running = false;\n    }\n}, 10, 4 );",
            "requires": [],
            "notes": "The deepest child category wins; between categories at the same level the one with the lowest ID is kept."
        },
        {
            "id": "admin-lock-published-slugs",
            "version": 1,
            "title": "Lock the URL Slug of Published Posts",
            "description": "Prevents authors and editors from changing the slug of a post or page once it is published, so shared links and search rankings do not break. Administrators can still change it.",
            "category": "admin",
            "tags": [
                "slugs",
                "permalinks",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Users with this capability may still change slugs.\n$wpide_slug_unlock_cap = 'manage_options';\n\nadd_filter( 'wp_insert_post_data', function ( $data, $postarr ) use ( $wpide_slug_unlock_cap ) {\n    if ( empty( $postarr['ID'] ) || current_user_can( $wpide_slug_unlock_cap ) ) {\n        return $data;\n    }\n\n    $current = get_post( (int) $postarr['ID'] );\n    if ( $current && 'publish' === $current->post_status && '' !== $current->post_name && 'publish' === $data['post_status'] ) {\n        $data['post_name'] = $current->post_name;\n    }\n\n    return $data;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change $wpide_slug_unlock_cap to choose who may still edit slugs."
        },
        {
            "id": "admin-change-post-type-box",
            "version": 1,
            "title": "Change a Post's Type From the Editor",
            "description": "Adds a \"Post type\" box to the editor sidebar for administrators that converts an item into another post type, such as turning a post into a page, keeping its content and URL slug.",
            "category": "admin",
            "tags": [
                "post-types",
                "editor",
                "conversion"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function ( $post_type, $post ) {\n    if ( ! current_user_can( 'manage_options' ) || ! $post instanceof WP_Post ) {\n        return;\n    }\n\n    add_meta_box( 'wpide-change-type', 'Post type', function ( $post ) {\n        $types = get_post_types( array( 'public' => true, 'show_ui' => true ), 'objects' );\n        wp_nonce_field( 'wpide_change_type_' . $post->ID, 'wpide_post_type_nonce' );\n        echo '<label class=\"screen-reader-text\" for=\"wpide-new-post-type\">Post type</label><select name=\"wpide_new_post_type\" id=\"wpide-new-post-type\" style=\"width:100%\">';\n        foreach ( $types as $type ) {\n            if ( 'attachment' === $type->name ) {\n                continue;\n            }\n            printf( '<option value=\"%s\"%s>%s</option>', esc_attr( $type->name ), selected( $post->post_type, $type->name, false ), esc_html( $type->labels->singular_name ) );\n        }\n        echo '</select><p class=\"description\">Changes apply when you save.</p>';\n    }, null, 'side', 'low' );\n}, 10, 2 );\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    if ( ! isset( $_POST['wpide_post_type_nonce'], $_POST['wpide_new_post_type'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_post_type_nonce'] ), 'wpide_change_type_' . $post_id ) ) {\n        return;\n    }\n    if ( ! current_user_can( 'manage_options' ) || wp_is_post_revision( $post_id ) ) {\n        return;\n    }\n\n    $new = sanitize_key( $_POST['wpide_new_post_type'] );\n    if ( $new !== $post->post_type && post_type_exists( $new ) && 'attachment' !== $new ) {\n        set_post_type( $post_id, $new );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Categories and tags that the new type does not support stay in the database but are hidden; check the URL afterwards."
        },
        {
            "id": "admin-hide-custom-fields-box",
            "version": 1,
            "title": "Hide the Custom Fields Box From Non-Administrators",
            "description": "Removes the raw \"Custom Fields\" box from the editor for everyone except administrators, so editors cannot accidentally change values that plugins and the theme depend on.",
            "category": "admin",
            "tags": [
                "custom-fields",
                "editor",
                "permissions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes', function () {\n    if ( current_user_can( 'manage_options' ) ) {\n        return;\n    }\n    foreach ( get_post_types( array( 'show_ui' => true ) ) as $post_type ) {\n        remove_meta_box( 'postcustom', $post_type, 'normal' );\n    }\n}, 99 );",
            "requires": [],
            "notes": "Fields shown in proper boxes by plugins such as ACF are not affected."
        },
        {
            "id": "admin-highlight-my-posts",
            "version": 1,
            "title": "Highlight Your Own Posts in Admin Lists",
            "description": "Gives rows you authored a subtle colored edge in admin post lists, so on busy multi-author sites you can spot your own work at a glance.",
            "category": "admin",
            "tags": [
                "admin-lists",
                "authors",
                "multi-author"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Edge color for your rows.\n$wpide_my_rows_color = '#2271b1';\n\nadd_filter( 'post_class', function ( $classes, $class, $post_id ) {\n    if ( is_admin() && (int) get_post_field( 'post_author', $post_id ) === get_current_user_id() ) {\n        $classes[] = 'wpide-mine';\n    }\n    return $classes;\n}, 10, 3 );\n\nadd_action( 'admin_head-edit.php', function () use ( $wpide_my_rows_color ) {\n    printf( '<style>.wp-list-table tr.wpide-mine th.check-column{box-shadow:inset 4px 0 0 %s}</style>', esc_attr( $wpide_my_rows_color ) );\n} );",
            "requires": [],
            "notes": "Change $wpide_my_rows_color to match your admin color scheme."
        },
        {
            "id": "admin-copy-link-row-action",
            "version": 1,
            "title": "\"Copy Link\" Action in Admin Lists",
            "description": "Adds a \"Copy link\" action under each published post and page in admin lists that copies its public URL to the clipboard in one click, for sharing in chats and emails.",
            "category": "admin",
            "tags": [
                "row-actions",
                "clipboard",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_copy_link_action = function ( $actions, $post ) {\n    if ( 'publish' === $post->post_status && is_post_type_viewable( $post->post_type ) ) {\n        $actions['wpide_copy_link'] = sprintf(\n            '<button type=\"button\" class=\"button-link wpide-copy-link\" data-url=\"%s\">Copy link</button>',\n            esc_attr( get_permalink( $post ) )\n        );\n    }\n    return $actions;\n};\nadd_filter( 'post_row_actions', $wpide_copy_link_action, 10, 2 );\nadd_filter( 'page_row_actions', $wpide_copy_link_action, 10, 2 );\n\nadd_action( 'admin_footer-edit.php', function () {\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var button = event.target.closest( '.wpide-copy-link' );\n        if ( ! button ) { return; }\n        var url = button.dataset.url;\n        var done = function () {\n            var label = button.textContent;\n            button.textContent = 'Copied!';\n            setTimeout( function () { button.textContent = label; }, 1500 );\n        };\n        if ( navigator.clipboard && window.isSecureContext ) {\n            navigator.clipboard.writeText( url ).then( done );\n        } else {\n            window.prompt( 'Copy this link:', url );\n        }\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Uses the browser clipboard; very old browsers fall back to a prompt with the URL selected."
        },
        {
            "id": "admin-custom-help-tab",
            "version": 1,
            "title": "Your Own Help Tab on Editing Screens",
            "description": "Adds a \"Site guide\" tab to the Help panel of the post and page editing screens with your own instructions, such as image sizes, tone of voice or who to contact.",
            "category": "admin",
            "tags": [
                "help",
                "documentation",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Tab title and content (basic HTML allowed).\n$wpide_help_title   = 'Site guide';\n$wpide_help_content = '<p><strong>Images:</strong> upload at least 1600px wide and always fill in the alt text.</p><p><strong>Headings:</strong> start with Heading 2; never skip levels.</p><p><strong>Questions?</strong> Email <a href=\"mailto:help@example.com\">help@example.com</a>.</p>';\n\nadd_action( 'current_screen', function ( $screen ) use ( $wpide_help_title, $wpide_help_content ) {\n    if ( ! in_array( $screen->base, array( 'post', 'edit' ), true ) ) {\n        return;\n    }\n    $screen->add_help_tab( array(\n        'id'       => 'wpide-site-guide',\n        'title'    => $wpide_help_title,\n        'content'  => wp_kses_post( $wpide_help_content ),\n        'priority' => 5,\n    ) );\n} );",
            "requires": [],
            "notes": "The tab content accepts basic HTML; open it with the Help button at the top right of classic admin screens."
        },
        {
            "id": "admin-bar-recently-edited",
            "version": 1,
            "title": "\"Recently Edited\" Menu in the Admin Bar",
            "description": "Adds a menu to the admin bar listing the last eight posts and pages you edited, with their status, so you can jump back into your work from anywhere on the site.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "navigation",
                "productivity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Number of items to list.\n$wpide_recent_count = 8;\n\nadd_action( 'admin_bar_menu', function ( $admin_bar ) use ( $wpide_recent_count ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $posts = get_posts( array(\n        'post_type'      => 'any',\n        'post_status'    => array( 'publish', 'draft', 'pending', 'future', 'private' ),\n        'posts_per_page' => $wpide_recent_count,\n        'orderby'        => 'modified',\n        'meta_key'       => '_edit_last',\n        'meta_value'     => get_current_user_id(),\n        'no_found_rows'  => true,\n    ) );\n\n    if ( ! $posts ) {\n        return;\n    }\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-recent-edits',\n        'title' => '<span class=\"ab-icon dashicons dashicons-backup\" style=\"margin-top:2px\" aria-hidden=\"true\"></span><span class=\"ab-label\">Recent</span>',\n    ) );\n\n    foreach ( $posts as $post ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-recent-' . $post->ID,\n            'parent' => 'wpide-recent-edits',\n            'title'  => esc_html( wp_trim_words( get_the_title( $post ) ? get_the_title( $post ) : '(no title)', 8 ) . ( 'publish' !== $post->post_status ? ' - ' . $post->post_status : '' ) ),\n            'href'   => get_edit_post_link( $post->ID, 'url' ),\n        ) );\n    }\n}, 80 );",
            "requires": [],
            "notes": "Based on WordPress's own \"last edited by\" record; change $wpide_recent_count for a longer list."
        },
        {
            "id": "admin-duplicate-nav-menu",
            "version": 1,
            "title": "Duplicate a Navigation Menu",
            "description": "Adds a \"Duplicate this menu\" button to Appearance > Menus that copies the selected classic menu with all its items and nesting, handy for trying a new structure safely.",
            "category": "admin",
            "tags": [
                "menus",
                "duplicate",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_footer-nav-menus.php', function () {\n    $menu_id = isset( $_REQUEST['menu'] ) ? absint( $_REQUEST['menu'] ) : absint( get_user_option( 'nav_menu_recently_edited' ) );\n    if ( ! $menu_id || ! is_nav_menu( $menu_id ) ) {\n        return;\n    }\n    $url = wp_nonce_url( admin_url( 'admin-post.php?action=wpide_duplicate_menu&menu=' . $menu_id ), 'wpide_duplicate_menu_' . $menu_id );\n    ?>\n    <script>\n    ( function () {\n        var target = document.querySelector( '.major-publishing-actions .publishing-action' );\n        if ( ! target ) { return; }\n        var link = document.createElement( 'a' );\n        link.className = 'button';\n        link.style.marginRight = '6px';\n        link.href = <?php echo wp_json_encode( $url ); ?>;\n        link.textContent = 'Duplicate this menu';\n        target.insertBefore( link, target.firstChild );\n    } )();\n    </script>\n    <?php\n} );\n\nadd_action( 'admin_post_wpide_duplicate_menu', function () {\n    $menu_id = isset( $_GET['menu'] ) ? absint( $_GET['menu'] ) : 0;\n    check_admin_referer( 'wpide_duplicate_menu_' . $menu_id );\n    if ( ! current_user_can( 'edit_theme_options' ) || ! is_nav_menu( $menu_id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    $source = wp_get_nav_menu_object( $menu_id );\n    $new_id = wp_create_nav_menu( $source->name . ' (Copy)' );\n    if ( is_wp_error( $new_id ) ) {\n        wp_die( esc_html( $new_id->get_error_message() ) );\n    }\n\n    $map = array();\n    foreach ( (array) wp_get_nav_menu_items( $menu_id, array( 'post_status' => 'any' ) ) as $item ) {\n        $map[ $item->ID ] = wp_update_nav_menu_item( $new_id, 0, array(\n            'menu-item-object-id'   => $item->object_id,\n            'menu-item-object'      => $item->object,\n            'menu-item-type'        => $item->type,\n            'menu-item-title'       => $item->title,\n            'menu-item-url'         => $item->url,\n            'menu-item-description' => $item->description,\n            'menu-item-attr-title'  => $item->attr_title,\n            'menu-item-target'      => $item->target,\n            'menu-item-classes'     => implode( ' ', (array) $item->classes ),\n            'menu-item-xfn'         => $item->xfn,\n            'menu-item-position'    => $item->menu_order,\n            'menu-item-parent-id'   => $item->menu_item_parent ? ( $map[ $item->menu_item_parent ] ?? 0 ) : 0,\n            'menu-item-status'      => 'publish',\n        ) );\n    }\n\n    wp_safe_redirect( admin_url( 'nav-menus.php?action=edit&menu=' . (int) $new_id ) );\n    exit;\n} );",
            "requires": [],
            "notes": "The copy is not assigned to any theme location until you choose one."
        },
        {
            "id": "admin-needs-review-view",
            "version": 1,
            "title": "\"Needs Review\" View for Outdated Posts",
            "description": "Adds a \"Needs review\" link above the Posts list that shows published posts not updated for a year or more, so editors can keep old content accurate.",
            "category": "admin",
            "tags": [
                "content-audit",
                "admin-lists",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Posts not modified for this many months need a review.\n$wpide_review_months = 12;\n\nadd_filter( 'views_edit-post', function ( $views ) use ( $wpide_review_months ) {\n    $count = get_transient( 'wpide_needs_review_count' );\n    if ( false === $count ) {\n        $query = new WP_Query( array(\n            'post_type'      => 'post',\n            'post_status'    => 'publish',\n            'posts_per_page' => 1,\n            'fields'         => 'ids',\n            'date_query'     => array( array( 'column' => 'post_modified_gmt', 'before' => $wpide_review_months . ' months ago' ) ),\n        ) );\n        $count = (int) $query->found_posts;\n        set_transient( 'wpide_needs_review_count', $count, HOUR_IN_SECONDS );\n    }\n\n    $current = ! empty( $_GET['wpide_review'] );\n    $views['wpide_review'] = sprintf(\n        '<a href=\"%s\"%s>Needs review <span class=\"count\">(%d)</span></a>',\n        esc_url( admin_url( 'edit.php?post_status=publish&wpide_review=1&orderby=modified&order=asc' ) ),\n        $current ? ' class=\"current\" aria-current=\"page\"' : '',\n        (int) $count\n    );\n    return $views;\n} );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_review_months ) {\n    if ( ! is_admin() || ! $query->is_main_query() || empty( $_GET['wpide_review'] ) ) {\n        return;\n    }\n    $query->set( 'date_query', array( array( 'column' => 'post_modified_gmt', 'before' => $wpide_review_months . ' months ago' ) ) );\n} );",
            "requires": [],
            "notes": "Change $wpide_review_months; the count in the link is cached for an hour."
        },
        {
            "id": "dash-private-notes-widget",
            "version": 1,
            "title": "\"My Notes\" Personal Dashboard Widget",
            "description": "Adds a \"My notes\" widget to the dashboard where each user keeps their own private notes and reminders, saved instantly and invisible to everyone else.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "notes",
                "productivity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_private_notes', 'My notes', function () {\n        $note = (string) get_user_meta( get_current_user_id(), 'wpide_private_note', true );\n        ?>\n        <label for=\"wpide-private-note\" class=\"screen-reader-text\">My notes</label>\n        <textarea id=\"wpide-private-note\" rows=\"6\" style=\"width:100%\" maxlength=\"5000\" placeholder=\"Only you can see these notes.\"><?php echo esc_textarea( $note ); ?></textarea>\n        <p class=\"description\" id=\"wpide-note-status\" aria-live=\"polite\"></p>\n        <script>\n        ( function () {\n            var box = document.getElementById( 'wpide-private-note' );\n            var status = document.getElementById( 'wpide-note-status' );\n            var saved = box.value;\n            box.addEventListener( 'blur', function () {\n                if ( box.value === saved ) { return; }\n                var body = new FormData();\n                body.append( 'action', 'wpide_private_note' );\n                body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( 'wpide_private_note' ) ); ?> );\n                body.append( 'note', box.value );\n                status.textContent = 'Saving...';\n                fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } )\n                    .then( function ( r ) { return r.json(); } )\n                    .then( function ( r ) { saved = box.value; status.textContent = r.success ? 'Saved.' : 'Could not save.'; } );\n            } );\n        } )();\n        </script>\n        <?php\n    } );\n} );\n\nadd_action( 'wp_ajax_wpide_private_note', function () {\n    check_ajax_referer( 'wpide_private_note', 'nonce' );\n    $note = isset( $_POST['note'] ) ? sanitize_textarea_field( wp_unslash( $_POST['note'] ) ) : '';\n    update_user_meta( get_current_user_id(), 'wpide_private_note', mb_substr( $note, 0, 5000 ) );\n    wp_send_json_success();\n} );",
            "requires": [],
            "notes": "Notes are stored per user, up to 5,000 characters, and saved when you click away from the box."
        },
        {
            "id": "dash-activity-timeline-widget",
            "version": 1,
            "title": "Site Activity Timeline Widget",
            "description": "Adds a dashboard widget with one timeline of the latest published posts, approved comments and new user sign-ups, newest first, so you see what happened on the site at a glance.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "activity",
                "timeline"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Number of events to show.\n$wpide_timeline_count = 12;\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_timeline_count ) {\n    wp_add_dashboard_widget( 'wpide_activity_timeline', 'Activity timeline', function () use ( $wpide_timeline_count ) {\n        $events = array();\n\n        foreach ( get_posts( array( 'post_type' => array( 'post', 'page' ), 'posts_per_page' => $wpide_timeline_count, 'no_found_rows' => true ) ) as $post ) {\n            $events[] = array( get_post_time( 'U', true, $post ), 'dashicons-edit', sprintf( 'Published <a href=\"%s\">%s</a>', esc_url( get_edit_post_link( $post ) ), esc_html( get_the_title( $post ) ) ) );\n        }\n        foreach ( get_comments( array( 'status' => 'approve', 'number' => $wpide_timeline_count, 'type' => 'comment' ) ) as $comment ) {\n            $events[] = array( strtotime( $comment->comment_date_gmt . ' UTC' ), 'dashicons-admin-comments', sprintf( '%s commented on <a href=\"%s\">%s</a>', esc_html( $comment->comment_author ), esc_url( get_comment_link( $comment ) ), esc_html( get_the_title( $comment->comment_post_ID ) ) ) );\n        }\n        if ( current_user_can( 'list_users' ) ) {\n            foreach ( get_users( array( 'number' => $wpide_timeline_count, 'orderby' => 'registered', 'order' => 'DESC' ) ) as $user ) {\n                $events[] = array( strtotime( $user->user_registered . ' UTC' ), 'dashicons-admin-users', sprintf( '<a href=\"%s\">%s</a> signed up', esc_url( get_edit_user_link( $user->ID ) ), esc_html( $user->display_name ) ) );\n            }\n        }\n\n        usort( $events, function ( $a, $b ) {\n            return $b[0] <=> $a[0];\n        } );\n\n        if ( ! $events ) {\n            echo '<p>No activity yet.</p>';\n            return;\n        }\n\n        echo '<ul style=\"margin:0\">';\n        foreach ( array_slice( $events, 0, $wpide_timeline_count ) as $event ) {\n            printf(\n                '<li style=\"display:flex;gap:8px;align-items:flex-start;padding:6px 0;border-bottom:1px solid #f0f0f1\"><span class=\"dashicons %s\" style=\"color:#787c82\" aria-hidden=\"true\"></span><span style=\"flex:1\">%s<br><small style=\"color:#646970\">%s ago</small></span></li>',\n                esc_attr( $event[1] ),\n                $event[2],\n                esc_html( human_time_diff( $event[0] ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "Shows the last 12 events; change $wpide_timeline_count. New users only appear for people who can list users."
        },
        {
            "id": "dash-pending-updates-widget",
            "version": 1,
            "title": "Pending Updates Summary Widget",
            "description": "Adds a dashboard widget showing how many WordPress, plugin, theme and translation updates are waiting, with the names of the plugins and a link to install them.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "updates",
                "maintenance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'update_plugins' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_pending_updates', 'Pending updates', function () {\n        $plugins      = get_site_transient( 'update_plugins' );\n        $themes       = get_site_transient( 'update_themes' );\n        $core         = get_site_transient( 'update_core' );\n        $plugin_list  = ! empty( $plugins->response ) ? $plugins->response : array();\n        $theme_count  = ! empty( $themes->response ) ? count( $themes->response ) : 0;\n        $core_update  = ! empty( $core->updates[0] ) && 'upgrade' === $core->updates[0]->response;\n        $translations = count( wp_get_translation_updates() );\n\n        if ( ! $plugin_list && ! $theme_count && ! $core_update && ! $translations ) {\n            echo '<p><span class=\"dashicons dashicons-yes-alt\" style=\"color:#00a32a\" aria-hidden=\"true\"></span> Everything is up to date.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        if ( $core_update ) {\n            printf( '<li><strong>WordPress %s</strong> is available.</li>', esc_html( $core->updates[0]->version ) );\n        }\n        if ( $plugin_list ) {\n            if ( ! function_exists( 'get_plugins' ) ) {\n                require_once ABSPATH . 'wp-admin/includes/plugin.php';\n            }\n            $all   = get_plugins();\n            $names = array();\n            foreach ( $plugin_list as $file => $update ) {\n                $names[] = ( $all[ $file ]['Name'] ?? $file ) . ' ' . $update->new_version;\n            }\n            printf( '<li><strong>%d plugin updates:</strong> %s</li>', count( $plugin_list ), esc_html( implode( ', ', $names ) ) );\n        }\n        if ( $theme_count ) {\n            printf( '<li><strong>%d theme updates</strong></li>', (int) $theme_count );\n        }\n        if ( $translations ) {\n            printf( '<li><strong>%d translation updates</strong></li>', (int) $translations );\n        }\n        echo '</ul>';\n        printf( '<p><a class=\"button button-primary\" href=\"%s\">Go to updates</a></p>', esc_url( admin_url( 'update-core.php' ) ) );\n    } );\n} );",
            "requires": [],
            "notes": "Uses the update data WordPress already checks twice a day, so it adds no remote requests."
        },
        {
            "id": "dash-media-overview-widget",
            "version": 1,
            "title": "Media Library Overview Widget",
            "description": "Adds a dashboard widget breaking the Media Library down into images, videos, audio and documents, with uploads this month and the number of files not attached to any content.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "media",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'upload_files' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_media_overview', 'Media Library overview', function () {\n        global $wpdb;\n\n        $stats = get_transient( 'wpide_media_overview' );\n        if ( false === $stats ) {\n            $types = $wpdb->get_results(\n                \"SELECT SUBSTRING_INDEX(post_mime_type, '/', 1) AS kind, COUNT(*) AS total FROM {$wpdb->posts} WHERE post_type = 'attachment' GROUP BY kind\",\n                OBJECT_K\n            );\n            $stats = array(\n                'image'      => (int) ( $types['image']->total ?? 0 ),\n                'video'      => (int) ( $types['video']->total ?? 0 ),\n                'audio'      => (int) ( $types['audio']->total ?? 0 ),\n                'other'      => (int) array_sum( array_map( function ( $row ) {\n                    return in_array( $row->kind, array( 'image', 'video', 'audio' ), true ) ? 0 : (int) $row->total;\n                }, $types ) ),\n                'month'      => (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_date >= %s\", wp_date( 'Y-m-01 00:00:00' ) ) ),\n                'unattached' => (int) $wpdb->get_var( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'attachment' AND post_parent = 0\" ),\n            );\n            set_transient( 'wpide_media_overview', $stats, HOUR_IN_SECONDS );\n        }\n\n        $tiles = array(\n            'Images'    => array( $stats['image'], 'upload.php?post_mime_type=image' ),\n            'Videos'    => array( $stats['video'], 'upload.php?post_mime_type=video' ),\n            'Audio'     => array( $stats['audio'], 'upload.php?post_mime_type=audio' ),\n            'Documents' => array( $stats['other'], 'upload.php?mode=list' ),\n        );\n\n        echo '<div style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(90px,1fr));gap:8px;margin-bottom:12px\">';\n        foreach ( $tiles as $label => $tile ) {\n            printf(\n                '<a href=\"%s\" style=\"display:block;border:1px solid #dcdcde;border-radius:4px;padding:10px;text-align:center;text-decoration:none\"><span style=\"display:block;font-size:22px;font-weight:600;color:#1d2327\">%s</span>%s</a>',\n                esc_url( admin_url( $tile[1] ) ),\n                esc_html( number_format_i18n( $tile[0] ) ),\n                esc_html( $label )\n            );\n        }\n        echo '</div>';\n        printf(\n            '<p>%s uploaded this month &middot; <a href=\"%s\">%s not attached to any content</a></p>',\n            esc_html( number_format_i18n( $stats['month'] ) ),\n            esc_url( admin_url( 'upload.php?mode=list&attachment-filter=detached' ) ),\n            esc_html( number_format_i18n( $stats['unattached'] ) )\n        );\n    } );\n} );\n\nadd_action( 'add_attachment', function () {\n    delete_transient( 'wpide_media_overview' );\n} );",
            "requires": [],
            "notes": "Counts are cached for an hour; the \"unattached\" link opens the matching Media Library view."
        },
        {
            "id": "dash-posts-per-month-chart",
            "version": 1,
            "title": "Posts per Month Chart Widget",
            "description": "Adds a dashboard widget with a simple bar chart of how many posts were published in each of the last 12 months, to keep an eye on your publishing rhythm.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "chart",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_posts_per_month', 'Posts per month', function () {\n        global $wpdb;\n\n        $rows = get_transient( 'wpide_posts_per_month' );\n        if ( false === $rows ) {\n            $rows = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT DATE_FORMAT(post_date, '%%Y-%%m') AS ym, COUNT(*) AS total FROM {$wpdb->posts}\n                WHERE post_type = 'post' AND post_status = 'publish' AND post_date >= %s GROUP BY ym\",\n                wp_date( 'Y-m-01 00:00:00', strtotime( '-11 months', current_time( 'timestamp' ) ) )\n            ), OBJECT_K );\n            set_transient( 'wpide_posts_per_month', $rows, 6 * HOUR_IN_SECONDS );\n        }\n\n        $months = array();\n        for ( $i = 11; $i >= 0; $i-- ) {\n            $stamp = strtotime( \"-$i months\", strtotime( wp_date( 'Y-m-01' ) ) );\n            $key   = gmdate( 'Y-m', $stamp );\n            $months[ $key ] = array( wp_date( 'M', $stamp ), isset( $rows[ $key ] ) ? (int) $rows[ $key ]->total : 0 );\n        }\n        $max = max( 1, max( array_column( $months, 1 ) ) );\n\n        echo '<div style=\"display:flex;align-items:flex-end;gap:4px;height:140px;padding-top:16px\" role=\"img\" aria-label=\"Posts published per month\">';\n        foreach ( $months as $month ) {\n            printf(\n                '<div style=\"flex:1;text-align:center;font-size:11px;color:#646970\"><div style=\"font-weight:600;color:#1d2327\">%2$d</div><div style=\"height:%1$dpx;background:#2271b1;border-radius:3px 3px 0 0;margin:2px 0\"></div>%3$s</div>',\n                (int) round( $month[1] / $max * 100 ),\n                (int) $month[1],\n                esc_html( $month[0] )\n            );\n        }\n        echo '</div>';\n    } );\n} );\n\nadd_action( 'publish_post', function () {\n    delete_transient( 'wpide_posts_per_month' );\n} );",
            "requires": [],
            "notes": "Pure HTML and CSS, no chart library; counts are cached for six hours and refreshed when a post is published."
        },
        {
            "id": "dash-author-leaderboard",
            "version": 1,
            "title": "Author Leaderboard Widget",
            "description": "Adds a dashboard widget ranking authors by the number of posts they published this month, with last month's figure for comparison, to celebrate your most active writers.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "authors",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_author_leaderboard', 'Author leaderboard', function () {\n        global $wpdb;\n\n        $data = get_transient( 'wpide_author_leaderboard' );\n        if ( false === $data ) {\n            $this_month = wp_date( 'Y-m-01 00:00:00' );\n            $last_month = wp_date( 'Y-m-01 00:00:00', strtotime( '-1 month', strtotime( $this_month ) ) );\n            $data       = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT post_author, SUM( post_date >= %s ) AS current_month, SUM( post_date < %s ) AS previous_month\n                FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date >= %s\n                GROUP BY post_author ORDER BY current_month DESC, previous_month DESC LIMIT 10\",\n                $this_month,\n                $this_month,\n                $last_month\n            ) );\n            set_transient( 'wpide_author_leaderboard', $data, HOUR_IN_SECONDS );\n        }\n\n        if ( ! $data ) {\n            echo '<p>No posts published since last month.</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>Author</th><th>This month</th><th>Last month</th></tr></thead><tbody>';\n        foreach ( $data as $row ) {\n            printf(\n                '<tr><td>%s %s</td><td><strong>%d</strong></td><td>%d</td></tr>',\n                get_avatar( $row->post_author, 20, '', '', array( 'extra_attr' => 'style=\"vertical-align:middle;border-radius:50%\"' ) ),\n                esc_html( get_the_author_meta( 'display_name', $row->post_author ) ),\n                (int) $row->current_month,\n                (int) $row->previous_month\n            );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Counts published posts only and are cached for an hour."
        },
        {
            "id": "dash-quick-draft-any-type",
            "version": 1,
            "title": "Quick Draft for Pages and Custom Post Types",
            "description": "Adds a dashboard widget like Quick Draft that lets you start a draft of any post type, such as a page, product or event, with a title and a few notes.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "drafts",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_quick_draft_any', 'Quick draft (any type)', function () {\n        $types = array_filter( get_post_types( array( 'show_ui' => true, 'public' => true ), 'objects' ), function ( $type ) {\n            return 'attachment' !== $type->name && current_user_can( $type->cap->edit_posts );\n        } );\n\n        if ( ! empty( $_GET['wpide_qd'] ) ) {\n            $id = absint( $_GET['wpide_qd'] );\n            printf( '<div class=\"notice notice-success inline\"><p>Draft saved. <a href=\"%s\">Continue editing</a></p></div>', esc_url( get_edit_post_link( $id ) ) );\n        }\n        ?>\n        <form method=\"post\" action=\"<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>\">\n            <input type=\"hidden\" name=\"action\" value=\"wpide_quick_draft\">\n            <?php wp_nonce_field( 'wpide_quick_draft', 'wpide_qd_nonce' ); ?>\n            <p><label for=\"wpide-qd-type\">Type</label><br>\n                <select id=\"wpide-qd-type\" name=\"wpide_qd_type\">\n                    <?php foreach ( $types as $type ) : ?>\n                        <option value=\"<?php echo esc_attr( $type->name ); ?>\"><?php echo esc_html( $type->labels->singular_name ); ?></option>\n                    <?php endforeach; ?>\n                </select></p>\n            <p><label for=\"wpide-qd-title\">Title</label><br><input id=\"wpide-qd-title\" name=\"wpide_qd_title\" type=\"text\" class=\"widefat\" required></p>\n            <p><label for=\"wpide-qd-content\">Notes</label><br><textarea id=\"wpide-qd-content\" name=\"wpide_qd_content\" rows=\"3\" class=\"widefat\"></textarea></p>\n            <p><button class=\"button button-primary\">Save draft</button></p>\n        </form>\n        <?php\n    } );\n} );\n\nadd_action( 'admin_post_wpide_quick_draft', function () {\n    check_admin_referer( 'wpide_quick_draft', 'wpide_qd_nonce' );\n\n    $type   = isset( $_POST['wpide_qd_type'] ) ? sanitize_key( $_POST['wpide_qd_type'] ) : 'post';\n    $object = get_post_type_object( $type );\n    if ( ! $object || ! current_user_can( $object->cap->edit_posts ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    $id = wp_insert_post( array(\n        'post_type'    => $type,\n        'post_status'  => 'draft',\n        'post_title'   => isset( $_POST['wpide_qd_title'] ) ? sanitize_text_field( wp_unslash( $_POST['wpide_qd_title'] ) ) : '',\n        'post_content' => isset( $_POST['wpide_qd_content'] ) ? wp_kses_post( wp_unslash( $_POST['wpide_qd_content'] ) ) : '',\n        'post_author'  => get_current_user_id(),\n    ) );\n\n    wp_safe_redirect( add_query_arg( 'wpide_qd', (int) $id, admin_url( 'index.php' ) ) );\n    exit;\n} );",
            "requires": [],
            "notes": "After saving you get a link to continue editing the new draft."
        },
        {
            "id": "dash-last-7-days-widget",
            "version": 1,
            "title": "Last 7 Days Compared Widget",
            "description": "Adds a dashboard widget comparing new posts, comments and user registrations from the last seven days with the seven days before, with up and down arrows.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "statistics",
                "trends"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_last_7_days', 'Last 7 days', function () {\n        global $wpdb;\n\n        $stats = get_transient( 'wpide_last_7_days' );\n        if ( false === $stats ) {\n            $now   = time();\n            $week  = gmdate( 'Y-m-d H:i:s', $now - WEEK_IN_SECONDS );\n            $two   = gmdate( 'Y-m-d H:i:s', $now - 2 * WEEK_IN_SECONDS );\n            $pair  = function ( $sql ) use ( $wpdb, $week, $two ) {\n                return array_map( 'intval', (array) $wpdb->get_row( $wpdb->prepare( $sql, $week, $week, $two ), ARRAY_N ) );\n            };\n            $stats = array(\n                'Posts'    => $pair( \"SELECT SUM( post_date_gmt >= %s ), SUM( post_date_gmt < %s ) FROM {$wpdb->posts} WHERE post_type = 'post' AND post_status = 'publish' AND post_date_gmt >= %s\" ),\n                'Comments' => $pair( \"SELECT SUM( comment_date_gmt >= %s ), SUM( comment_date_gmt < %s ) FROM {$wpdb->comments} WHERE comment_approved = '1' AND comment_date_gmt >= %s\" ),\n                'Users'    => $pair( \"SELECT SUM( user_registered >= %s ), SUM( user_registered < %s ) FROM {$wpdb->users} WHERE user_registered >= %s\" ),\n            );\n            set_transient( 'wpide_last_7_days', $stats, HOUR_IN_SECONDS );\n        }\n\n        echo '<div style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:12px\">';\n        foreach ( $stats as $label => $values ) {\n            list( $current, $previous ) = array_pad( $values, 2, 0 );\n            $diff  = $current - $previous;\n            $arrow = $diff > 0 ? '&#9650;' : ( $diff < 0 ? '&#9660;' : '&#8212;' );\n            $color = $diff > 0 ? '#00a32a' : ( $diff < 0 ? '#d63638' : '#646970' );\n            printf(\n                '<div style=\"border:1px solid #dcdcde;border-radius:4px;padding:10px;text-align:center\"><div style=\"font-size:24px;font-weight:600\">%d</div><div>%s</div><div style=\"color:%s;font-size:12px\"><span aria-hidden=\"true\">%s</span> %s vs previous week (%d)</div></div>',\n                (int) $current,\n                esc_html( $label ),\n                esc_attr( $color ),\n                $arrow,\n                esc_html( ( $diff > 0 ? '+' : '' ) . $diff ),\n                (int) $previous\n            );\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Figures are cached for an hour."
        },
        {
            "id": "dash-shared-todo-widget",
            "version": 1,
            "title": "Shared To-Do List Widget",
            "description": "Adds a team to-do list to the dashboard where editors can add tasks, tick them off and clear finished ones, shared by everyone who can edit content.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "tasks",
                "team"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_todo', 'Team to-do list', function () {\n        $items = (array) get_option( 'wpide_todo_items', array() );\n        ?>\n        <ul id=\"wpide-todo-list\" style=\"margin-top:0\">\n            <?php foreach ( $items as $i => $item ) : ?>\n                <li><label><input type=\"checkbox\" data-index=\"<?php echo (int) $i; ?>\" <?php checked( ! empty( $item['done'] ) ); ?>>\n                    <span style=\"<?php echo ! empty( $item['done'] ) ? 'text-decoration:line-through;color:#787c82' : ''; ?>\"><?php echo esc_html( $item['text'] ); ?></span></label></li>\n            <?php endforeach; ?>\n            <?php if ( ! $items ) : ?><li><em>Nothing to do.</em></li><?php endif; ?>\n        </ul>\n        <form id=\"wpide-todo-form\" style=\"display:flex;gap:6px\">\n            <label for=\"wpide-todo-text\" class=\"screen-reader-text\">New task</label>\n            <input id=\"wpide-todo-text\" type=\"text\" class=\"widefat\" maxlength=\"200\" placeholder=\"New task\" required>\n            <button class=\"button\">Add</button>\n            <button type=\"button\" class=\"button-link\" id=\"wpide-todo-clear\">Clear done</button>\n        </form>\n        <script>\n        ( function () {\n            var nonce = <?php echo wp_json_encode( wp_create_nonce( 'wpide_todo' ) ); ?>;\n            var send = function ( data ) {\n                var body = new FormData();\n                body.append( 'action', 'wpide_todo' );\n                body.append( 'nonce', nonce );\n                Object.keys( data ).forEach( function ( key ) { body.append( key, data[ key ] ); } );\n                return fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } ).then( function () { window.location.reload(); } );\n            };\n            document.getElementById( 'wpide-todo-form' ).addEventListener( 'submit', function ( event ) {\n                event.preventDefault();\n                send( { op: 'add', text: document.getElementById( 'wpide-todo-text' ).value } );\n            } );\n            document.getElementById( 'wpide-todo-list' ).addEventListener( 'change', function ( event ) {\n                if ( event.target.dataset.index !== undefined ) { send( { op: 'toggle', index: event.target.dataset.index } ); }\n            } );\n            document.getElementById( 'wpide-todo-clear' ).addEventListener( 'click', function () { send( { op: 'clear' } ); } );\n        } )();\n        </script>\n        <?php\n    } );\n} );\n\nadd_action( 'wp_ajax_wpide_todo', function () {\n    check_ajax_referer( 'wpide_todo', 'nonce' );\n    if ( ! current_user_can( 'edit_others_posts' ) ) {\n        wp_send_json_error( null, 403 );\n    }\n\n    $items = (array) get_option( 'wpide_todo_items', array() );\n    $op    = isset( $_POST['op'] ) ? sanitize_key( $_POST['op'] ) : '';\n\n    if ( 'add' === $op && ! empty( $_POST['text'] ) ) {\n        $items[] = array( 'text' => mb_substr( sanitize_text_field( wp_unslash( $_POST['text'] ) ), 0, 200 ), 'done' => false );\n    } elseif ( 'toggle' === $op && isset( $_POST['index'], $items[ absint( $_POST['index'] ) ] ) ) {\n        $items[ absint( $_POST['index'] ) ]['done'] = empty( $items[ absint( $_POST['index'] ) ]['done'] );\n    } elseif ( 'clear' === $op ) {\n        $items = array_filter( $items, function ( $item ) {\n            return empty( $item['done'] );\n        } );\n    }\n\n    update_option( 'wpide_todo_items', array_values( $items ), false );\n    wp_send_json_success();\n} );",
            "requires": [],
            "notes": "Tasks are stored in one option; finished tasks stay listed until someone clicks \"Clear done\"."
        },
        {
            "id": "dash-default-layout-new-users",
            "version": 1,
            "title": "Default Dashboard Layout for New Users",
            "description": "Gives every user who has never arranged their dashboard the same widget order and columns you choose, so clients see the most useful widgets first.",
            "category": "dashboard",
            "tags": [
                "dashboard",
                "layout",
                "onboarding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Column => comma-separated widget IDs, in order.\n$wpide_dashboard_layout = array(\n    'normal'  => 'dashboard_site_health,dashboard_right_now,dashboard_activity',\n    'side'    => 'dashboard_quick_press,dashboard_primary',\n    'column3' => '',\n    'column4' => '',\n);\n\nadd_filter( 'get_user_option_meta-box-order_dashboard', function ( $order ) use ( $wpide_dashboard_layout ) {\n    return empty( $order ) ? $wpide_dashboard_layout : $order;\n} );",
            "requires": [],
            "notes": "List widget IDs per column; users who later drag widgets around keep their own layout."
        },
        {
            "id": "dash-recent-uploads-widget",
            "version": 1,
            "title": "Recent Uploads Dashboard Widget",
            "description": "Adds a dashboard widget with thumbnails of the 12 most recently uploaded images and files, each linking to its edit screen, so you can review new media quickly.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "media",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'upload_files' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_recent_uploads', 'Recent uploads', function () {\n        $files = get_posts( array(\n            'post_type'      => 'attachment',\n            'post_status'    => 'inherit',\n            'posts_per_page' => 12,\n            'no_found_rows'  => true,\n        ) );\n\n        if ( ! $files ) {\n            echo '<p>No uploads yet.</p>';\n            return;\n        }\n\n        echo '<div style=\"display:grid;grid-template-columns:repeat(auto-fill,minmax(70px,1fr));gap:6px\">';\n        foreach ( $files as $file ) {\n            printf(\n                '<a href=\"%s\" title=\"%s\" style=\"display:block;aspect-ratio:1;overflow:hidden;border-radius:3px;background:#f0f0f1\">%s</a>',\n                esc_url( get_edit_post_link( $file->ID ) ),\n                esc_attr( get_the_title( $file ) ),\n                wp_get_attachment_image( $file->ID, 'thumbnail', true, array( 'style' => 'width:100%;height:100%;object-fit:cover', 'alt' => get_the_title( $file ) ) )\n            );\n        }\n        echo '</div>';\n        printf( '<p><a href=\"%s\">Open the Media Library</a></p>', esc_url( admin_url( 'upload.php' ) ) );\n    } );\n} );",
            "requires": [],
            "notes": "Non-image files show their file type icon."
        },
        {
            "id": "dash-missing-featured-images-widget",
            "version": 1,
            "title": "Posts Missing a Featured Image Widget",
            "description": "Adds a dashboard widget listing the latest published posts that have no featured image, with edit links, so social shares and archive pages never show blank spaces.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "featured-image",
                "content-audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post type to check.\n$wpide_thumb_check_type = 'post';\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_thumb_check_type ) {\n    wp_add_dashboard_widget( 'wpide_missing_thumbs', 'Missing featured images', function () use ( $wpide_thumb_check_type ) {\n        $query = new WP_Query( array(\n            'post_type'      => $wpide_thumb_check_type,\n            'post_status'    => 'publish',\n            'posts_per_page' => 10,\n            'meta_query'     => array( array( 'key' => '_thumbnail_id', 'compare' => 'NOT EXISTS' ) ),\n        ) );\n\n        if ( ! $query->have_posts() ) {\n            echo '<p><span class=\"dashicons dashicons-yes-alt\" style=\"color:#00a32a\" aria-hidden=\"true\"></span> Every published post has a featured image.</p>';\n            return;\n        }\n\n        printf( '<p>%d posts without a featured image. Latest:</p><ul>', (int) $query->found_posts );\n        foreach ( $query->posts as $post ) {\n            printf( '<li><a href=\"%s\">%s</a> <small>%s</small></li>', esc_url( get_edit_post_link( $post ) ), esc_html( get_the_title( $post ) ), esc_html( get_the_date( '', $post ) ) );\n        }\n        echo '</ul>';\n        printf( '<p><a href=\"%s\">See all</a></p>', esc_url( admin_url( 'edit.php?post_type=' . $wpide_thumb_check_type . '&wpide_thumb=without' ) ) );\n    } );\n} );",
            "requires": [],
            "notes": "Shows up to ten posts; change the post type in $wpide_thumb_check_type."
        },
        {
            "id": "dash-forgotten-drafts-widget",
            "version": 1,
            "title": "Forgotten Drafts Widget",
            "description": "Adds a dashboard widget listing drafts nobody has touched for over 30 days, oldest first, with their author, so half-finished ideas get finished or cleaned up.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "drafts",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Days without changes before a draft counts as forgotten.\n$wpide_forgotten_days = 30;\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_forgotten_days ) {\n    wp_add_dashboard_widget( 'wpide_forgotten_drafts', 'Forgotten drafts', function () use ( $wpide_forgotten_days ) {\n        $args = array(\n            'post_type'      => array( 'post', 'page' ),\n            'post_status'    => 'draft',\n            'posts_per_page' => 10,\n            'orderby'        => 'modified',\n            'order'          => 'ASC',\n            'no_found_rows'  => true,\n            'date_query'     => array( array( 'column' => 'post_modified_gmt', 'before' => $wpide_forgotten_days . ' days ago' ) ),\n        );\n        if ( ! current_user_can( 'edit_others_posts' ) ) {\n            $args['author'] = get_current_user_id();\n        }\n        $drafts = get_posts( $args );\n\n        if ( ! $drafts ) {\n            echo '<p>No forgotten drafts.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $drafts as $draft ) {\n            printf(\n                '<li><a href=\"%s\">%s</a><br><small>%s &middot; last edited %s ago</small></li>',\n                esc_url( get_edit_post_link( $draft ) ),\n                esc_html( get_the_title( $draft ) ? get_the_title( $draft ) : '(no title)' ),\n                esc_html( get_the_author_meta( 'display_name', $draft->post_author ) ),\n                esc_html( human_time_diff( get_post_modified_time( 'U', true, $draft ) ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "Editors see everyone's drafts; authors only see their own."
        },
        {
            "id": "dash-unanswered-comments-widget",
            "version": 1,
            "title": "Unanswered Comments Widget",
            "description": "Adds a dashboard widget listing recent approved comments from readers that nobody on your team has replied to yet, so questions do not go unanswered.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "comments",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_unanswered_comments', 'Unanswered comments', function () {\n        global $wpdb;\n\n        $staff = get_users( array( 'capability' => 'edit_posts', 'fields' => 'ID' ) );\n        $staff = $staff ? implode( ',', array_map( 'absint', $staff ) ) : '0';\n\n        $comments = $wpdb->get_results( $wpdb->prepare(\n            \"SELECT c.comment_ID, c.comment_post_ID, c.comment_author, c.comment_content, c.comment_date_gmt FROM {$wpdb->comments} c\n            WHERE c.comment_approved = '1' AND c.comment_type IN ('comment','') AND c.user_id NOT IN ($staff) AND c.comment_date_gmt >= %s\n            AND NOT EXISTS ( SELECT 1 FROM {$wpdb->comments} r WHERE r.comment_parent = c.comment_ID AND r.user_id IN ($staff) )\n            ORDER BY c.comment_date_gmt DESC LIMIT 8\",\n            gmdate( 'Y-m-d H:i:s', time() - 60 * DAY_IN_SECONDS )\n        ) );\n\n        if ( ! $comments ) {\n            echo '<p>Every recent comment has a reply.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( $comments as $comment ) {\n            printf(\n                '<li style=\"margin-bottom:10px\"><strong>%s</strong> on <a href=\"%s\">%s</a><br>%s<br><a href=\"%s\">Reply</a> <small>&middot; %s ago</small></li>',\n                esc_html( $comment->comment_author ),\n                esc_url( get_permalink( $comment->comment_post_ID ) ),\n                esc_html( get_the_title( $comment->comment_post_ID ) ),\n                esc_html( wp_trim_words( $comment->comment_content, 18 ) ),\n                esc_url( admin_url( 'edit-comments.php?p=' . (int) $comment->comment_post_ID . '#comment-' . (int) $comment->comment_ID ) ),\n                esc_html( human_time_diff( strtotime( $comment->comment_date_gmt . ' UTC' ) ) )\n            );\n        }\n        echo '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "A reply from any user who can edit posts counts as answered; looks at the last 60 days."
        },
        {
            "id": "dash-team-time-zones-widget",
            "version": 1,
            "title": "Team Time Zones Widget",
            "description": "Adds a dashboard widget showing the current local time for each office or teammate you list, next to the site's own time, so scheduling across time zones is easier.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "time-zones",
                "team"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Label => PHP time zone name.\n$wpide_team_zones = array(\n    'London'   => 'Europe/London',\n    'New York' => 'America/New_York',\n    'Sydney'   => 'Australia/Sydney',\n);\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_team_zones ) {\n    wp_add_dashboard_widget( 'wpide_team_zones', 'Team time zones', function () use ( $wpide_team_zones ) {\n        $format = get_option( 'time_format' );\n        $zones  = array( 'Site time' => wp_timezone() );\n        foreach ( $wpide_team_zones as $label => $zone ) {\n            try {\n                $zones[ $label ] = new DateTimeZone( $zone );\n            } catch ( Exception $e ) {\n                continue;\n            }\n        }\n\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $zones as $label => $zone ) {\n            $now = new DateTime( 'now', $zone );\n            printf(\n                '<tr><td>%s</td><td style=\"text-align:right\"><strong>%s</strong> <small style=\"color:#646970\">%s</small></td></tr>',\n                esc_html( $label ),\n                esc_html( wp_date( $format, $now->getTimestamp(), $zone ) ),\n                esc_html( wp_date( 'D', $now->getTimestamp(), $zone ) )\n            );\n        }\n        echo '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Use PHP time zone names such as Europe/London or America/New_York in $wpide_team_zones."
        },
        {
            "id": "content-prepublish-checklist",
            "version": 1,
            "title": "Pre-Publish Checklist in the Block Editor",
            "description": "Adds a checklist to the block editor's publish panel, such as \"Spell-checked\" or \"Links tested\", and keeps the Publish button locked until every item is ticked.",
            "category": "content",
            "tags": [
                "editorial",
                "checklist",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Checklist items shown before publishing.\n$wpide_checklist_items = array(\n    'Spelling and grammar checked',\n    'Links tested',\n    'Featured image and alt text set',\n    'Category and tags chosen',\n);\n// Post types that get the checklist.\n$wpide_checklist_types = array( 'post' );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_checklist_items, $wpide_checklist_types ) {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || ! in_array( $screen->post_type, $wpide_checklist_types, true ) ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-prepublish-checklist', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-prepublish-checklist' );\n    wp_add_inline_script( 'wpide-prepublish-checklist', 'var wpideChecklistItems = ' . wp_json_encode( array_values( $wpide_checklist_items ) ) . ';' . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPrePublishPanel ) || ( wp.editPost && wp.editPost.PluginPrePublishPanel );\n    if ( ! Panel ) { return; }\n\n    function Checklist() {\n        var state = wp.element.useState( {} );\n        var checked = state[0], setChecked = state[1];\n        var done = wpideChecklistItems.every( function ( item, i ) { return checked[ i ]; } );\n        var isPublished = wp.data.useSelect( function ( select ) { return select( 'core/editor' ).isCurrentPostPublished(); }, [] );\n\n        wp.element.useEffect( function () {\n            var editor = wp.data.dispatch( 'core/editor' );\n            if ( done || isPublished ) { editor.unlockPostSaving( 'wpide-checklist' ); } else { editor.lockPostSaving( 'wpide-checklist' ); }\n            return function () { editor.unlockPostSaving( 'wpide-checklist' ); };\n        }, [ done, isPublished ] );\n\n        return el( Panel, { title: 'Checklist', initialOpen: true },\n            wpideChecklistItems.map( function ( item, i ) {\n                return el( wp.components.CheckboxControl, {\n                    key: i,\n                    label: item,\n                    checked: !! checked[ i ],\n                    onChange: function ( value ) { var next = Object.assign( {}, checked ); next[ i ] = value; setChecked( next ); }\n                } );\n            } ),\n            done ? null : el( 'p', { style: { color: '#b32d2e' } }, 'Tick every item to enable publishing.' )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-prepublish-checklist', { render: Checklist } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Edit the items in $wpide_checklist_items; ticks are not saved, so the list resets each time the post is published."
        },
        {
            "id": "content-require-real-category",
            "version": 1,
            "title": "Require a Real Category Before Publishing",
            "description": "Stops posts from being published while they are only in \"Uncategorized\" or have no category, and shows the writer a clear message in the block or classic editor.",
            "category": "content",
            "tags": [
                "editorial",
                "categories",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_has_real_category = function ( $category_ids ) {\n    $category_ids = array_filter( array_map( 'absint', (array) $category_ids ) );\n    return (bool) array_diff( $category_ids, array( (int) get_option( 'default_category' ) ) );\n};\n\n// Block editor and REST API.\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) use ( $wpide_has_real_category ) {\n    $status = $request->get_param( 'status' ) ? $request->get_param( 'status' ) : get_post_status( $prepared->ID ?? 0 );\n    if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n        return $prepared;\n    }\n\n    $categories = null !== $request->get_param( 'categories' ) ? $request->get_param( 'categories' ) : wp_get_post_categories( (int) ( $prepared->ID ?? 0 ) );\n\n    if ( ! $wpide_has_real_category( $categories ) ) {\n        return new WP_Error( 'wpide_category_required', 'Please choose a category other than \"Uncategorized\" before publishing.', array( 'status' => 400 ) );\n    }\n\n    return $prepared;\n}, 10, 2 );\n\n// Classic editor.\nadd_filter( 'wp_insert_post_data', function ( $data, $postarr ) use ( $wpide_has_real_category ) {\n    if ( 'post' !== $data['post_type'] || ! in_array( $data['post_status'], array( 'publish', 'future' ), true ) || ! isset( $_POST['post_category'] ) || wp_is_serving_rest_request() ) {\n        return $data;\n    }\n\n    if ( ! $wpide_has_real_category( wp_unslash( $_POST['post_category'] ) ) ) {\n        $data['post_status'] = 'draft';\n        set_transient( 'wpide_category_notice_' . get_current_user_id(), 1, 60 );\n    }\n\n    return $data;\n}, 10, 2 );\n\nadd_action( 'admin_notices', function () {\n    if ( get_transient( 'wpide_category_notice_' . get_current_user_id() ) ) {\n        delete_transient( 'wpide_category_notice_' . get_current_user_id() );\n        echo '<div class=\"notice notice-error\"><p>The post was saved as a draft: choose a category other than \"Uncategorized\" to publish it.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Drafts can still be saved; only publishing and scheduling are blocked."
        },
        {
            "id": "content-require-excerpt",
            "version": 1,
            "title": "Require an Excerpt Before Publishing",
            "description": "Blocks publishing posts that have no hand-written excerpt, so archive pages, search results and social shares always have a proper summary.",
            "category": "content",
            "tags": [
                "editorial",
                "excerpts",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum excerpt length in characters.\n$wpide_excerpt_min_chars = 50;\n// Post types that need an excerpt.\n$wpide_excerpt_types = array( 'post' );\n\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) use ( $wpide_excerpt_min_chars, $wpide_excerpt_types ) {\n    $post_id = (int) ( $prepared->ID ?? 0 );\n    $type    = $post_id ? get_post_type( $post_id ) : 'post';\n    $status  = $request->get_param( 'status' ) ? $request->get_param( 'status' ) : get_post_status( $post_id );\n\n    if ( ! in_array( $type, $wpide_excerpt_types, true ) || ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n        return $prepared;\n    }\n\n    $excerpt = null !== $request->get_param( 'excerpt' ) ? $request->get_param( 'excerpt' ) : get_post_field( 'post_excerpt', $post_id, 'raw' );\n    $excerpt = is_array( $excerpt ) ? ( $excerpt['raw'] ?? '' ) : (string) $excerpt;\n\n    if ( mb_strlen( trim( wp_strip_all_tags( $excerpt ) ) ) < $wpide_excerpt_min_chars ) {\n        return new WP_Error( 'wpide_excerpt_required', sprintf( 'Please write an excerpt of at least %d characters before publishing.', $wpide_excerpt_min_chars ), array( 'status' => 400 ) );\n    }\n\n    return $prepared;\n}, 10, 2 );",
            "requires": [],
            "notes": "Set $wpide_excerpt_min_chars to require a minimum length; drafts can still be saved."
        },
        {
            "id": "content-block-placeholder-text",
            "version": 1,
            "title": "Block Publishing Posts That Contain Placeholder Text",
            "description": "Refuses to publish content that still contains placeholder words such as \"Lorem ipsum\", \"TODO\", \"TK\" or \"[insert\", so unfinished drafts never go live by accident.",
            "category": "content",
            "tags": [
                "editorial",
                "publishing",
                "quality"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Phrases that mean a post is not finished.\n$wpide_placeholder_phrases = array( 'lorem ipsum', 'todo', 'tk tk', '[insert', 'xxx', 'placeholder' );\n\n$wpide_find_placeholder = function ( $text ) use ( $wpide_placeholder_phrases ) {\n    $text = wp_strip_all_tags( (string) $text );\n    foreach ( $wpide_placeholder_phrases as $phrase ) {\n        if ( preg_match( '/(^|\\W)' . preg_quote( $phrase, '/' ) . '($|\\W)/i', $text ) ) {\n            return $phrase;\n        }\n    }\n    return '';\n};\n\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) use ( $wpide_find_placeholder ) {\n    $status = $request->get_param( 'status' ) ? $request->get_param( 'status' ) : get_post_status( (int) ( $prepared->ID ?? 0 ) );\n    if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n        return $prepared;\n    }\n\n    $content = $prepared->post_content ?? get_post_field( 'post_content', (int) ( $prepared->ID ?? 0 ), 'raw' );\n    $found   = $wpide_find_placeholder( ( $prepared->post_title ?? '' ) . ' ' . $content );\n\n    return $found\n        ? new WP_Error( 'wpide_placeholder_text', sprintf( 'This post still contains placeholder text (\"%s\"). Replace it before publishing.', $found ), array( 'status' => 400 ) )\n        : $prepared;\n}, 10, 2 );\n\nadd_filter( 'wp_insert_post_data', function ( $data ) use ( $wpide_find_placeholder ) {\n    if ( wp_is_serving_rest_request() || ! in_array( $data['post_status'], array( 'publish', 'future' ), true ) || ! in_array( $data['post_type'], array( 'post', 'page' ), true ) ) {\n        return $data;\n    }\n    if ( $wpide_find_placeholder( wp_unslash( $data['post_title'] . ' ' . $data['post_content'] ) ) ) {\n        $data['post_status'] = 'draft';\n    }\n    return $data;\n} );",
            "requires": [],
            "notes": "Add or remove phrases in $wpide_placeholder_phrases; matching ignores letter case."
        },
        {
            "id": "content-title-length-meter",
            "version": 1,
            "title": "Title Length Meter in the Block Editor",
            "description": "Shows a live character count for the post title in the editor sidebar, turning amber and red as it passes the length search engines usually display, to help write tidy titles.",
            "category": "content",
            "tags": [
                "block-editor",
                "titles",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Ideal and maximum title lengths in characters.\n$wpide_title_ideal = 55;\n$wpide_title_max   = 65;\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_title_ideal, $wpide_title_max ) {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-title-meter', false, array( 'wp-plugins', 'wp-element', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-title-meter' );\n    wp_add_inline_script( 'wpide-title-meter', sprintf( 'var wpideTitleLimits = { ideal: %d, max: %d };', $wpide_title_ideal, $wpide_title_max ) . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPostStatusInfo ) || ( wp.editPost && wp.editPost.PluginPostStatusInfo );\n    if ( ! Panel ) { return; }\n\n    function TitleMeter() {\n        var title = wp.data.useSelect( function ( select ) { return select( 'core/editor' ).getEditedPostAttribute( 'title' ) || ''; }, [] );\n        var length = title.length;\n        var color = length === 0 ? '#757575' : ( length <= wpideTitleLimits.ideal ? '#00a32a' : ( length <= wpideTitleLimits.max ? '#dba617' : '#d63638' ) );\n        var width = Math.min( 100, Math.round( length / wpideTitleLimits.max * 100 ) );\n\n        return el( Panel, { className: 'wpide-title-meter' },\n            el( 'div', { style: { width: '100%' } },\n                el( 'div', { style: { display: 'flex', justifyContent: 'space-between' } }, el( 'span', null, 'Title length' ), el( 'strong', { style: { color: color } }, length + ' / ' + wpideTitleLimits.max ) ),\n                el( 'div', { style: { height: '4px', background: '#ddd', borderRadius: '2px', marginTop: '4px' }, 'aria-hidden': 'true' },\n                    el( 'div', { style: { height: '100%', width: width + '%', background: color, borderRadius: '2px' } } )\n                )\n            )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-title-meter', { render: TitleMeter } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Adjust $wpide_title_ideal and $wpide_title_max; the meter never blocks saving."
        },
        {
            "id": "content-word-count-goal",
            "version": 1,
            "title": "Word Count Goal in the Block Editor",
            "description": "Lets writers set a target word count for each post in the editor sidebar and shows a progress bar that fills as they write, handy for briefs and freelance work.",
            "category": "content",
            "tags": [
                "block-editor",
                "word-count",
                "writing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default goal shown for posts without one (0 to hide the bar).\n$wpide_default_word_goal = 800;\n\nadd_action( 'init', function () {\n    register_post_meta( 'post', '_wpide_word_goal', array(\n        'type'              => 'integer',\n        'single'            => true,\n        'show_in_rest'      => true,\n        'default'           => 0,\n        'sanitize_callback' => 'absint',\n        'auth_callback'     => function ( $allowed, $key, $post_id ) {\n            return current_user_can( 'edit_post', $post_id );\n        },\n    ) );\n} );\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_default_word_goal ) {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || 'post' !== $screen->post_type ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-word-goal', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor', 'wp-wordcount' ), '1.0', true );\n    wp_enqueue_script( 'wpide-word-goal' );\n    wp_add_inline_script( 'wpide-word-goal', 'var wpideDefaultGoal = ' . (int) $wpide_default_word_goal . ';' . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginDocumentSettingPanel ) || ( wp.editPost && wp.editPost.PluginDocumentSettingPanel );\n    if ( ! Panel ) { return; }\n\n    function WordGoal() {\n        var data = wp.data.useSelect( function ( select ) {\n            var editor = select( 'core/editor' );\n            return {\n                goal: ( editor.getEditedPostAttribute( 'meta' ) || {} )._wpide_word_goal || 0,\n                content: editor.getEditedPostAttribute( 'content' ) || ''\n            };\n        }, [] );\n        var editPost = wp.data.useDispatch( 'core/editor' ).editPost;\n        var goal = data.goal || wpideDefaultGoal;\n        var words = wp.wordcount.count( data.content, 'words' );\n        var pct = goal ? Math.min( 100, Math.round( words / goal * 100 ) ) : 0;\n\n        return el( Panel, { name: 'wpide-word-goal', title: 'Word count goal' },\n            el( wp.components.TextControl, {\n                label: 'Target words',\n                type: 'number',\n                min: 0,\n                value: data.goal || '',\n                placeholder: String( wpideDefaultGoal ),\n                onChange: function ( value ) { editPost( { meta: { _wpide_word_goal: parseInt( value, 10 ) || 0 } } ); }\n            } ),\n            goal ? el( 'p', null, words + ' of ' + goal + ' words (' + pct + '%)' ) : null,\n            goal ? el( 'div', { style: { height: '8px', background: '#ddd', borderRadius: '4px' }, role: 'progressbar', 'aria-valuenow': pct, 'aria-valuemin': 0, 'aria-valuemax': 100 },\n                el( 'div', { style: { height: '100%', width: pct + '%', background: pct >= 100 ? '#00a32a' : '#3858e9', borderRadius: '4px' } } )\n            ) : null\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-word-goal', { render: WordGoal } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "The goal is stored in the _wpide_word_goal custom field; set $wpide_default_word_goal for new posts."
        },
        {
            "id": "content-editorial-notes-panel",
            "version": 1,
            "title": "Private Editorial Notes in the Block Editor",
            "description": "Adds an \"Editorial notes\" panel to the editor sidebar where writers and editors leave internal notes about a post, such as sources or open questions, that never appear on the site.",
            "category": "content",
            "tags": [
                "block-editor",
                "notes",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    foreach ( array( 'post', 'page' ) as $post_type ) {\n        register_post_meta( $post_type, '_wpide_editorial_notes', array(\n            'type'              => 'string',\n            'single'            => true,\n            'show_in_rest'      => true,\n            'sanitize_callback' => 'sanitize_textarea_field',\n            'auth_callback'     => function ( $allowed, $key, $post_id ) {\n                return current_user_can( 'edit_post', $post_id );\n            },\n        ) );\n    }\n} );\n\nadd_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || ! in_array( $screen->post_type, array( 'post', 'page' ), true ) ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-editorial-notes', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-editorial-notes' );\n    wp_add_inline_script( 'wpide-editorial-notes', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginDocumentSettingPanel ) || ( wp.editPost && wp.editPost.PluginDocumentSettingPanel );\n    if ( ! Panel ) { return; }\n\n    function Notes() {\n        var notes = wp.data.useSelect( function ( select ) {\n            return ( select( 'core/editor' ).getEditedPostAttribute( 'meta' ) || {} )._wpide_editorial_notes || '';\n        }, [] );\n        var editPost = wp.data.useDispatch( 'core/editor' ).editPost;\n\n        return el( Panel, { name: 'wpide-editorial-notes', title: 'Editorial notes', icon: 'edit' },\n            el( wp.components.TextareaControl, {\n                label: 'Internal notes (not published)',\n                rows: 6,\n                value: notes,\n                onChange: function ( value ) { editPost( { meta: { _wpide_editorial_notes: value } } ); }\n            } )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-editorial-notes', { render: Notes } );\n} )( window.wp );\nJS\n    );\n} );\n\nadd_filter( 'manage_posts_columns', function ( $columns ) {\n    $columns['wpide_notes'] = '<span class=\"dashicons dashicons-format-aside\" aria-hidden=\"true\"></span><span class=\"screen-reader-text\">Notes</span>';\n    return $columns;\n} );\n\nadd_action( 'manage_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_notes' !== $column ) {\n        return;\n    }\n    $notes = (string) get_post_meta( $post_id, '_wpide_editorial_notes', true );\n    if ( '' !== $notes ) {\n        printf( '<span class=\"dashicons dashicons-format-aside\" title=\"%s\" aria-label=\"Has editorial notes\"></span>', esc_attr( wp_trim_words( $notes, 30 ) ) );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Notes are stored in the protected _wpide_editorial_notes field and shown as an icon in the Posts list."
        },
        {
            "id": "content-publishing-queue",
            "version": 1,
            "title": "\"Add to Queue\" Scheduling for Drafts",
            "description": "Adds an \"Add to queue\" link under drafts in the Posts list that schedules the post into the next free publishing slot, such as weekdays at 9:00, without picking dates by hand.",
            "category": "content",
            "tags": [
                "scheduling",
                "editorial",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Publishing days (1 = Monday ... 7 = Sunday) and times (24h, site time zone).\n$wpide_queue_days  = array( 1, 2, 3, 4, 5 );\n$wpide_queue_times = array( '09:00', '15:00' );\n\nadd_filter( 'post_row_actions', function ( $actions, $post ) {\n    if ( 'draft' === $post->post_status && 'post' === $post->post_type && current_user_can( 'publish_post', $post->ID ) ) {\n        $actions['wpide_queue'] = sprintf(\n            '<a href=\"%s\">Add to queue</a>',\n            esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_queue_post&post=' . $post->ID ), 'wpide_queue_' . $post->ID ) )\n        );\n    }\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'admin_post_wpide_queue_post', function () use ( $wpide_queue_days, $wpide_queue_times ) {\n    $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;\n    check_admin_referer( 'wpide_queue_' . $post_id );\n    if ( ! current_user_can( 'publish_post', $post_id ) || 'draft' !== get_post_status( $post_id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    // Slots already taken by scheduled posts.\n    $taken = array();\n    foreach ( get_posts( array( 'post_status' => 'future', 'posts_per_page' => -1, 'fields' => 'ids', 'no_found_rows' => true ) ) as $id ) {\n        $taken[ get_post_field( 'post_date', $id ) ] = true;\n    }\n\n    $tz   = wp_timezone();\n    $day  = new DateTimeImmutable( 'now', $tz );\n    $slot = null;\n    for ( $i = 0; $i < 90 && ! $slot; $i++ ) {\n        $date = $day->modify( \"+$i days\" );\n        if ( ! in_array( (int) $date->format( 'N' ), $wpide_queue_days, true ) ) {\n            continue;\n        }\n        foreach ( $wpide_queue_times as $time ) {\n            list( $h, $m ) = array_map( 'intval', explode( ':', $time ) );\n            $candidate     = $date->setTime( $h, $m );\n            if ( $candidate->getTimestamp() > time() + 5 * MINUTE_IN_SECONDS && empty( $taken[ $candidate->format( 'Y-m-d H:i:s' ) ] ) ) {\n                $slot = $candidate;\n                break;\n            }\n        }\n    }\n\n    if ( $slot ) {\n        wp_update_post( array(\n            'ID'            => $post_id,\n            'post_status'   => 'future',\n            'post_date'     => $slot->format( 'Y-m-d H:i:s' ),\n            'post_date_gmt' => get_gmt_from_date( $slot->format( 'Y-m-d H:i:s' ) ),\n            'edit_date'     => true,\n        ) );\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_queued', $slot ? rawurlencode( $slot->format( 'Y-m-d H:i' ) ) : 'none', admin_url( 'edit.php' ) ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( empty( $_GET['wpide_queued'] ) ) {\n        return;\n    }\n    $slot = sanitize_text_field( wp_unslash( $_GET['wpide_queued'] ) );\n    echo 'none' === $slot\n        ? '<div class=\"notice notice-warning is-dismissible\"><p>No free slot found in the next 90 days.</p></div>'\n        : '<div class=\"notice notice-success is-dismissible\"><p>Scheduled for ' . esc_html( $slot ) . '.</p></div>';\n} );",
            "requires": [],
            "notes": "Define your slots in $wpide_queue_days and $wpide_queue_times (site time zone); only one post is scheduled per slot."
        },
        {
            "id": "content-editorial-calendar",
            "version": 1,
            "title": "Editorial Calendar Screen",
            "description": "Adds a Posts > Calendar screen showing a month grid of published, scheduled and draft posts on their dates, with links to edit each one and arrows to move between months.",
            "category": "content",
            "tags": [
                "editorial",
                "calendar",
                "scheduling"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_posts_page( 'Editorial Calendar', 'Calendar', 'edit_posts', 'wpide-calendar', function () {\n        $month = isset( $_GET['month'] ) && preg_match( '/^\\d{4}-\\d{2}$/', $_GET['month'] ) ? sanitize_text_field( wp_unslash( $_GET['month'] ) ) : wp_date( 'Y-m' );\n        $first = new DateTimeImmutable( $month . '-01', wp_timezone() );\n        $last  = $first->modify( 'last day of this month' );\n\n        $posts = get_posts( array(\n            'post_type'      => 'post',\n            'post_status'    => array( 'publish', 'future', 'draft', 'pending' ),\n            'posts_per_page' => 300,\n            'no_found_rows'  => true,\n            'date_query'     => array( array( 'after' => $first->format( 'Y-m-d 00:00:00' ), 'before' => $last->format( 'Y-m-d 23:59:59' ), 'inclusive' => true ) ),\n        ) );\n\n        $by_day = array();\n        foreach ( $posts as $post ) {\n            $by_day[ (int) mysql2date( 'j', $post->post_date ) ][] = $post;\n        }\n\n        $colors = array( 'publish' => '#00a32a', 'future' => '#2271b1', 'draft' => '#8c8f94', 'pending' => '#dba617' );\n        $nav    = function ( $offset, $label ) use ( $first ) {\n            return sprintf( '<a class=\"button\" href=\"%s\">%s</a>', esc_url( add_query_arg( array( 'page' => 'wpide-calendar', 'month' => $first->modify( $offset . ' month' )->format( 'Y-m' ) ), admin_url( 'edit.php' ) ) ), esc_html( $label ) );\n        };\n\n        echo '<div class=\"wrap\"><h1>Editorial Calendar</h1>';\n        printf( '<p style=\"display:flex;gap:8px;align-items:center\">%s <strong style=\"font-size:16px\">%s</strong> %s</p>', $nav( -1, '&larr; Previous' ), esc_html( wp_date( 'F Y', $first->getTimestamp() ) ), $nav( 1, 'Next &rarr;' ) );\n        echo '<div style=\"display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:4px\">';\n\n        $start_of_week = (int) get_option( 'start_of_week', 1 );\n        for ( $i = 0; $i < 7; $i++ ) {\n            printf( '<div style=\"font-weight:600;padding:4px\">%s</div>', esc_html( $GLOBALS['wp_locale']->get_weekday_abbrev( $GLOBALS['wp_locale']->get_weekday( ( $start_of_week + $i ) % 7 ) ) ) );\n        }\n        $blank = ( (int) $first->format( 'w' ) - $start_of_week + 7 ) % 7;\n        echo str_repeat( '<div></div>', $blank );\n\n        for ( $day = 1; $day <= (int) $last->format( 'j' ); $day++ ) {\n            echo '<div style=\"background:#fff;border:1px solid #dcdcde;min-height:90px;padding:4px;overflow:hidden\"><div style=\"color:#646970\">' . (int) $day . '</div>';\n            foreach ( $by_day[ $day ] ?? array() as $post ) {\n                printf(\n                    '<a href=\"%s\" style=\"display:block;font-size:12px;margin-top:3px;padding:2px 4px;border-left:3px solid %s;text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis\" title=\"%s\">%s</a>',\n                    esc_url( get_edit_post_link( $post ) ),\n                    esc_attr( $colors[ $post->post_status ] ?? '#8c8f94' ),\n                    esc_attr( get_the_title( $post ) . ' (' . $post->post_status . ')' ),\n                    esc_html( get_the_title( $post ) ? get_the_title( $post ) : '(no title)' )\n                );\n            }\n            echo '</div>';\n        }\n        echo '</div></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Drafts appear on their last modified date in grey; scheduled posts in blue."
        },
        {
            "id": "content-unpublish-category-after-days",
            "version": 1,
            "title": "Move Posts in a Category Back to Draft After a Number of Days",
            "description": "Automatically unpublishes posts in a chosen category, such as Jobs, Offers or Events, once they are older than a set number of days, so stale listings disappear on their own.",
            "category": "content",
            "tags": [
                "content-expiry",
                "cron",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slug, age in days and the status to set.\n$wpide_expire_category = 'jobs';\n$wpide_expire_days     = 30;\n$wpide_expire_status   = 'draft';\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_expire_category_posts' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'twicedaily', 'wpide_expire_category_posts' );\n    }\n} );\n\nadd_action( 'wpide_expire_category_posts', function () use ( $wpide_expire_category, $wpide_expire_days, $wpide_expire_status ) {\n    $ids = get_posts( array(\n        'post_type'      => 'post',\n        'post_status'    => 'publish',\n        'category_name'  => $wpide_expire_category,\n        'posts_per_page' => 50,\n        'fields'         => 'ids',\n        'no_found_rows'  => true,\n        'date_query'     => array( array( 'before' => $wpide_expire_days . ' days ago' ) ),\n    ) );\n\n    foreach ( $ids as $id ) {\n        wp_update_post( array( 'ID' => $id, 'post_status' => $wpide_expire_status ) );\n    }\n} );",
            "requires": [],
            "notes": "Runs twice a day in batches of 50; posts are set to draft, not deleted, so nothing is lost."
        },
        {
            "id": "content-sticky-posts-expire",
            "version": 1,
            "title": "Unstick Sticky Posts Automatically After a Few Days",
            "description": "Removes the sticky flag from posts a set number of days after they were made sticky, so featured announcements do not stay pinned to the top of the blog forever.",
            "category": "content",
            "tags": [
                "sticky-posts",
                "cron",
                "content-expiry"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days a post stays sticky.\n$wpide_sticky_days = 7;\n\nadd_action( 'init', function () {\n    if ( ! wp_next_scheduled( 'wpide_unstick_old_posts' ) ) {\n        wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'wpide_unstick_old_posts' );\n    }\n} );\n\n// Remember when each post became sticky.\nadd_action( 'post_stuck', function ( $post_id ) {\n    $since             = (array) get_option( 'wpide_sticky_since', array() );\n    $since[ $post_id ] = time();\n    update_option( 'wpide_sticky_since', $since, false );\n} );\n\nadd_action( 'wpide_unstick_old_posts', function () use ( $wpide_sticky_days ) {\n    $since   = (array) get_option( 'wpide_sticky_since', array() );\n    $sticky  = array_map( 'intval', (array) get_option( 'sticky_posts', array() ) );\n    $changed = false;\n\n    foreach ( $sticky as $post_id ) {\n        if ( ! isset( $since[ $post_id ] ) ) {\n            $since[ $post_id ] = time();\n            $changed           = true;\n        } elseif ( $since[ $post_id ] < time() - $wpide_sticky_days * DAY_IN_SECONDS ) {\n            unstick_post( $post_id );\n            unset( $since[ $post_id ] );\n            $changed = true;\n        }\n    }\n\n    // Forget posts that were unstuck by hand.\n    foreach ( array_keys( $since ) as $post_id ) {\n        if ( ! in_array( (int) $post_id, $sticky, true ) ) {\n            unset( $since[ $post_id ] );\n            $changed = true;\n        }\n    }\n\n    if ( $changed ) {\n        update_option( 'wpide_sticky_since', $since, false );\n    }\n} );",
            "requires": [],
            "notes": "The countdown starts when the post is made sticky (or when this snippet first sees it); change $wpide_sticky_days."
        },
        {
            "id": "content-revisions-keep-custom-fields",
            "version": 1,
            "title": "Keep Custom Field Changes in Revisions",
            "description": "Saves chosen custom fields, such as a subtitle, price or event date, with every revision so restoring an older version brings those values back too.",
            "category": "content",
            "tags": [
                "revisions",
                "custom-fields",
                "history"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Custom field keys to store with revisions.\n$wpide_revision_meta_keys = array( 'subtitle', 'price', 'event_date' );\n\nadd_filter( 'wp_post_revision_meta_keys', function ( $keys ) use ( $wpide_revision_meta_keys ) {\n    return array_values( array_unique( array_merge( $keys, $wpide_revision_meta_keys ) ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.4 or newer; list the field keys in $wpide_revision_meta_keys."
        },
        {
            "id": "content-rewrite-and-republish",
            "version": 1,
            "title": "Rewrite and Republish Published Posts Safely",
            "description": "Adds a \"Rewrite & republish\" link to published posts that creates a private working copy; when the copy is published, its content replaces the original at the same URL and the copy is removed.",
            "category": "content",
            "tags": [
                "duplicate",
                "editorial",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'post_row_actions', function ( $actions, $post ) {\n    if ( 'publish' === $post->post_status && 'post' === $post->post_type && current_user_can( 'edit_post', $post->ID ) ) {\n        $actions['wpide_rewrite'] = sprintf(\n            '<a href=\"%s\">Rewrite &amp; republish</a>',\n            esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_rewrite&post=' . $post->ID ), 'wpide_rewrite_' . $post->ID ) )\n        );\n    }\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'admin_post_wpide_rewrite', function () {\n    $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;\n    check_admin_referer( 'wpide_rewrite_' . $post_id );\n    $post = get_post( $post_id );\n    if ( ! $post || ! current_user_can( 'edit_post', $post_id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    $copy_id = wp_insert_post( wp_slash( array(\n        'post_type'     => $post->post_type,\n        'post_status'   => 'draft',\n        'post_title'    => $post->post_title,\n        'post_content'  => $post->post_content,\n        'post_excerpt'  => $post->post_excerpt,\n        'post_author'   => get_current_user_id(),\n        'post_category' => wp_get_post_categories( $post_id ),\n        'tags_input'    => wp_get_post_tags( $post_id, array( 'fields' => 'names' ) ),\n    ) ) );\n    update_post_meta( $copy_id, '_wpide_rewrite_of', $post_id );\n    if ( has_post_thumbnail( $post_id ) ) {\n        set_post_thumbnail( $copy_id, get_post_thumbnail_id( $post_id ) );\n    }\n\n    wp_safe_redirect( get_edit_post_link( $copy_id, 'url' ) );\n    exit;\n} );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $copy ) {\n    if ( 'publish' !== $new_status || 'publish' === $old_status ) {\n        return;\n    }\n    $original_id = (int) get_post_meta( $copy->ID, '_wpide_rewrite_of', true );\n    if ( ! $original_id || ! get_post( $original_id ) ) {\n        return;\n    }\n\n    // Copy the new version onto the original, keeping its URL and date.\n    wp_update_post( wp_slash( array(\n        'ID'           => $original_id,\n        'post_title'   => $copy->post_title,\n        'post_content' => $copy->post_content,\n        'post_excerpt' => $copy->post_excerpt,\n    ) ) );\n    wp_set_post_categories( $original_id, wp_get_post_categories( $copy->ID ) );\n    wp_set_post_tags( $original_id, wp_get_post_tags( $copy->ID, array( 'fields' => 'names' ) ) );\n    if ( has_post_thumbnail( $copy->ID ) ) {\n        set_post_thumbnail( $original_id, get_post_thumbnail_id( $copy->ID ) );\n    }\n\n    delete_post_meta( $copy->ID, '_wpide_rewrite_of' );\n    wp_trash_post( $copy->ID );\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () {\n    $post = get_post();\n    $screen = get_current_screen();\n    if ( $post && $screen && 'post' === $screen->base && ( $original = (int) get_post_meta( $post->ID, '_wpide_rewrite_of', true ) ) ) {\n        printf( '<div class=\"notice notice-info\"><p>This is a working copy of <a href=\"%s\">%s</a>. Publishing it will update the original.</p></div>', esc_url( get_permalink( $original ) ), esc_html( get_the_title( $original ) ) );\n    }\n} );",
            "requires": [],
            "notes": "The original stays live and unchanged until you publish the copy; comments, URL and publish date of the original are kept."
        },
        {
            "id": "content-featured-image-min-size",
            "version": 1,
            "title": "Minimum Size for Featured Images",
            "description": "Stops posts from being published with a featured image smaller than the size you set, such as 1200 by 630 pixels, so social previews and hero images never look blurry.",
            "category": "content",
            "tags": [
                "featured-image",
                "editorial",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Minimum featured image size in pixels.\n$wpide_min_thumb_width  = 1200;\n$wpide_min_thumb_height = 630;\n\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) use ( $wpide_min_thumb_width, $wpide_min_thumb_height ) {\n    $post_id = (int) ( $prepared->ID ?? 0 );\n    $status  = $request->get_param( 'status' ) ? $request->get_param( 'status' ) : get_post_status( $post_id );\n    if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n        return $prepared;\n    }\n\n    $image_id = null !== $request->get_param( 'featured_media' ) ? (int) $request->get_param( 'featured_media' ) : (int) get_post_thumbnail_id( $post_id );\n    if ( ! $image_id ) {\n        return $prepared;\n    }\n\n    $meta = wp_get_attachment_metadata( $image_id );\n    if ( empty( $meta['width'] ) || ( $meta['width'] >= $wpide_min_thumb_width && $meta['height'] >= $wpide_min_thumb_height ) ) {\n        return $prepared;\n    }\n\n    return new WP_Error(\n        'wpide_featured_image_too_small',\n        sprintf( 'The featured image is %1$d x %2$d pixels; please use one of at least %3$d x %4$d.', $meta['width'], $meta['height'], $wpide_min_thumb_width, $wpide_min_thumb_height ),\n        array( 'status' => 400 )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "Posts without a featured image are not affected; pair it with a \"require featured image\" rule if you need both."
        },
        {
            "id": "content-old-post-editor-notice",
            "version": 1,
            "title": "Fact-Check Reminder When Editing Old Posts",
            "description": "Shows a notice in the block editor when someone opens a post published more than two years ago, reminding them to check facts, links and screenshots while they are there.",
            "category": "content",
            "tags": [
                "block-editor",
                "notices",
                "content-audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Age in years that triggers the reminder.\n$wpide_old_post_years = 2;\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_old_post_years ) {\n    $post = get_post();\n    if ( ! $post || 'publish' !== $post->post_status || get_post_time( 'U', true, $post ) > strtotime( \"-{$wpide_old_post_years} years\" ) ) {\n        return;\n    }\n\n    $message = sprintf( 'This post was published %s ago. Please check that facts, prices, links and screenshots are still correct.', human_time_diff( get_post_time( 'U', true, $post ) ) );\n\n    wp_add_inline_script(\n        'wp-notices',\n        sprintf( 'wp.domReady(function(){wp.data.dispatch(\"core/notices\").createWarningNotice(%s,{id:\"wpide-old-post\",isDismissible:true});});', wp_json_encode( $message ) ),\n        'after'\n    );\n    wp_enqueue_script( 'wp-notices' );\n    wp_enqueue_script( 'wp-dom-ready' );\n} );",
            "requires": [],
            "notes": "Change $wpide_old_post_years; the notice can be dismissed for the session."
        },
        {
            "id": "content-store-auto-excerpt",
            "version": 1,
            "title": "Save an Excerpt Automatically When One Is Missing",
            "description": "Fills in the excerpt field from the first sentences of the post when it is saved without one, so feeds, plugins and APIs that read the stored excerpt always get a summary.",
            "category": "content",
            "tags": [
                "excerpts",
                "publishing",
                "automation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Length of the generated excerpt.\n$wpide_auto_excerpt_words = 35;\n// Post types to fill.\n$wpide_auto_excerpt_types = array( 'post' );\n\nadd_filter( 'wp_insert_post_data', function ( $data ) use ( $wpide_auto_excerpt_words, $wpide_auto_excerpt_types ) {\n    if ( '' !== trim( $data['post_excerpt'] ) || ! in_array( $data['post_type'], $wpide_auto_excerpt_types, true ) || ! in_array( $data['post_status'], array( 'publish', 'future', 'pending' ), true ) ) {\n        return $data;\n    }\n\n    $content = wp_unslash( $data['post_content'] );\n    $content = excerpt_remove_blocks( strip_shortcodes( $content ) );\n    // Skip headings so the summary starts with real text.\n    $content = preg_replace( '#<h[1-6][^>]*>.*?</h[1-6]>#is', '', $content );\n    $text    = trim( preg_replace( '/\\s+/', ' ', wp_strip_all_tags( $content ) ) );\n\n    if ( '' !== $text ) {\n        $data['post_excerpt'] = wp_slash( wp_trim_words( $text, $wpide_auto_excerpt_words, '...' ) );\n    }\n\n    return $data;\n} );",
            "requires": [],
            "notes": "Writers can still type their own excerpt, which is never overwritten; change $wpide_auto_excerpt_words for a longer summary."
        },
        {
            "id": "columns-shortcode",
            "version": 1,
            "title": "Responsive Columns Shortcode",
            "description": "Adds [layout_columns] and [layout_col] shortcodes that place content side by side in two, three or four columns and stack them on phones, for classic editor content and page builders without a columns tool.",
            "category": "content",
            "tags": [
                "shortcodes",
                "columns",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_style( 'wpide-columns', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-columns', '.wpide-cols{display:flex;flex-wrap:wrap;gap:var(--wpide-gap,1.5rem);margin:1.5em 0}.wpide-col{flex:1 1 0;min-width:0}.wpide-col>:first-child{margin-top:0}.wpide-col>:last-child{margin-bottom:0}@media (max-width:640px){.wpide-col{flex-basis:100%!important}}' );\n\n    add_shortcode( 'layout_columns', function ( $atts, $content = '' ) {\n        $atts = shortcode_atts( array( 'gap' => '1.5rem', 'class' => '' ), $atts, 'layout_columns' );\n        wp_enqueue_style( 'wpide-columns' );\n\n        // Remove the stray paragraphs wpautop adds between [layout_col] tags.\n        $content = preg_replace( '#^\\s*</p>|<p>\\s*$#', '', trim( $content ) );\n\n        return sprintf(\n            '<div class=\"wpide-cols %s\" style=\"--wpide-gap:%s\">%s</div>',\n            esc_attr( $atts['class'] ),\n            esc_attr( preg_replace( '/[^0-9a-z.%]/i', '', $atts['gap'] ) ),\n            do_shortcode( shortcode_unautop( $content ) )\n        );\n    } );\n\n    add_shortcode( 'layout_col', function ( $atts, $content = '' ) {\n        $atts  = shortcode_atts( array( 'width' => '' ), $atts, 'layout_col' );\n        $width = preg_replace( '/[^0-9.%a-z]/i', '', $atts['width'] );\n\n        return sprintf(\n            '<div class=\"wpide-col\"%s>%s</div>',\n            $width ? ' style=\"flex:0 0 calc(' . esc_attr( $width ) . ' - var(--wpide-gap,1.5rem))\"' : '',\n            wpautop( do_shortcode( trim( $content ) ) )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [layout_columns gap=\"2rem\"][layout_col]Left[/layout_col][layout_col]Right[/layout_col][/layout_columns]; columns share the width equally unless you add width=\"30%\"."
        },
        {
            "id": "inline-icon-shortcode",
            "version": 1,
            "title": "Inline SVG Icon Shortcode",
            "description": "Adds an [svg_icon] shortcode with a small built-in set of clean SVG icons, such as check, cross, star, arrow, info, warning, phone and mail, sized to match the surrounding text.",
            "category": "content",
            "tags": [
                "shortcodes",
                "icons",
                "svg"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'svg_icon', function ( $atts ) {\n        $atts = shortcode_atts( array( 'name' => 'check', 'color' => 'currentColor', 'size' => '1em', 'label' => '' ), $atts, 'svg_icon' );\n\n        // 24x24 stroke icons.\n        $paths = array(\n            'check'   => '<path d=\"M5 12.5l4.5 4.5L19 7.5\"/>',\n            'cross'   => '<path d=\"M6 6l12 12M18 6L6 18\"/>',\n            'star'    => '<path d=\"M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z\"/>',\n            'arrow'   => '<path d=\"M4 12h15M13 6l6 6-6 6\"/>',\n            'info'    => '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v6M12 7.5v.5\"/>',\n            'warning' => '<path d=\"M12 4l9 16H3z\"/><path d=\"M12 10v4M12 17v.5\"/>',\n            'phone'   => '<path d=\"M6.5 3.5l3 .5 1.5 4-2 1.5a11 11 0 0 0 5.5 5.5l1.5-2 4 1.5.5 3c0 1-1 2-2 2A16.5 16.5 0 0 1 4.5 5.5c0-1 1-2 2-2z\"/>',\n            'mail'    => '<rect x=\"3\" y=\"5.5\" width=\"18\" height=\"13\" rx=\"2\"/><path d=\"M3.5 7l8.5 6 8.5-6\"/>',\n            'heart'   => '<path d=\"M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z\"/>',\n            'clock'   => '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n            'pin'     => '<path d=\"M12 21s-6.5-6.2-6.5-11a6.5 6.5 0 0 1 13 0c0 4.8-6.5 11-6.5 11z\"/><circle cx=\"12\" cy=\"10\" r=\"2.3\"/>',\n            'download'=> '<path d=\"M12 4v11M7 10.5l5 5 5-5M5 19.5h14\"/>',\n        );\n\n        $name = sanitize_key( $atts['name'] );\n        if ( ! isset( $paths[ $name ] ) ) {\n            return '';\n        }\n\n        $a11y = '' !== $atts['label']\n            ? 'role=\"img\" aria-label=\"' . esc_attr( $atts['label'] ) . '\"'\n            : 'aria-hidden=\"true\" focusable=\"false\"';\n\n        return sprintf(\n            '<svg class=\"wpide-icon wpide-icon-%1$s\" %2$s width=\"%3$s\" height=\"%3$s\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"%4$s\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"vertical-align:-0.125em;display:inline-block\">%5$s</svg>',\n            esc_attr( $name ),\n            $a11y,\n            esc_attr( preg_replace( '/[^0-9.a-z%]/i', '', $atts['size'] ) ),\n            esc_attr( $atts['color'] ),\n            $paths[ $name ]\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [svg_icon name=\"check\" color=\"green\" label=\"Included\"]; without a label the icon is hidden from screen readers as decoration."
        },
        {
            "id": "csv-table-shortcode",
            "version": 1,
            "title": "Table From Comma-Separated Text Shortcode",
            "description": "Adds a [data_table] shortcode that turns comma-separated lines typed between the tags into a clean, accessible HTML table with a header row, which is much quicker than building tables by hand.",
            "category": "content",
            "tags": [
                "shortcodes",
                "tables",
                "data"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'data_table', function ( $atts, $content = '' ) {\n        $atts = shortcode_atts( array( 'delimiter' => ',', 'header' => 'yes', 'caption' => '' ), $atts, 'data_table' );\n\n        // Undo wpautop line breaks and paragraphs.\n        $text  = preg_replace( '#<br\\s*/?>|</p>\\s*<p>#i', \"\\n\", $content );\n        $text  = trim( wp_strip_all_tags( $text ) );\n        $lines = array_filter( array_map( 'trim', preg_split( '/\\r\\n|\\r|\\n/', $text ) ), 'strlen' );\n        if ( ! $lines ) {\n            return '';\n        }\n\n        $delimiter = substr( (string) $atts['delimiter'], 0, 1 );\n        $rows      = array_map( function ( $line ) use ( $delimiter ) {\n            return array_map( 'trim', str_getcsv( html_entity_decode( $line, ENT_QUOTES ), $delimiter ? $delimiter : ',', '\"', '\\\\' ) );\n        }, array_values( $lines ) );\n\n        $html = '<div class=\"wpide-data-table\" style=\"overflow-x:auto\"><table>';\n        if ( '' !== $atts['caption'] ) {\n            $html .= '<caption>' . esc_html( $atts['caption'] ) . '</caption>';\n        }\n        if ( 'yes' === $atts['header'] ) {\n            $head  = array_shift( $rows );\n            $html .= '<thead><tr>' . implode( '', array_map( function ( $cell ) {\n                return '<th scope=\"col\">' . esc_html( $cell ) . '</th>';\n            }, $head ) ) . '</tr></thead>';\n        }\n        $html .= '<tbody>';\n        foreach ( $rows as $row ) {\n            $html .= '<tr>' . implode( '', array_map( function ( $cell ) {\n                return '<td>' . esc_html( $cell ) . '</td>';\n            }, $row ) ) . '</tr>';\n        }\n\n        return $html . '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Example [data_table caption=\"Opening hours\"]Day, Hours<br>Monday, 9-17[/data_table]; use delimiter=\";\" for semicolons and header=\"no\" to skip the header row."
        },
        {
            "id": "pull-quote-shortcode",
            "version": 1,
            "title": "Pull Quote Shortcode",
            "description": "Adds a [pull_quote] shortcode that sets a short quotation in large type, floated to the side on wide screens and full width on phones, to break up long articles.",
            "category": "content",
            "tags": [
                "shortcodes",
                "quotes",
                "typography"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_style( 'wpide-pullquote', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-pullquote', '.wpide-pullquote{margin:1.5em 0;padding:.5em 0 .5em 1em;border-left:4px solid currentColor;font-size:1.35em;line-height:1.4;font-style:italic}.wpide-pullquote p{margin:0}.wpide-pullquote cite{display:block;margin-top:.5em;font-size:.7em;font-style:normal;opacity:.8}@media (min-width:700px){.wpide-pullquote.is-left{float:left;width:40%;margin:.3em 1.5em 1em 0}.wpide-pullquote.is-right{float:right;width:40%;margin:.3em 0 1em 1.5em}.wpide-pullquote.is-center{text-align:center;border-left:0;border-top:3px solid currentColor;border-bottom:3px solid currentColor;padding:.75em 0}}' );\n\n    add_shortcode( 'pull_quote', function ( $atts, $content = '' ) {\n        $atts  = shortcode_atts( array( 'align' => 'right', 'cite' => '' ), $atts, 'pull_quote' );\n        $align = in_array( $atts['align'], array( 'left', 'right', 'center' ), true ) ? $atts['align'] : 'right';\n        wp_enqueue_style( 'wpide-pullquote' );\n\n        return sprintf(\n            '<blockquote class=\"wpide-pullquote is-%s\"><p>%s</p>%s</blockquote>',\n            esc_attr( $align ),\n            wp_kses( trim( $content ), array( 'em' => array(), 'strong' => array(), 'br' => array(), 'a' => array( 'href' => true ) ) ),\n            '' !== $atts['cite'] ? '<cite>&mdash; ' . esc_html( $atts['cite'] ) . '</cite>' : ''\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [pull_quote align=\"right\" cite=\"Jane Doe\"]Great design is invisible.[/pull_quote]; align can be left, right or center."
        },
        {
            "id": "star-rating-shortcode",
            "version": 1,
            "title": "Star Rating Shortcode",
            "description": "Adds a [star_rating] shortcode that shows a rating such as 4.5 out of 5 as stars, with half stars and a text label for screen readers, for reviews and comparison posts.",
            "category": "content",
            "tags": [
                "shortcodes",
                "ratings",
                "reviews"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'star_rating', function ( $atts ) {\n        $atts   = shortcode_atts( array( 'rating' => 0, 'max' => 5, 'color' => '#f5a623', 'size' => '1.1em' ), $atts, 'star_rating' );\n        $max    = max( 1, min( 10, (int) $atts['max'] ) );\n        $rating = max( 0, min( $max, round( (float) $atts['rating'] * 2 ) / 2 ) );\n        $star   = 'M12 2.8l2.9 5.9 6.5.9-4.7 4.6 1.1 6.4L12 17.6l-5.8 3 1.1-6.4-4.7-4.6 6.5-.9z';\n        $id     = wp_unique_id( 'wpide-half-' );\n        $out    = '';\n\n        for ( $i = 1; $i <= $max; $i++ ) {\n            $fill = $rating >= $i ? esc_attr( $atts['color'] ) : ( $rating >= $i - 0.5 ? 'url(#' . $id . ')' : 'none' );\n            $out .= '<svg aria-hidden=\"true\" focusable=\"false\" viewBox=\"0 0 24 24\" width=\"' . esc_attr( $atts['size'] ) . '\" height=\"' . esc_attr( $atts['size'] ) . '\" style=\"vertical-align:-0.15em\"><path d=\"' . $star . '\" fill=\"' . $fill . '\" stroke=\"' . esc_attr( $atts['color'] ) . '\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>';\n        }\n\n        $defs = '<svg width=\"0\" height=\"0\" style=\"position:absolute\" aria-hidden=\"true\" focusable=\"false\"><defs><linearGradient id=\"' . esc_attr( $id ) . '\"><stop offset=\"50%\" stop-color=\"' . esc_attr( $atts['color'] ) . '\"/><stop offset=\"50%\" stop-color=\"transparent\"/></linearGradient></defs></svg>';\n\n        return sprintf(\n            '<span class=\"wpide-stars\" role=\"img\" aria-label=\"%s\">%s%s</span>',\n            esc_attr( sprintf( 'Rated %s out of %d', $rating, $max ) ),\n            $defs,\n            $out\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [star_rating rating=\"4.5\" max=\"5\" color=\"#f5a623\"]; values are clamped between 0 and max."
        },
        {
            "id": "phone-link-shortcode",
            "version": 1,
            "title": "Click-to-Call Phone Number Shortcode",
            "description": "Adds a [phone_link] shortcode that prints your phone number as a tap-to-call link, formatted for reading but dialled correctly in international format, so it only needs to be changed in one place.",
            "category": "content",
            "tags": [
                "shortcodes",
                "phone",
                "contact"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default number, written the way people should read it.\n$wpide_phone_number = '+1 (555) 010-0200';\n\nadd_action( 'init', function () use ( $wpide_phone_number ) {\n    add_shortcode( 'phone_link', function ( $atts ) use ( $wpide_phone_number ) {\n        $atts = shortcode_atts( array( 'number' => $wpide_phone_number, 'text' => '', 'icon' => 'yes' ), $atts, 'phone_link' );\n\n        // Drop the \"(0)\" trunk prefix and everything but digits and a leading plus.\n        $dial = preg_replace( '/\\(0\\)/', '', $atts['number'] );\n        $dial = ( 0 === strpos( trim( $dial ), '+' ) ? '+' : '' ) . preg_replace( '/\\D/', '', $dial );\n\n        return sprintf(\n            '<a class=\"wpide-phone\" href=\"tel:%s\">%s%s</a>',\n            esc_attr( $dial ),\n            'yes' === $atts['icon'] ? '<span aria-hidden=\"true\">&#9742;&nbsp;</span>' : '',\n            esc_html( '' !== $atts['text'] ? $atts['text'] : $atts['number'] )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Set $wpide_phone_number once, then use [phone_link] anywhere; [phone_link number=\"+44 20 7946 0000\" text=\"Call London\"] overrides it."
        },
        {
            "id": "date-offset-shortcode",
            "version": 1,
            "title": "Formatted Date Shortcode With Offsets",
            "description": "Adds a [show_date] shortcode that prints today's date, or a date relative to it such as \"+3 days\" or \"next friday\", in any format, for delivery promises, deadlines and offer end dates.",
            "category": "content",
            "tags": [
                "shortcodes",
                "dates",
                "time"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'show_date', function ( $atts ) {\n        $atts = shortcode_atts( array( 'format' => get_option( 'date_format' ), 'offset' => '' ), $atts, 'show_date' );\n\n        $now = new DateTimeImmutable( 'now', wp_timezone() );\n        if ( '' !== trim( $atts['offset'] ) ) {\n            $offset = sanitize_text_field( $atts['offset'] );\n            // Reject text PHP cannot parse instead of letting modify() warn.\n            if ( false === strtotime( $offset ) ) {\n                return '';\n            }\n            $now = $now->modify( $offset );\n        }\n\n        return '<time datetime=\"' . esc_attr( $now->format( 'Y-m-d' ) ) . '\">' . esc_html( wp_date( $atts['format'], $now->getTimestamp() ) ) . '</time>';\n    } );\n} );",
            "requires": [],
            "notes": "Example [show_date offset=\"+2 weekdays\" format=\"l, F j\"]; uses the site time zone. Pages cached by a caching plugin show the date they were cached."
        },
        {
            "id": "years-since-shortcode",
            "version": 1,
            "title": "\"Years Since\" Shortcode",
            "description": "Adds a [years_since] shortcode that prints how many full years have passed since a date, such as years in business, years of experience or a person's age, so the number never goes out of date.",
            "category": "content",
            "tags": [
                "shortcodes",
                "dates",
                "numbers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'years_since', function ( $atts ) {\n        $atts = shortcode_atts( array( 'date' => '' ), $atts, 'years_since' );\n\n        $start = DateTimeImmutable::createFromFormat( '!Y-m-d', $atts['date'], wp_timezone() );\n        if ( ! $start ) {\n            return '';\n        }\n\n        return esc_html( number_format_i18n( $start->diff( new DateTimeImmutable( 'now', wp_timezone() ) )->y ) );\n    } );\n} );",
            "requires": [],
            "notes": "Example \"Serving customers for [years_since date='2009-05-01'] years\"."
        },
        {
            "id": "download-button-shortcode",
            "version": 1,
            "title": "Download Button Shortcode With File Size",
            "description": "Adds a [download_button] shortcode that turns a Media Library file into a download button showing the file type and size, such as \"Brochure (PDF, 2.4 MB)\".",
            "category": "content",
            "tags": [
                "shortcodes",
                "downloads",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'download_button', function ( $atts ) {\n        $atts = shortcode_atts( array( 'id' => 0, 'text' => '' ), $atts, 'download_button' );\n        $id   = absint( $atts['id'] );\n        if ( ! $id || 'attachment' !== get_post_type( $id ) ) {\n            return '';\n        }\n\n        $url  = wp_get_attachment_url( $id );\n        $file = get_attached_file( $id );\n        $meta = wp_get_attachment_metadata( $id );\n        $size = ! empty( $meta['filesize'] ) ? (int) $meta['filesize'] : ( $file && file_exists( $file ) ? filesize( $file ) : 0 );\n        $type = strtoupper( pathinfo( (string) $url, PATHINFO_EXTENSION ) );\n        $text = '' !== $atts['text'] ? $atts['text'] : get_the_title( $id );\n\n        return sprintf(\n            '<a class=\"wp-element-button wpide-download\" href=\"%s\" download>%s <span class=\"wpide-download-meta\">(%s%s)</span></a>',\n            esc_url( $url ),\n            esc_html( $text ),\n            esc_html( $type ),\n            $size ? esc_html( ', ' . size_format( $size, 1 ) ) : ''\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [download_button id=\"123\" text=\"Get the brochure\"]; find the ID in the Media Library address bar when you open the file."
        },
        {
            "id": "post-card-shortcode",
            "version": 1,
            "title": "Link Card for Another Post Shortcode",
            "description": "Adds a [post_card] shortcode that shows another post or page as a card with its image, title and excerpt, perfect for \"see also\" links inside articles.",
            "category": "content",
            "tags": [
                "shortcodes",
                "internal-links",
                "cards"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_style( 'wpide-post-card', false, array(), '1.0' );\n    wp_add_inline_style( 'wpide-post-card', '.wpide-post-card{display:flex;gap:1rem;align-items:center;margin:1.5em 0;padding:.75rem;border:1px solid rgba(0,0,0,.15);border-radius:8px;text-decoration:none;color:inherit}.wpide-post-card:hover,.wpide-post-card:focus{border-color:currentColor}.wpide-post-card img{width:120px;height:80px;object-fit:cover;border-radius:4px;flex:none}.wpide-post-card strong{display:block;margin-bottom:.25em}.wpide-post-card span{font-size:.9em;opacity:.85}@media (max-width:480px){.wpide-post-card img{width:80px;height:60px}}' );\n\n    add_shortcode( 'post_card', function ( $atts ) {\n        $atts = shortcode_atts( array( 'id' => 0, 'slug' => '', 'type' => 'post' ), $atts, 'post_card' );\n        $post = $atts['id'] ? get_post( absint( $atts['id'] ) ) : get_page_by_path( sanitize_title( $atts['slug'] ), OBJECT, sanitize_key( $atts['type'] ) );\n\n        if ( ! $post || 'publish' !== $post->post_status || post_password_required( $post ) || get_the_ID() === $post->ID ) {\n            return '';\n        }\n\n        wp_enqueue_style( 'wpide-post-card' );\n\n        return sprintf(\n            '<a class=\"wpide-post-card\" href=\"%s\">%s<span><strong>%s</strong><span>%s</span></span></a>',\n            esc_url( get_permalink( $post ) ),\n            has_post_thumbnail( $post ) ? get_the_post_thumbnail( $post, 'thumbnail', array( 'alt' => '', 'loading' => 'lazy' ) ) : '',\n            esc_html( get_the_title( $post ) ),\n            esc_html( wp_trim_words( get_the_excerpt( $post ), 20 ) )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [post_card id=\"42\"] or [post_card slug=\"pricing\" type=\"page\"]; only published items are shown."
        },
        {
            "id": "opening-hours-shortcode",
            "version": 1,
            "title": "Opening Hours Shortcode With \"Open Now\" Status",
            "description": "Adds an [opening_hours] shortcode that prints your weekly opening hours as a table, highlights today and shows whether you are open right now, based on the site time zone.",
            "category": "content",
            "tags": [
                "shortcodes",
                "opening-hours",
                "local-business"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Day number (1 = Monday ... 7 = Sunday) => \"HH:MM-HH:MM\", several ranges separated by commas, or '' when closed.\n$wpide_opening_hours = array(\n    1 => '09:00-17:30',\n    2 => '09:00-17:30',\n    3 => '09:00-17:30',\n    4 => '09:00-20:00',\n    5 => '09:00-12:30,13:30-17:00',\n    6 => '10:00-14:00',\n    7 => '',\n);\n\nadd_action( 'init', function () use ( $wpide_opening_hours ) {\n    add_shortcode( 'opening_hours', function ( $atts ) use ( $wpide_opening_hours ) {\n        $atts  = shortcode_atts( array( 'status' => 'yes' ), $atts, 'opening_hours' );\n        $now   = new DateTimeImmutable( 'now', wp_timezone() );\n        $today = (int) $now->format( 'N' );\n        $time  = $now->format( 'H:i' );\n        $open  = false;\n\n        foreach ( array_filter( explode( ',', $wpide_opening_hours[ $today ] ?? '' ) ) as $range ) {\n            list( $from, $to ) = array_map( 'trim', explode( '-', $range ) + array( '', '' ) );\n            if ( $time >= $from && $time < $to ) {\n                $open = true;\n            }\n        }\n\n        $badge = sprintf(\n            '<span class=\"wpide-open-status\" style=\"display:inline-block;padding:.15em .6em;border-radius:1em;font-weight:600;color:#fff;background:%s\">%s</span>',\n            $open ? '#1a7f37' : '#b42318',\n            $open ? 'Open now' : 'Closed now'\n        );\n\n        if ( 'only' === $atts['status'] ) {\n            return $badge;\n        }\n\n        $html = 'yes' === $atts['status'] ? '<p>' . $badge . '</p>' : '';\n        $html .= '<table class=\"wpide-opening-hours\"><tbody>';\n        foreach ( $wpide_opening_hours as $day => $hours ) {\n            $name  = wp_date( 'l', strtotime( 'monday this week +' . ( $day - 1 ) . ' days' ) );\n            $hours = '' === $hours ? 'Closed' : str_replace( array( '-', ',' ), array( '&ndash;', ', ' ), esc_html( $hours ) );\n            $html .= sprintf( '<tr%s><th scope=\"row\">%s</th><td>%s</td></tr>', $day === $today ? ' style=\"font-weight:700\" aria-current=\"date\"' : '', esc_html( $name ), $hours );\n        }\n        return $html . '</tbody></table>';\n    } );\n} );",
            "requires": [],
            "notes": "Edit $wpide_opening_hours (use an empty string for closed days); add status=\"only\" to print just the open or closed badge. Full-page caching can delay the status."
        },
        {
            "id": "map-link-shortcode",
            "version": 1,
            "title": "\"Get Directions\" Map Link Shortcode",
            "description": "Adds a [map_link] shortcode that links an address to Google Maps or Apple Maps directions, opening the visitor's maps app on phones, without embedding a map or loading any script.",
            "category": "content",
            "tags": [
                "shortcodes",
                "maps",
                "directions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default address used when the shortcode has none.\n$wpide_map_address = '';\n\nadd_action( 'init', function () use ( $wpide_map_address ) {\n    add_shortcode( 'map_link', function ( $atts ) use ( $wpide_map_address ) {\n        $atts    = shortcode_atts( array( 'address' => $wpide_map_address, 'text' => '', 'app' => 'google' ), $atts, 'map_link' );\n        $address = trim( sanitize_text_field( $atts['address'] ) );\n        if ( '' === $address ) {\n            return '';\n        }\n\n        $url = 'apple' === $atts['app']\n            ? 'https://maps.apple.com/?daddr=' . rawurlencode( $address )\n            : 'https://www.google.com/maps/dir/?api=1&destination=' . rawurlencode( $address );\n\n        return sprintf(\n            '<a class=\"wpide-map-link\" href=\"%s\" target=\"_blank\" rel=\"noopener\">%s<span class=\"screen-reader-text\"> (opens in a new tab)</span></a>',\n            esc_url( $url ),\n            esc_html( '' !== $atts['text'] ? $atts['text'] : $address )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [map_link address=\"10 Downing St, London\" text=\"Get directions\"]; add app=\"apple\" for Apple Maps links."
        },
        {
            "id": "pdf-embed-shortcode",
            "version": 1,
            "title": "PDF Viewer Shortcode",
            "description": "Adds a [pdf_viewer] shortcode that shows a PDF from the Media Library or a URL inside the page with the browser's built-in viewer, plus a download link for phones that cannot display it inline.",
            "category": "content",
            "tags": [
                "shortcodes",
                "pdf",
                "embeds"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'pdf_viewer', function ( $atts ) {\n        $atts = shortcode_atts( array( 'id' => 0, 'url' => '', 'height' => 700, 'title' => '' ), $atts, 'pdf_viewer' );\n        $url  = $atts['id'] ? wp_get_attachment_url( absint( $atts['id'] ) ) : $atts['url'];\n        $url  = esc_url( (string) $url, array( 'http', 'https' ) );\n\n        if ( ! $url || 'pdf' !== strtolower( pathinfo( (string) wp_parse_url( $url, PHP_URL_PATH ), PATHINFO_EXTENSION ) ) ) {\n            return '';\n        }\n\n        $title = '' !== $atts['title'] ? $atts['title'] : ( $atts['id'] ? get_the_title( absint( $atts['id'] ) ) : 'PDF document' );\n\n        return sprintf(\n            '<div class=\"wpide-pdf\"><object data=\"%1$s#view=FitH\" type=\"application/pdf\" width=\"100%%\" height=\"%2$d\" aria-label=\"%3$s\"><p><a href=\"%1$s\">%4$s</a></p></object><p><a href=\"%1$s\" download>Download %3$s (PDF)</a></p></div>',\n            $url,\n            max( 200, min( 2000, (int) $atts['height'] ) ),\n            esc_attr( $title ),\n            esc_html( 'Open ' . $title )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Example [pdf_viewer id=\"123\" height=\"800\"]; PDFs from other domains may be blocked from embedding by that site."
        },
        {
            "id": "block-coming-soon-posts",
            "version": 1,
            "title": "\"Coming Soon\" Block Listing Scheduled Posts",
            "description": "Registers a \"Coming Soon\" block, rendered on the server without a build step, that lists the titles and dates of your next scheduled posts to build anticipation for upcoming content.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "scheduling",
                "dynamic-block"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_script( 'wpide-block-coming-soon', false, array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render' ), '1.0', true );\n    wp_add_inline_script( 'wpide-block-coming-soon', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    wp.blocks.registerBlockType( 'wpide/coming-soon', {\n        apiVersion: 3,\n        title: 'Coming Soon',\n        icon: 'clock',\n        category: 'widgets',\n        attributes: { count: { type: 'number', default: 5 }, showDate: { type: 'boolean', default: true } },\n        edit: function ( props ) {\n            return el( 'div', wp.blockEditor.useBlockProps(),\n                el( wp.blockEditor.InspectorControls, null,\n                    el( wp.components.PanelBody, { title: 'Settings' },\n                        el( wp.components.RangeControl, { label: 'Number of posts', min: 1, max: 20, value: props.attributes.count, onChange: function ( v ) { props.setAttributes( { count: v } ); } } ),\n                        el( wp.components.ToggleControl, { label: 'Show date', checked: props.attributes.showDate, onChange: function ( v ) { props.setAttributes( { showDate: v } ); } } )\n                    )\n                ),\n                el( wp.serverSideRender, { block: 'wpide/coming-soon', attributes: props.attributes } )\n            );\n        },\n        save: function () { return null; }\n    } );\n} )( window.wp );\nJS\n    );\n\n    register_block_type( 'wpide/coming-soon', array(\n        'api_version'           => 3,\n        'title'                 => 'Coming Soon',\n        'category'              => 'widgets',\n        'editor_script_handles' => array( 'wpide-block-coming-soon' ),\n        'attributes'            => array(\n            'count'    => array( 'type' => 'number', 'default' => 5 ),\n            'showDate' => array( 'type' => 'boolean', 'default' => true ),\n        ),\n        'render_callback'       => function ( $attributes ) {\n            $posts = get_posts( array(\n                'post_status'    => 'future',\n                'posts_per_page' => max( 1, min( 20, (int) $attributes['count'] ) ),\n                'orderby'        => 'date',\n                'order'          => 'ASC',\n                'no_found_rows'  => true,\n            ) );\n\n            if ( ! $posts ) {\n                return '';\n            }\n\n            $items = '';\n            foreach ( $posts as $post ) {\n                $items .= sprintf(\n                    '<li><span class=\"wpide-coming-soon-title\">%s</span>%s</li>',\n                    esc_html( get_the_title( $post ) ),\n                    ! empty( $attributes['showDate'] ) ? ' <time datetime=\"' . esc_attr( get_post_time( 'c', true, $post ) ) . '\">' . esc_html( get_the_date( '', $post ) ) . '</time>' : ''\n                );\n            }\n\n            return '<ul ' . get_block_wrapper_attributes( array( 'class' => 'wpide-coming-soon' ) ) . '>' . $items . '</ul>';\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Only titles and dates are shown, never content; choose how many posts to list in the block settings."
        },
        {
            "id": "block-sibling-pages",
            "version": 1,
            "title": "\"Pages in This Section\" Block",
            "description": "Registers a server-rendered block that lists the other pages sharing the current page's parent, with the current page marked, giving every section of a large site its own side navigation.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "navigation",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_script( 'wpide-block-sibling-pages', false, array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-server-side-render' ), '1.0', true );\n    wp_add_inline_script( 'wpide-block-sibling-pages', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    wp.blocks.registerBlockType( 'wpide/sibling-pages', {\n        apiVersion: 3,\n        title: 'Pages in This Section',\n        icon: 'list-view',\n        category: 'theme',\n        usesContext: [ 'postId' ],\n        edit: function ( props ) {\n            return el( 'div', wp.blockEditor.useBlockProps(),\n                el( wp.serverSideRender, { block: 'wpide/sibling-pages', urlQueryArgs: { post_id: props.context.postId || 0 } } )\n            );\n        },\n        save: function () { return null; }\n    } );\n} )( window.wp );\nJS\n    );\n\n    register_block_type( 'wpide/sibling-pages', array(\n        'api_version'           => 3,\n        'title'                 => 'Pages in This Section',\n        'category'              => 'theme',\n        'uses_context'          => array( 'postId' ),\n        'editor_script_handles' => array( 'wpide-block-sibling-pages' ),\n        'render_callback'       => function ( $attributes, $content, $block ) {\n            $page_id = ! empty( $block->context['postId'] ) ? (int) $block->context['postId'] : ( isset( $_GET['post_id'] ) ? absint( $_GET['post_id'] ) : get_the_ID() );\n            if ( ! $page_id || 'page' !== get_post_type( $page_id ) ) {\n                return '';\n            }\n\n            $parent = (int) wp_get_post_parent_id( $page_id );\n            $pages  = get_pages( array(\n                'parent'      => $parent ? $parent : $page_id,\n                'sort_column' => 'menu_order,post_title',\n            ) );\n\n            if ( ! $pages ) {\n                return '';\n            }\n\n            $items = '';\n            foreach ( $pages as $page ) {\n                $current = (int) $page->ID === $page_id;\n                $items  .= sprintf(\n                    '<li%s><a href=\"%s\"%s>%s</a></li>',\n                    $current ? ' class=\"is-current\"' : '',\n                    esc_url( get_permalink( $page ) ),\n                    $current ? ' aria-current=\"page\"' : '',\n                    esc_html( get_the_title( $page ) )\n                );\n            }\n\n            $label = $parent ? get_the_title( $parent ) : get_the_title( $page_id );\n\n            return sprintf( '<nav %s aria-label=\"%s\"><ul>%s</ul></nav>', get_block_wrapper_attributes( array( 'class' => 'wpide-sibling-pages' ) ), esc_attr( $label ), $items );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "On a top-level page it lists that page's children instead; add the block to a page template so every page in the section gets it."
        },
        {
            "id": "block-contact-card",
            "version": 1,
            "title": "Contact Card Block",
            "description": "Registers a \"Contact Card\" block, rendered on the server, where you type a name, role, phone, email and address in the sidebar and get a tidy card with tap-to-call and email links.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "contact",
                "dynamic-block"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_script( 'wpide-block-contact-card', false, array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render' ), '1.0', true );\n    wp_add_inline_script( 'wpide-block-contact-card', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var fields = [ [ 'name', 'Name' ], [ 'role', 'Role or title' ], [ 'phone', 'Phone' ], [ 'email', 'Email' ], [ 'address', 'Address' ] ];\n    var attributes = {};\n    fields.forEach( function ( f ) { attributes[ f[0] ] = { type: 'string', default: '' }; } );\n\n    wp.blocks.registerBlockType( 'wpide/contact-card', {\n        apiVersion: 3,\n        title: 'Contact Card',\n        icon: 'id',\n        category: 'widgets',\n        attributes: attributes,\n        edit: function ( props ) {\n            return el( 'div', wp.blockEditor.useBlockProps(),\n                el( wp.blockEditor.InspectorControls, null,\n                    el( wp.components.PanelBody, { title: 'Contact details' },\n                        fields.map( function ( f ) {\n                            var Control = f[0] === 'address' ? wp.components.TextareaControl : wp.components.TextControl;\n                            return el( Control, { key: f[0], label: f[1], value: props.attributes[ f[0] ], onChange: function ( v ) { var a = {}; a[ f[0] ] = v; props.setAttributes( a ); } } );\n                        } )\n                    )\n                ),\n                props.attributes.name ? el( wp.serverSideRender, { block: 'wpide/contact-card', attributes: props.attributes } ) : el( 'p', null, 'Fill in the contact details in the block settings.' )\n            );\n        },\n        save: function () { return null; }\n    } );\n} )( window.wp );\nJS\n    );\n\n    $fields = array( 'name', 'role', 'phone', 'email', 'address' );\n\n    register_block_type( 'wpide/contact-card', array(\n        'api_version'           => 3,\n        'title'                 => 'Contact Card',\n        'category'              => 'widgets',\n        'editor_script_handles' => array( 'wpide-block-contact-card' ),\n        'attributes'            => array_fill_keys( $fields, array( 'type' => 'string', 'default' => '' ) ),\n        'render_callback'       => function ( $a ) {\n            if ( empty( $a['name'] ) ) {\n                return '';\n            }\n            $html  = '<div ' . get_block_wrapper_attributes( array( 'class' => 'wpide-contact-card h-card', 'style' => 'padding:1.25em;border:1px solid currentColor;border-radius:8px' ) ) . '>';\n            $html .= '<p class=\"p-name\" style=\"margin:0;font-weight:700;font-size:1.15em\">' . esc_html( $a['name'] ) . '</p>';\n            if ( '' !== $a['role'] ) {\n                $html .= '<p class=\"p-job-title\" style=\"margin:.2em 0 .8em;opacity:.8\">' . esc_html( $a['role'] ) . '</p>';\n            }\n            if ( '' !== $a['phone'] ) {\n                $html .= '<p style=\"margin:.3em 0\"><a class=\"p-tel\" href=\"tel:' . esc_attr( preg_replace( '/[^0-9+]/', '', $a['phone'] ) ) . '\">' . esc_html( $a['phone'] ) . '</a></p>';\n            }\n            if ( is_email( $a['email'] ) ) {\n                $html .= '<p style=\"margin:.3em 0\"><a class=\"u-email\" href=\"mailto:' . esc_attr( antispambot( $a['email'] ) ) . '\">' . esc_html( antispambot( $a['email'] ) ) . '</a></p>';\n            }\n            if ( '' !== $a['address'] ) {\n                $html .= '<p class=\"p-adr\" style=\"margin:.3em 0\">' . nl2br( esc_html( $a['address'] ) ) . '</p>';\n            }\n            return $html . '</div>';\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Uses semantic h-card markup; style it with the .wpide-contact-card class."
        },
        {
            "id": "block-site-announcement",
            "version": 1,
            "title": "Site Announcement Block Managed in Settings",
            "description": "Adds an \"Announcement\" text field to Settings > General and a block that displays it, so you can place the block on many pages and change the message everywhere from one setting.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "announcements",
                "settings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    register_setting( 'general', 'wpide_announcement', array(\n        'type'              => 'string',\n        'sanitize_callback' => 'wp_kses_post',\n        'default'           => '',\n    ) );\n    add_settings_field( 'wpide_announcement', '<label for=\"wpide_announcement\">Announcement</label>', function () {\n        printf(\n            '<textarea id=\"wpide_announcement\" name=\"wpide_announcement\" rows=\"3\" class=\"large-text\">%s</textarea><p class=\"description\">Shown wherever the Announcement block is placed. Leave empty to hide it.</p>',\n            esc_textarea( (string) get_option( 'wpide_announcement', '' ) )\n        );\n    }, 'general' );\n} );\n\nadd_action( 'init', function () {\n    wp_register_script( 'wpide-block-announcement', false, array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-server-side-render' ), '1.0', true );\n    wp_add_inline_script( 'wpide-block-announcement', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    wp.blocks.registerBlockType( 'wpide/announcement', {\n        apiVersion: 3,\n        title: 'Announcement',\n        icon: 'megaphone',\n        category: 'widgets',\n        edit: function () {\n            return el( 'div', wp.blockEditor.useBlockProps(),\n                el( wp.serverSideRender, { block: 'wpide/announcement', EmptyResponsePlaceholder: function () { return el( 'p', null, 'The announcement is empty. Set it in Settings > General.' ); } } )\n            );\n        },\n        save: function () { return null; }\n    } );\n} )( window.wp );\nJS\n    );\n\n    register_block_type( 'wpide/announcement', array(\n        'api_version'           => 3,\n        'title'                 => 'Announcement',\n        'category'              => 'widgets',\n        'editor_script_handles' => array( 'wpide-block-announcement' ),\n        'render_callback'       => function () {\n            $message = trim( (string) get_option( 'wpide_announcement', '' ) );\n            if ( '' === $message ) {\n                return '';\n            }\n            return '<div ' . get_block_wrapper_attributes( array( 'class' => 'wpide-announcement', 'role' => 'note' ) ) . '>' . wpautop( wp_kses_post( $message ) ) . '</div>';\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "The block outputs nothing while the setting is empty, so you can leave it in place between announcements."
        },
        {
            "id": "bindings-author-details",
            "version": 1,
            "title": "Block Bindings Source: Author Details",
            "description": "Registers a block bindings source so paragraphs, headings, buttons and images can show the post author's name, bio, archive link or avatar, which is handy for author boxes in block themes.",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "authors",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/author', array(\n        'label'              => 'Post author',\n        'uses_context'       => array( 'postId' ),\n        'get_value_callback' => function ( $args, $block ) {\n            $post_id   = ! empty( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID();\n            $author_id = (int) get_post_field( 'post_author', $post_id );\n            if ( ! $author_id ) {\n                return null;\n            }\n\n            switch ( $args['key'] ?? 'name' ) {\n                case 'bio':\n                    return esc_html( get_the_author_meta( 'description', $author_id ) );\n                case 'url':\n                    return esc_url( get_author_posts_url( $author_id ) );\n                case 'avatar':\n                    return esc_url( get_avatar_url( $author_id, array( 'size' => 256 ) ) );\n                case 'website':\n                    return esc_url( get_the_author_meta( 'user_url', $author_id ) );\n                default:\n                    return esc_html( get_the_author_meta( 'display_name', $author_id ) );\n            }\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.5+; bind with \"source\":\"wpide/author\" and args {\"key\":\"name\"} (also bio, url, avatar) in the block's metadata.bindings."
        },
        {
            "id": "bindings-post-dates",
            "version": 1,
            "title": "Block Bindings Source: Formatted Post Dates",
            "description": "Registers a block bindings source that fills blocks with the post's published or last updated date in any format, so you can write \"Updated on ...\" in a paragraph inside block templates.",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "dates",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/dates', array(\n        'label'              => 'Post dates',\n        'uses_context'       => array( 'postId' ),\n        'get_value_callback' => function ( $args, $block ) {\n            $post = get_post( ! empty( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID() );\n            if ( ! $post ) {\n                return null;\n            }\n\n            $format = isset( $args['format'] ) ? sanitize_text_field( $args['format'] ) : get_option( 'date_format' );\n\n            switch ( $args['key'] ?? 'published' ) {\n                case 'modified':\n                    return esc_html( get_the_modified_date( $format, $post ) );\n                case 'year':\n                    return esc_html( get_the_date( 'Y', $post ) );\n                default:\n                    return esc_html( get_the_date( $format, $post ) );\n            }\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.5+; bind with \"source\":\"wpide/dates\" and args {\"key\":\"modified\",\"format\":\"F j, Y\"} (keys published, modified, year)."
        },
        {
            "id": "bindings-primary-category",
            "version": 1,
            "title": "Block Bindings Source: Main Category",
            "description": "Registers a block bindings source that shows a post's main category name, link or description in any paragraph, heading or button, for example as a label above titles in a Query Loop.",
            "category": "gutenberg",
            "tags": [
                "block-bindings",
                "categories",
                "query-loop"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! function_exists( 'register_block_bindings_source' ) ) {\n        return;\n    }\n\n    register_block_bindings_source( 'wpide/category', array(\n        'label'              => 'Main category',\n        'uses_context'       => array( 'postId' ),\n        'get_value_callback' => function ( $args, $block ) {\n            $post_id    = ! empty( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID();\n            $categories = get_the_category( $post_id );\n            if ( ! $categories ) {\n                return null;\n            }\n            $category = $categories[0];\n\n            switch ( $args['key'] ?? 'name' ) {\n                case 'link':\n                    return esc_url( get_category_link( $category ) );\n                case 'description':\n                    return esc_html( wp_strip_all_tags( category_description( $category ) ) );\n                default:\n                    return esc_html( $category->name );\n            }\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.5+; bind with \"source\":\"wpide/category\" and args {\"key\":\"name\"} (also link, description). The first category alphabetically is used."
        },
        {
            "id": "block-category-for-site-blocks",
            "version": 1,
            "title": "Your Own Block Category at the Top of the Inserter",
            "description": "Adds a block category named after your site to the top of the block inserter and moves custom blocks with a chosen namespace into it, so editors find your bespoke blocks first.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "inserter",
                "custom-blocks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Block name prefixes that belong in the category.\n$wpide_block_namespaces = array( 'wpide/' );\n\nadd_filter( 'block_categories_all', function ( $categories ) {\n    array_unshift( $categories, array(\n        'slug'  => 'site-blocks',\n        'title' => get_bloginfo( 'name' ),\n        'icon'  => null,\n    ) );\n    return $categories;\n} );\n\nadd_filter( 'register_block_type_args', function ( $args, $name ) use ( $wpide_block_namespaces ) {\n    foreach ( $wpide_block_namespaces as $prefix ) {\n        if ( 0 === strpos( $name, $prefix ) ) {\n            $args['category'] = 'site-blocks';\n        }\n    }\n    return $args;\n}, 10, 2 );",
            "requires": [],
            "notes": "Set $wpide_block_namespaces to the prefix of your blocks, such as acme/ or wpide/."
        },
        {
            "id": "editor-additional-css",
            "version": 1,
            "title": "Show Additional CSS Inside the Block Editor",
            "description": "Loads the CSS from Appearance > Customize > Additional CSS into the block editor canvas, so posts look the same while you write as they do on the site.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "css",
                "editor-styles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'block_editor_settings_all', function ( $settings ) {\n    $css = wp_get_custom_css();\n    if ( '' === trim( $css ) ) {\n        return $settings;\n    }\n\n    $settings['styles'][] = array(\n        'css'            => $css,\n        '__unstableType' => 'theme',\n        'source'         => 'wpide-additional-css',\n    );\n\n    return $settings;\n} );",
            "requires": [],
            "notes": "Rules are scoped to the editor canvas automatically; very broad selectors such as body may still need small tweaks."
        },
        {
            "id": "block-usage-report",
            "version": 1,
            "title": "Block Usage Report Under Tools",
            "description": "Adds a Tools > Block Usage screen counting how many posts and pages use each block, with a link to the content using it, so you know which blocks and plugins are safe to remove.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "audit",
                "tools"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Block Usage', 'Block Usage', 'manage_options', 'wpide-block-usage', function () {\n        global $wpdb;\n\n        if ( isset( $_GET['rescan'] ) && check_admin_referer( 'wpide_block_rescan' ) ) {\n            delete_transient( 'wpide_block_usage' );\n        }\n\n        $usage = get_transient( 'wpide_block_usage' );\n        if ( false === $usage ) {\n            $usage = array();\n            $rows  = $wpdb->get_results(\n                \"SELECT ID, post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','private','future','pending')\n                AND post_type NOT IN ('revision','attachment','nav_menu_item','customize_changeset') AND post_content LIKE '%<!-- wp:%' ORDER BY ID DESC LIMIT 2000\"\n            );\n            foreach ( $rows as $row ) {\n                preg_match_all( '/<!--\\s+wp:([a-z0-9-]+\\/)?([a-z0-9-]+)[\\s\\/]/', $row->post_content, $matches, PREG_SET_ORDER );\n                $seen = array();\n                foreach ( $matches as $match ) {\n                    $name = ( $match[1] ? $match[1] : 'core/' ) . $match[2];\n                    $usage[ $name ]['uses'] = ( $usage[ $name ]['uses'] ?? 0 ) + 1;\n                    if ( ! isset( $seen[ $name ] ) ) {\n                        $usage[ $name ]['posts'] = ( $usage[ $name ]['posts'] ?? 0 ) + 1;\n                        $usage[ $name ]['example'] = $usage[ $name ]['example'] ?? (int) $row->ID;\n                        $seen[ $name ] = true;\n                    }\n                }\n            }\n            uasort( $usage, function ( $a, $b ) {\n                return $b['posts'] <=> $a['posts'];\n            } );\n            set_transient( 'wpide_block_usage', $usage, HOUR_IN_SECONDS );\n        }\n\n        echo '<div class=\"wrap\"><h1>Block Usage</h1>';\n        printf( '<p><a class=\"button\" href=\"%s\">Rescan</a></p>', esc_url( wp_nonce_url( admin_url( 'tools.php?page=wpide-block-usage&rescan=1' ), 'wpide_block_rescan' ) ) );\n        echo '<table class=\"widefat striped\"><thead><tr><th>Block</th><th>Posts using it</th><th>Total uses</th><th>Example</th></tr></thead><tbody>';\n        foreach ( $usage as $name => $data ) {\n            $registered = WP_Block_Type_Registry::get_instance()->is_registered( $name );\n            printf(\n                '<tr><td><code>%s</code>%s</td><td>%d</td><td>%d</td><td><a href=\"%s\">%s</a></td></tr>',\n                esc_html( $name ),\n                $registered ? '' : ' <span style=\"color:#b32d2e\">(not registered)</span>',\n                (int) $data['posts'],\n                (int) $data['uses'],\n                esc_url( get_edit_post_link( $data['example'] ) ),\n                esc_html( get_the_title( $data['example'] ) ? get_the_title( $data['example'] ) : '#' . $data['example'] )\n            );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Scans up to 2,000 published and draft items and caches the result for an hour; click \"Rescan\" to refresh."
        },
        {
            "id": "synced-pattern-usage-column",
            "version": 1,
            "title": "\"Used In\" Column for Synced Patterns",
            "description": "Adds a \"Used in\" column to the Patterns list (Appearance > Editor > Patterns > Manage) showing how many posts and pages include each synced pattern, so you know what changes will affect.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "synced-patterns",
                "admin-columns"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_wp_block_posts_columns', function ( $columns ) {\n    $columns['wpide_used_in'] = 'Used in';\n    return $columns;\n} );\n\nadd_action( 'manage_wp_block_posts_custom_column', function ( $column, $pattern_id ) {\n    global $wpdb;\n\n    if ( 'wpide_used_in' !== $column ) {\n        return;\n    }\n\n    $counts = get_transient( 'wpide_synced_pattern_counts' );\n    if ( false === $counts ) {\n        $counts = array();\n        $rows   = $wpdb->get_col(\n            \"SELECT post_content FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','private','future')\n            AND post_type NOT IN ('revision','wp_block') AND post_content LIKE '%<!-- wp:block %'\"\n        );\n        foreach ( $rows as $content ) {\n            if ( preg_match_all( '/<!--\\s+wp:block\\s+\\{[^}]*\"ref\":(\\d+)/', $content, $matches ) ) {\n                foreach ( array_unique( $matches[1] ) as $ref ) {\n                    $counts[ (int) $ref ] = ( $counts[ (int) $ref ] ?? 0 ) + 1;\n                }\n            }\n        }\n        set_transient( 'wpide_synced_pattern_counts', $counts, HOUR_IN_SECONDS );\n    }\n\n    $count = (int) ( $counts[ (int) $pattern_id ] ?? 0 );\n    echo $count ? esc_html( sprintf( _n( '%d item', '%d items', $count ), $count ) ) : '<span style=\"color:#787c82\">not used</span>';\n}, 10, 2 );\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    if ( 'revision' !== $post->post_type ) {\n        delete_transient( 'wpide_synced_pattern_counts' );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Counts are cached for an hour; patterns nested inside other patterns are counted once for the outer pattern only."
        },
        {
            "id": "block-template-for-post-type",
            "version": 1,
            "title": "Default Block Template for a Custom Post Type",
            "description": "Registers a block template from code, such as \"Single Event\", that block themes use automatically for a custom post type, so new post types look right without building the template by hand.",
            "category": "gutenberg",
            "tags": [
                "block-themes",
                "templates",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post type that gets the template.\n$wpide_template_post_type = 'event';\n\nadd_action( 'init', function () use ( $wpide_template_post_type ) {\n    if ( ! function_exists( 'register_block_template' ) ) {\n        return;\n    }\n\n    $object = get_post_type_object( $wpide_template_post_type );\n\n    register_block_template( 'wpide//single-' . $wpide_template_post_type, array(\n        'title'       => 'Single ' . ( $object ? $object->labels->singular_name : ucfirst( $wpide_template_post_type ) ),\n        'description' => 'Layout for a single ' . $wpide_template_post_type . '.',\n        'content'     => '<!-- wp:template-part {\"slug\":\"header\",\"tagName\":\"header\"} /-->\n<!-- wp:group {\"tagName\":\"main\",\"layout\":{\"type\":\"constrained\"},\"style\":{\"spacing\":{\"padding\":{\"top\":\"var:preset|spacing|50\",\"bottom\":\"var:preset|spacing|50\"}}}} -->\n<main class=\"wp-block-group\">\n<!-- wp:post-title {\"level\":1} /-->\n<!-- wp:post-date /-->\n<!-- wp:post-featured-image {\"aspectRatio\":\"16/9\"} /-->\n<!-- wp:post-content {\"layout\":{\"type\":\"constrained\"}} /-->\n<!-- wp:post-terms {\"term\":\"category\"} /-->\n</main>\n<!-- /wp:group -->\n<!-- wp:template-part {\"slug\":\"footer\",\"tagName\":\"footer\"} /-->',\n    ) );\n}, 20 );",
            "requires": [],
            "notes": "Needs WordPress 6.7+ and a block theme; set the post type slug and edit the block markup. A template saved in the Site Editor overrides this one."
        },
        {
            "id": "abbreviation-format-button",
            "version": 1,
            "title": "Abbreviation Button in the Block Toolbar",
            "description": "Adds an \"Abbreviation\" option to the text formatting menu that wraps selected text such as \"SEO\" in an abbr tag with its full meaning, shown as a tooltip and read by screen readers.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "formatting",
                "accessibility"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    wp_register_script( 'wpide-abbr-format', false, array( 'wp-rich-text', 'wp-element', 'wp-block-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-abbr-format' );\n    wp_add_inline_script( 'wpide-abbr-format', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var name = 'wpide/abbr';\n\n    wp.richText.registerFormatType( name, {\n        title: 'Abbreviation',\n        tagName: 'abbr',\n        className: 'wpide-abbr',\n        attributes: { title: 'title' },\n        edit: function ( props ) {\n            return el( wp.blockEditor.RichTextToolbarButton, {\n                icon: 'editor-textcolor',\n                title: 'Abbreviation',\n                isActive: props.isActive,\n                onClick: function () {\n                    if ( props.isActive ) {\n                        props.onChange( wp.richText.removeFormat( props.value, name ) );\n                        return;\n                    }\n                    var meaning = window.prompt( 'What does this abbreviation stand for?' );\n                    if ( meaning ) {\n                        props.onChange( wp.richText.applyFormat( props.value, { type: name, attributes: { title: meaning } } ) );\n                    }\n                }\n            } );\n        }\n    } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Select the abbreviation, open the dropdown arrow in the toolbar and choose Abbreviation; click it again on marked text to remove it."
        },
        {
            "id": "convert-classic-blocks-on-open",
            "version": 1,
            "title": "Convert Classic Blocks to Regular Blocks on Open",
            "description": "Converts old \"Classic\" content into proper paragraph, heading, list and image blocks as soon as a post is opened in the block editor, then tells you so you can review before saving.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "classic-editor",
                "migration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-convert-classic', false, array( 'wp-blocks', 'wp-data', 'wp-dom-ready', 'wp-block-editor', 'wp-notices', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-convert-classic' );\n    wp_add_inline_script( 'wpide-convert-classic', <<<'JS'\nwp.domReady( function () {\n    var done = false;\n    var unsubscribe = wp.data.subscribe( function () {\n        if ( done ) { return; }\n        var editor = wp.data.select( 'core/editor' );\n        if ( ! editor || ! editor.getCurrentPostId() ) { return; }\n        var blocks = wp.data.select( 'core/block-editor' ).getBlocks();\n        if ( ! blocks.length ) { return; }\n        done = true;\n        unsubscribe();\n\n        var count = 0;\n        blocks.forEach( function ( block ) {\n            if ( block.name !== 'core/freeform' ) { return; }\n            var converted = wp.blocks.rawHandler( { HTML: wp.blocks.serialize( block ) } );\n            if ( converted.length ) {\n                wp.data.dispatch( 'core/block-editor' ).replaceBlocks( block.clientId, converted );\n                count++;\n            }\n        } );\n\n        if ( count ) {\n            wp.data.dispatch( 'core/notices' ).createInfoNotice( count + ' classic block(s) converted to blocks. Review the content, then save.', { type: 'snackbar' } );\n        }\n    } );\n} );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Nothing changes until you save; use Undo or leave without saving to keep the classic block."
        },
        {
            "id": "oversized-images-prepublish",
            "version": 1,
            "title": "Warn About Oversized Images Before Publishing",
            "description": "Lists image blocks whose original file is larger than a size you set, such as 1 MB, in the block editor's publish panel, so heavy photos get replaced before they slow down the page.",
            "category": "gutenberg",
            "tags": [
                "images",
                "performance",
                "block-editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Largest acceptable image file size in kilobytes.\n$wpide_max_image_kb = 1024;\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_max_image_kb ) {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-oversized-images', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-core-data', 'wp-editor', 'wp-block-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-oversized-images' );\n    wp_add_inline_script( 'wpide-oversized-images', 'var wpideMaxImageKb = ' . (int) $wpide_max_image_kb . ';' . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPrePublishPanel ) || ( wp.editPost && wp.editPost.PluginPrePublishPanel );\n    if ( ! Panel ) { return; }\n\n    function images( blocks, list ) {\n        blocks.forEach( function ( block ) {\n            if ( block.name === 'core/image' && block.attributes.id ) { list.push( block ); }\n            if ( block.innerBlocks && block.innerBlocks.length ) { images( block.innerBlocks, list ); }\n        } );\n        return list;\n    }\n\n    function OversizedImages() {\n        var heavy = wp.data.useSelect( function ( select ) {\n            var core = select( 'core' );\n            return images( select( 'core/block-editor' ).getBlocks(), [] ).map( function ( block ) {\n                var media = core.getMedia( block.attributes.id, { context: 'view' } );\n                var size = media && media.media_details && media.media_details.filesize;\n                return size && size > wpideMaxImageKb * 1024 ? { block: block, size: size, name: media.source_url.split( '/' ).pop() } : null;\n            } ).filter( Boolean );\n        }, [] );\n        var selectBlock = wp.data.useDispatch( 'core/block-editor' ).selectBlock;\n\n        return el( Panel, { title: heavy.length ? 'Images: ' + heavy.length + ' over ' + wpideMaxImageKb + ' KB' : 'Image sizes', initialOpen: heavy.length > 0 },\n            heavy.length\n                ? heavy.map( function ( item ) {\n                    return el( 'p', { key: item.block.clientId }, el( wp.components.Button, { variant: 'link', onClick: function () { selectBlock( item.block.clientId ); } }, item.name + ' (' + Math.round( item.size / 1024 ) + ' KB)' ) );\n                } )\n                : el( 'p', null, 'All images are a reasonable size.' )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-oversized-images', { render: OversizedImages } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Set $wpide_max_image_kb; the check reads file sizes WordPress stored at upload, so very old uploads without that data are skipped."
        },
        {
            "id": "post-id-in-editor-sidebar",
            "version": 1,
            "title": "Show the Post ID and Short Link in the Block Editor",
            "description": "Adds the post ID and a copyable short link (?p=123) to the Summary panel of the block editor sidebar, which is handy for shortcodes, support tickets and quick sharing.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "post-id",
                "sidebar"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-post-id-panel', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-post-id-panel' );\n    wp_add_inline_script( 'wpide-post-id-panel', 'var wpideHomeUrl = ' . wp_json_encode( home_url( '/' ) ) . ';' . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPostStatusInfo ) || ( wp.editPost && wp.editPost.PluginPostStatusInfo );\n    if ( ! Panel ) { return; }\n\n    function PostId() {\n        var id = wp.data.useSelect( function ( select ) { return select( 'core/editor' ).getCurrentPostId(); }, [] );\n        var copied = wp.element.useState( false );\n        if ( ! id ) { return null; }\n        var link = wpideHomeUrl + '?p=' + id;\n\n        return el( Panel, null,\n            el( 'div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' } },\n                el( 'span', null, 'ID ', el( 'strong', null, id ) ),\n                el( wp.components.Button, {\n                    variant: 'link',\n                    onClick: function () {\n                        if ( navigator.clipboard ) { navigator.clipboard.writeText( link ).then( function () { copied[1]( true ); } ); }\n                    }\n                }, copied[0] ? 'Copied!' : 'Copy short link' )\n            )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-post-id-panel', { render: PostId } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "The short link always redirects to the current address, even after the slug changes."
        },
        {
            "id": "hide-page-templates-non-admins",
            "version": 1,
            "title": "Hide Selected Page Templates From Non-Administrators",
            "description": "Removes chosen templates, such as a landing page or a blank canvas, from the template picker for editors and authors, so only administrators can apply layouts that need care.",
            "category": "gutenberg",
            "tags": [
                "templates",
                "permissions",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Templates only administrators may choose.\n$wpide_admin_only_templates = array( 'blank', 'template-landing.php', 'page-no-title' );\n\nadd_filter( 'theme_page_templates', function ( $templates ) use ( $wpide_admin_only_templates ) {\n    if ( current_user_can( 'manage_options' ) ) {\n        return $templates;\n    }\n    return array_diff_key( $templates, array_flip( $wpide_admin_only_templates ) );\n} );",
            "requires": [],
            "notes": "List template file names (classic themes, such as template-landing.php) or slugs (block themes, such as blank) in $wpide_admin_only_templates; pages already using them keep working."
        },
        {
            "id": "media-replace-file",
            "version": 1,
            "title": "Replace a Media File and Keep Its URL",
            "description": "Adds a \"Replace file\" box to the media edit screen that uploads a new version of an image, PDF or other file over the old one, keeping the same address so every link and embed updates at once.",
            "category": "media",
            "tags": [
                "media-library",
                "replace",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_attachment', function ( $post ) {\n    if ( ! current_user_can( 'edit_post', $post->ID ) ) {\n        return;\n    }\n    add_meta_box( 'wpide-replace-file', 'Replace file', function ( $post ) {\n        wp_nonce_field( 'wpide_replace_' . $post->ID, 'wpide_replace_nonce' );\n        printf(\n            '<p><label for=\"wpide-replace-input\">Upload a new %s file to replace this one:</label></p><p><input type=\"file\" id=\"wpide-replace-input\" name=\"wpide_replace_file\" accept=\"%s\"></p><p class=\"description\">Then click Update. The URL stays the same.</p>',\n            esc_html( strtoupper( pathinfo( (string) get_attached_file( $post->ID ), PATHINFO_EXTENSION ) ) ),\n            esc_attr( $post->post_mime_type )\n        );\n    }, 'attachment', 'side' );\n} );\n\n// The attachment form needs multipart encoding for file uploads.\nadd_action( 'post_edit_form_tag', function ( $post ) {\n    if ( 'attachment' === $post->post_type ) {\n        echo ' enctype=\"multipart/form-data\"';\n    }\n} );\n\nadd_action( 'edit_attachment', function ( $attachment_id ) {\n    if ( empty( $_FILES['wpide_replace_file']['tmp_name'] ) || ! isset( $_POST['wpide_replace_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_replace_nonce'] ), 'wpide_replace_' . $attachment_id ) || ! current_user_can( 'edit_post', $attachment_id ) ) {\n        return;\n    }\n\n    $upload = $_FILES['wpide_replace_file']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput\n    $old    = get_attached_file( $attachment_id );\n    $check  = wp_check_filetype_and_ext( $upload['tmp_name'], $upload['name'] );\n\n    if ( UPLOAD_ERR_OK !== (int) $upload['error'] || ! $check['type'] || $check['type'] !== get_post_mime_type( $attachment_id ) || ! is_uploaded_file( $upload['tmp_name'] ) ) {\n        set_transient( 'wpide_replace_error_' . get_current_user_id(), 'The new file must be a valid ' . get_post_mime_type( $attachment_id ) . ' file.', 60 );\n        return;\n    }\n\n    // Remove the old intermediate sizes before writing the new original.\n    $meta = wp_get_attachment_metadata( $attachment_id );\n    if ( ! empty( $meta['sizes'] ) ) {\n        foreach ( $meta['sizes'] as $size ) {\n            wp_delete_file( path_join( dirname( $old ), $size['file'] ) );\n        }\n    }\n    if ( ! empty( $meta['original_image'] ) ) {\n        $old = path_join( dirname( $old ), $meta['original_image'] );\n        update_attached_file( $attachment_id, $old );\n    }\n\n    if ( move_uploaded_file( $upload['tmp_name'], $old ) ) {\n        require_once ABSPATH . 'wp-admin/includes/image.php';\n        wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $old ) );\n        clean_post_cache( $attachment_id );\n    }\n} );\n\nadd_action( 'admin_notices', function () {\n    $key = 'wpide_replace_error_' . get_current_user_id();\n    if ( $message = get_transient( $key ) ) {\n        delete_transient( $key );\n        printf( '<div class=\"notice notice-error\"><p>%s</p></div>', esc_html( $message ) );\n    }\n} );",
            "requires": [],
            "notes": "The new file must be the same type (for example JPEG for JPEG); image sizes are regenerated automatically. Browsers and CDNs may show the old file until their cache expires."
        },
        {
            "id": "media-where-is-it-used",
            "version": 1,
            "title": "\"Where Is This File Used?\" Box in the Media Library",
            "description": "Adds a box to the media edit screen listing every post and page that shows the file in its content or as its featured image, so you can check before replacing or deleting it.",
            "category": "media",
            "tags": [
                "media-library",
                "usage",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_attachment', function () {\n    add_meta_box( 'wpide-media-usage', 'Where this file is used', function ( $attachment ) {\n        global $wpdb;\n\n        $file = wp_basename( (string) get_attached_file( $attachment->ID ) );\n        $name = preg_replace( '/(-scaled|-rotated)?\\.[a-z0-9]+$/i', '', $file );\n\n        $in_content = $name ? $wpdb->get_col( $wpdb->prepare(\n            \"SELECT ID FROM {$wpdb->posts} WHERE post_status IN ('publish','draft','future','private','pending') AND post_type NOT IN ('revision','attachment')\n            AND ( post_content LIKE %s OR post_content LIKE %s ) LIMIT 50\",\n            '%' . $wpdb->esc_like( $name ) . '%',\n            '%wp-image-' . (int) $attachment->ID . '\"%'\n        ) ) : array();\n\n        $as_featured = $wpdb->get_col( $wpdb->prepare(\n            \"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_thumbnail_id' AND meta_value = %d LIMIT 50\",\n            $attachment->ID\n        ) );\n\n        if ( ! $in_content && ! $as_featured ) {\n            echo '<p>Not found in any content or as a featured image.</p>';\n            return;\n        }\n\n        echo '<ul>';\n        foreach ( array_unique( array_merge( $as_featured, $in_content ) ) as $post_id ) {\n            $labels = array();\n            if ( in_array( $post_id, $as_featured, false ) ) {\n                $labels[] = 'featured image';\n            }\n            if ( in_array( $post_id, $in_content, false ) ) {\n                $labels[] = 'in content';\n            }\n            printf(\n                '<li><a href=\"%s\">%s</a> <small>(%s, %s)</small></li>',\n                esc_url( get_edit_post_link( $post_id ) ),\n                esc_html( get_the_title( $post_id ) ? get_the_title( $post_id ) : '#' . $post_id ),\n                esc_html( get_post_type( $post_id ) ),\n                esc_html( implode( ', ', $labels ) )\n            );\n        }\n        echo '</ul>';\n    }, 'attachment', 'side', 'low' );\n} );",
            "requires": [],
            "notes": "Searches content for the file name and the image block ID; files used by page builders that store data elsewhere may not be found."
        },
        {
            "id": "media-unused-files-report",
            "version": 1,
            "title": "Unused Media Files Report",
            "description": "Adds a Media > Unused Files screen listing uploads that are not attached to any content, not used as a featured image and not found in any post, so you can review and tidy the Media Library.",
            "category": "media",
            "tags": [
                "media-library",
                "cleanup",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_media_page( 'Unused Files', 'Unused Files', 'upload_files', 'wpide-unused-media', function () {\n        global $wpdb;\n\n        $paged    = isset( $_GET['paged'] ) ? max( 1, absint( $_GET['paged'] ) ) : 1;\n        $per_page = 40;\n\n        // Unattached files that are not a featured image anywhere.\n        $candidates = $wpdb->get_results( $wpdb->prepare(\n            \"SELECT p.ID, p.post_title, p.post_date FROM {$wpdb->posts} p\n            WHERE p.post_type = 'attachment' AND p.post_parent = 0\n            AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} m WHERE m.meta_key = '_thumbnail_id' AND m.meta_value = p.ID )\n            ORDER BY p.ID DESC LIMIT %d OFFSET %d\",\n            $per_page,\n            ( $paged - 1 ) * $per_page\n        ) );\n\n        $unused = array();\n        foreach ( $candidates as $file ) {\n            $name = preg_replace( '/(-scaled|-rotated)?\\.[a-z0-9]+$/i', '', wp_basename( (string) get_attached_file( $file->ID ) ) );\n            $used = $name && $wpdb->get_var( $wpdb->prepare(\n                \"SELECT ID FROM {$wpdb->posts} WHERE post_type NOT IN ('revision','attachment') AND post_status <> 'trash' AND post_content LIKE %s LIMIT 1\",\n                '%' . $wpdb->esc_like( $name ) . '%'\n            ) );\n            if ( ! $used && (int) get_option( 'site_icon' ) !== (int) $file->ID && (int) get_theme_mod( 'custom_logo' ) !== (int) $file->ID ) {\n                $unused[] = $file;\n            }\n        }\n\n        echo '<div class=\"wrap\"><h1>Unused Files</h1><p>Files below are not attached, not a featured image, not the logo or site icon, and not mentioned in any content.</p>';\n        if ( ! $unused ) {\n            echo '<p>No unused files on this page.</p>';\n        } else {\n            echo '<table class=\"widefat striped\"><thead><tr><th style=\"width:70px\">File</th><th>Title</th><th>Uploaded</th></tr></thead><tbody>';\n            foreach ( $unused as $file ) {\n                printf(\n                    '<tr><td>%s</td><td><a href=\"%s\">%s</a></td><td>%s</td></tr>',\n                    wp_get_attachment_image( $file->ID, array( 60, 60 ), true ),\n                    esc_url( get_edit_post_link( $file->ID ) ),\n                    esc_html( $file->post_title ),\n                    esc_html( mysql2date( get_option( 'date_format' ), $file->post_date ) )\n                );\n            }\n            echo '</tbody></table>';\n        }\n        if ( count( $candidates ) === $per_page ) {\n            printf( '<p><a class=\"button\" href=\"%s\">Next page</a></p>', esc_url( add_query_arg( 'paged', $paged + 1 ) ) );\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Nothing is deleted automatically; check each file before removing it, as themes, widgets and page builders can use files in ways this report cannot see."
        },
        {
            "id": "media-svg-previews",
            "version": 1,
            "title": "Show SVG Previews and Dimensions in the Media Library",
            "description": "Makes uploaded SVG files show a real preview in the Media Library grid and attachment details, and stores their width and height from the viewBox so they display at the right size in posts.",
            "category": "media",
            "tags": [
                "svg",
                "media-library",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_generate_attachment_metadata', function ( $metadata, $attachment_id ) {\n    if ( 'image/svg+xml' !== get_post_mime_type( $attachment_id ) ) {\n        return $metadata;\n    }\n\n    $file = get_attached_file( $attachment_id );\n    $svg  = $file && is_readable( $file ) ? (string) file_get_contents( $file, false, null, 0, 64 * KB_IN_BYTES ) : '';\n\n    if ( preg_match( '/viewBox=[\"\\']\\s*[-\\d.]+[\\s,]+[-\\d.]+[\\s,]+([\\d.]+)[\\s,]+([\\d.]+)/i', $svg, $box ) ) {\n        $metadata['width']  = (int) round( (float) $box[1] );\n        $metadata['height'] = (int) round( (float) $box[2] );\n    } elseif ( preg_match( '/<svg[^>]*\\swidth=[\"\\']([\\d.]+)[^>]*\\sheight=[\"\\']([\\d.]+)/i', $svg, $size ) ) {\n        $metadata['width']  = (int) $size[1];\n        $metadata['height'] = (int) $size[2];\n    }\n    $metadata['file'] = _wp_relative_upload_path( $file );\n\n    return $metadata;\n}, 10, 2 );\n\nadd_filter( 'wp_prepare_attachment_for_js', function ( $response ) {\n    if ( 'image/svg+xml' !== ( $response['mime'] ?? '' ) ) {\n        return $response;\n    }\n\n    $full = array(\n        'url'         => $response['url'],\n        'width'       => (int) ( $response['width'] ?? 0 ),\n        'height'      => (int) ( $response['height'] ?? 0 ),\n        'orientation' => ( $response['height'] ?? 0 ) > ( $response['width'] ?? 0 ) ? 'portrait' : 'landscape',\n    );\n    $response['image'] = array( 'src' => $response['url'] );\n    $response['sizes'] = array( 'full' => $full, 'medium' => $full, 'thumbnail' => $full );\n\n    return $response;\n} );\n\nadd_action( 'admin_head', function () {\n    echo '<style>.attachment-preview img[src$=\".svg\"],.media-modal img[src$=\".svg\"],td.media-icon img[src$=\".svg\"]{width:100%;height:auto;padding:8px;box-sizing:border-box}</style>';\n} );",
            "requires": [],
            "notes": "Only affects SVG files you are already allowed to upload; it does not enable SVG uploads by itself."
        },
        {
            "id": "media-webp-avif-quality",
            "version": 1,
            "title": "Set WebP and AVIF Image Quality",
            "description": "Sets the compression quality WordPress uses when it creates WebP and AVIF images, separately from JPEG, so you can trade a little sharpness for much smaller files.",
            "category": "media",
            "tags": [
                "webp",
                "avif",
                "image-quality"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Quality from 1 (smallest) to 100 (best).\n$wpide_webp_quality = 75;\n$wpide_avif_quality = 60;\n\nadd_filter( 'wp_editor_set_quality', function ( $quality, $mime_type ) use ( $wpide_webp_quality, $wpide_avif_quality ) {\n    if ( 'image/webp' === $mime_type ) {\n        return $wpide_webp_quality;\n    }\n    if ( 'image/avif' === $mime_type ) {\n        return $wpide_avif_quality;\n    }\n    return $quality;\n}, 10, 2 );",
            "requires": [],
            "notes": "Applies to newly generated images only; regenerate thumbnails to update existing ones. WordPress's default is 86 for WebP."
        },
        {
            "id": "media-quality-by-image-size",
            "version": 1,
            "title": "Lower Quality for Small Thumbnails, Higher for Large Images",
            "description": "Saves small thumbnails with stronger compression and large images with higher quality, so grids load fast while hero images stay crisp, instead of one quality for everything.",
            "category": "media",
            "tags": [
                "image-quality",
                "thumbnails",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Width threshold and the two quality levels.\n$wpide_small_size_px = 400;\n$wpide_small_quality = 70;\n$wpide_large_quality = 88;\n\nadd_filter( 'wp_editor_set_quality', function ( $quality, $mime_type, $size = array() ) use ( $wpide_small_size_px, $wpide_small_quality, $wpide_large_quality ) {\n    if ( ! in_array( $mime_type, array( 'image/jpeg', 'image/webp' ), true ) || empty( $size['width'] ) ) {\n        return $quality;\n    }\n\n    return (int) $size['width'] <= $wpide_small_size_px ? $wpide_small_quality : $wpide_large_quality;\n}, 10, 3 );",
            "requires": [],
            "notes": "Needs WordPress 6.8+, which tells the quality filter which size is being made; applies to JPEG and WebP images created from now on."
        },
        {
            "id": "media-date-prefix-file-names",
            "version": 1,
            "title": "Add the Date to Uploaded File Names",
            "description": "Prefixes uploaded file names with the upload date, such as 2025-03-14-team-photo.jpg, so files sort chronologically on the server and in downloads.",
            "category": "media",
            "tags": [
                "uploads",
                "file-names",
                "organization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Date format used as the prefix.\n$wpide_upload_date_format = 'Y-m-d';\n\nadd_filter( 'wp_handle_upload_prefilter', function ( $file ) use ( $wpide_upload_date_format ) {\n    if ( preg_match( '/^\\d{4}-?\\d{2}-?\\d{2}/', $file['name'] ) ) {\n        return $file;\n    }\n    $file['name'] = wp_date( $wpide_upload_date_format ) . '-' . $file['name'];\n    return $file;\n}, 5 );",
            "requires": [],
            "notes": "Change $wpide_upload_date_format, for example to Ymd; files that already start with a date are left alone."
        },
        {
            "id": "media-folders-by-post-type",
            "version": 1,
            "title": "Store Uploads in Folders Named After the Post Type",
            "description": "Saves files uploaded from inside a post, page or custom post into a folder named after its type, such as uploads/products/ or uploads/pages/, keeping the year and month folders inside.",
            "category": "media",
            "tags": [
                "uploads",
                "folders",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_post_type_folder = function ( $dirs ) {\n    $post_id = isset( $_REQUEST['post_id'] ) ? absint( $_REQUEST['post_id'] ) : ( isset( $_REQUEST['post'] ) ? absint( $_REQUEST['post'] ) : 0 );\n    if ( ! $post_id ) {\n        return $dirs;\n    }\n\n    $type = get_post_type( $post_id );\n    if ( ! $type || 'attachment' === $type ) {\n        return $dirs;\n    }\n\n    $folder = '/' . sanitize_title( get_post_type_object( $type )->labels->name );\n    if ( 0 === strpos( $dirs['subdir'], $folder ) ) {\n        return $dirs;\n    }\n\n    $dirs['subdir'] = $folder . $dirs['subdir'];\n    $dirs['path']   = $dirs['basedir'] . $dirs['subdir'];\n    $dirs['url']    = $dirs['baseurl'] . $dirs['subdir'];\n\n    return $dirs;\n};\n\n// Only change the folder while a file is being uploaded.\n$wpide_folder_on = function ( $file ) use ( $wpide_post_type_folder ) {\n    add_filter( 'upload_dir', $wpide_post_type_folder );\n    return $file;\n};\n$wpide_folder_off = function ( $upload ) use ( $wpide_post_type_folder ) {\n    remove_filter( 'upload_dir', $wpide_post_type_folder );\n    return $upload;\n};\nadd_filter( 'wp_handle_upload_prefilter', $wpide_folder_on );\nadd_filter( 'wp_handle_sideload_prefilter', $wpide_folder_on );\nadd_filter( 'wp_handle_upload', $wpide_folder_off );\nadd_filter( 'wp_handle_sideload', $wpide_folder_off );",
            "requires": [],
            "notes": "Only affects new uploads made from the editor; files uploaded directly to the Media Library keep the default folders."
        },
        {
            "id": "media-crop-thumbnails-top",
            "version": 1,
            "title": "Crop Thumbnails From the Top Instead of the Center",
            "description": "Makes WordPress crop square thumbnails from the top of each image rather than the middle, so heads in portraits and headlines in screenshots are not cut off.",
            "category": "media",
            "tags": [
                "thumbnails",
                "cropping",
                "image-sizes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Cropped image sizes that should keep the top of the image.\n$wpide_top_crop_sizes = array( 'thumbnail' );\n\nadd_action( 'after_setup_theme', function () use ( $wpide_top_crop_sizes ) {\n    foreach ( $wpide_top_crop_sizes as $size ) {\n        $sizes = wp_get_registered_image_subsizes();\n        if ( empty( $sizes[ $size ] ) || empty( $sizes[ $size ]['crop'] ) ) {\n            continue;\n        }\n        add_image_size( $size, $sizes[ $size ]['width'], $sizes[ $size ]['height'], array( 'center', 'top' ) );\n    }\n}, 100 );",
            "requires": [],
            "notes": "Applies to new uploads; regenerate thumbnails to update existing images. Also works for other cropped sizes listed in $wpide_top_crop_sizes."
        },
        {
            "id": "media-default-align-center",
            "version": 1,
            "title": "Center Inserted Images by Default",
            "description": "Makes images inserted in the classic editor and the media window centered by default instead of having no alignment, which suits most single-column blogs.",
            "category": "media",
            "tags": [
                "images",
                "alignment",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Default alignment for inserted images.\n$wpide_image_align = 'center';\n\nadd_filter( 'pre_option_image_default_align', function () use ( $wpide_image_align ) {\n    return $wpide_image_align;\n} );",
            "requires": [],
            "notes": "Change $wpide_image_align to left, right or none."
        },
        {
            "id": "media-library-list-view-default",
            "version": 1,
            "title": "Open the Media Library in List View",
            "description": "Shows Media > Library as a table by default instead of the thumbnail grid, which makes file names, dates, authors and attachment details easier to scan on large libraries.",
            "category": "media",
            "tags": [
                "media-library",
                "list-view",
                "admin"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'get_user_option_media_library_mode', function ( $mode ) {\n    return isset( $_GET['mode'] ) && in_array( $_GET['mode'], array( 'grid', 'list' ), true ) ? sanitize_key( $_GET['mode'] ) : 'list';\n} );",
            "requires": [],
            "notes": "Users can still switch to the grid with the icons above the list; the default only applies when no mode is chosen in the address."
        },
        {
            "id": "media-camera-details-box",
            "version": 1,
            "title": "Camera Details Box for Photos",
            "description": "Adds a \"Camera details\" box to the media edit screen showing the camera, lens settings, ISO, shutter speed, focal length and date taken that WordPress read from the photo.",
            "category": "media",
            "tags": [
                "exif",
                "photography",
                "media-library"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_attachment', function ( $post ) {\n    if ( ! wp_attachment_is_image( $post ) ) {\n        return;\n    }\n    add_meta_box( 'wpide-camera-details', 'Camera details', function ( $post ) {\n        $meta = wp_get_attachment_metadata( $post->ID );\n        $info = $meta['image_meta'] ?? array();\n\n        $rows = array(\n            'Camera'        => $info['camera'] ?? '',\n            'Aperture'      => ! empty( $info['aperture'] ) ? 'f/' . $info['aperture'] : '',\n            'Shutter speed' => ! empty( $info['shutter_speed'] ) ? ( $info['shutter_speed'] < 1 ? '1/' . round( 1 / $info['shutter_speed'] ) : $info['shutter_speed'] ) . ' s' : '',\n            'ISO'           => $info['iso'] ?? '',\n            'Focal length'  => ! empty( $info['focal_length'] ) ? $info['focal_length'] . ' mm' : '',\n            'Taken'         => ! empty( $info['created_timestamp'] ) ? wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $info['created_timestamp'] ) : '',\n            'Copyright'     => $info['copyright'] ?? '',\n        );\n        $rows = array_filter( $rows, 'strlen' );\n\n        if ( ! $rows ) {\n            echo '<p>No camera data stored for this image.</p>';\n            return;\n        }\n        echo '<table class=\"widefat striped\"><tbody>';\n        foreach ( $rows as $label => $value ) {\n            printf( '<tr><th scope=\"row\">%s</th><td>%s</td></tr>', esc_html( $label ), esc_html( $value ) );\n        }\n        echo '</tbody></table>';\n    }, 'attachment', 'side', 'low' );\n} );",
            "requires": [],
            "notes": "Only shows values that were in the file at upload time; photos stripped of metadata show nothing."
        },
        {
            "id": "media-fill-alt-from-library-on-output",
            "version": 1,
            "title": "Use Media Library Alt Text for Images Missing It in Posts",
            "description": "When an image in a post has an empty alt attribute but its Media Library entry has alt text, the library text is used on the page, fixing older posts without editing them.",
            "category": "media",
            "tags": [
                "alt-text",
                "accessibility",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'wp_content_img_tag', function ( $image, $context, $attachment_id ) {\n    if ( ! $attachment_id || ! preg_match( '/\\salt=([\"\\'])\\s*\\1/', $image ) ) {\n        return $image;\n    }\n\n    $alt = trim( (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) );\n    if ( '' === $alt ) {\n        return $image;\n    }\n\n    return preg_replace( '/\\salt=([\"\\'])\\s*\\1/', ' alt=\"' . esc_attr( $alt ) . '\"', $image, 1 );\n}, 10, 3 );",
            "requires": [],
            "notes": "Images deliberately left with empty alt text are filled too if the library has a description; clear the library alt text for decorative images."
        },
        {
            "id": "media-regenerate-one-image",
            "version": 1,
            "title": "\"Regenerate Sizes\" Link for a Single Image",
            "description": "Adds a \"Regenerate sizes\" link under each image in the Media Library list that rebuilds all its thumbnails, useful after changing image sizes or fixing a bad crop, without a plugin.",
            "category": "media",
            "tags": [
                "thumbnails",
                "regenerate",
                "image-sizes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'media_row_actions', function ( $actions, $post ) {\n    if ( wp_attachment_is_image( $post ) && current_user_can( 'edit_post', $post->ID ) ) {\n        $actions['wpide_regen'] = sprintf(\n            '<a href=\"%s\">Regenerate sizes</a>',\n            esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_regenerate_image&attachment=' . $post->ID ), 'wpide_regen_' . $post->ID ) )\n        );\n    }\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'admin_post_wpide_regenerate_image', function () {\n    $id = isset( $_GET['attachment'] ) ? absint( $_GET['attachment'] ) : 0;\n    check_admin_referer( 'wpide_regen_' . $id );\n    if ( ! current_user_can( 'edit_post', $id ) || ! wp_attachment_is_image( $id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    $file = wp_get_original_image_path( $id );\n    $ok   = false;\n    if ( $file && file_exists( $file ) ) {\n        require_once ABSPATH . 'wp-admin/includes/image.php';\n        $meta = wp_generate_attachment_metadata( $id, $file );\n        $ok   = ! empty( $meta ) && ! is_wp_error( $meta ) && wp_update_attachment_metadata( $id, $meta );\n    }\n\n    wp_safe_redirect( add_query_arg( 'wpide_regen', $ok ? 'done' : 'failed', admin_url( 'upload.php?mode=list' ) ) );\n    exit;\n} );\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_regen'] ) ) {\n        echo 'done' === $_GET['wpide_regen']\n            ? '<div class=\"notice notice-success is-dismissible\"><p>Image sizes regenerated.</p></div>'\n            : '<div class=\"notice notice-error is-dismissible\"><p>Could not regenerate this image: the original file is missing.</p></div>';\n    }\n} );",
            "requires": [],
            "notes": "Old size files that are no longer registered stay on disk; for whole-library runs use \"wp media regenerate\" in WP-CLI."
        },
        {
            "id": "media-generated-sizes-box",
            "version": 1,
            "title": "List the Generated Sizes of Each Image",
            "description": "Adds a box to the image edit screen listing every size WordPress generated for it, with dimensions, file size and a link to open each one, to check crops and spot missing sizes.",
            "category": "media",
            "tags": [
                "image-sizes",
                "thumbnails",
                "media-library"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'add_meta_boxes_attachment', function ( $post ) {\n    if ( ! wp_attachment_is_image( $post ) ) {\n        return;\n    }\n    add_meta_box( 'wpide-image-sizes', 'Generated sizes', function ( $post ) {\n        $meta  = wp_get_attachment_metadata( $post->ID );\n        $sizes = $meta['sizes'] ?? array();\n        $base  = trailingslashit( dirname( (string) wp_get_attachment_url( $post->ID ) ) );\n\n        if ( ! $sizes ) {\n            echo '<p>No extra sizes were generated.</p>';\n            return;\n        }\n\n        echo '<table class=\"widefat striped\"><thead><tr><th>Size</th><th>Dimensions</th><th>File size</th></tr></thead><tbody>';\n        foreach ( $sizes as $name => $size ) {\n            printf(\n                '<tr><td><a href=\"%s\" target=\"_blank\" rel=\"noopener\">%s</a></td><td>%d &times; %d</td><td>%s</td></tr>',\n                esc_url( $base . $size['file'] ),\n                esc_html( $name ),\n                (int) $size['width'],\n                (int) $size['height'],\n                ! empty( $size['filesize'] ) ? esc_html( size_format( $size['filesize'] ) ) : '&ndash;'\n            );\n        }\n        echo '</tbody></table>';\n        $missing = array_diff( array_keys( wp_get_registered_image_subsizes() ), array_keys( $sizes ) );\n        if ( $missing ) {\n            echo '<p class=\"description\">Not generated: ' . esc_html( implode( ', ', $missing ) ) . ' (the original may be too small).</p>';\n        }\n    }, 'attachment', 'normal', 'low' );\n} );",
            "requires": [],
            "notes": "Sizes registered after the image was uploaded will be missing until you regenerate it."
        },
        {
            "id": "media-search-alt-text",
            "version": 1,
            "title": "Search Media by Alt Text Too",
            "description": "Makes the Media Library search, in both the grid and list views and the media picker, also match images by their alt text, which often describes the image better than the file name.",
            "category": "media",
            "tags": [
                "media-library",
                "search",
                "alt-text"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'posts_search', function ( $search, $query ) {\n    global $wpdb, $pagenow;\n\n    $is_media_screen = 'upload.php' === $pagenow || ( wp_doing_ajax() && isset( $_REQUEST['action'] ) && 'query-attachments' === $_REQUEST['action'] );\n    if ( ! is_admin() || '' === $search || ! $is_media_screen || 'attachment' !== $query->get( 'post_type' ) ) {\n        return $search;\n    }\n\n    $term = trim( (string) $query->get( 's' ) );\n    if ( '' === $term ) {\n        return $search;\n    }\n\n    $match = $wpdb->prepare(\n        \"{$wpdb->posts}.ID IN ( SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_wp_attachment_image_alt' AND meta_value LIKE %s )\",\n        '%' . $wpdb->esc_like( $term ) . '%'\n    );\n\n    return preg_replace( '/^\\s*AND\\s*/', ' AND ( ( ', $search, 1 ) . \" ) OR $match )\";\n}, 10, 2 );",
            "requires": [],
            "notes": "Only adds matches; normal title, caption and file name searches keep working."
        },
        {
            "id": "media-shorten-long-file-names",
            "version": 1,
            "title": "Shorten Very Long Upload File Names",
            "description": "Trims uploaded file names longer than 60 characters, such as camera exports or copied web titles, to a tidy length before saving, while keeping the extension.",
            "category": "media",
            "tags": [
                "uploads",
                "file-names",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Longest allowed name, without the extension.\n$wpide_max_file_name_length = 60;\n\nadd_filter( 'sanitize_file_name', function ( $filename ) use ( $wpide_max_file_name_length ) {\n    $info = pathinfo( $filename );\n    $name = $info['filename'] ?? $filename;\n\n    if ( mb_strlen( $name ) <= $wpide_max_file_name_length ) {\n        return $filename;\n    }\n\n    $name = rtrim( mb_substr( $name, 0, $wpide_max_file_name_length ), '-_.' );\n\n    return $name . ( ! empty( $info['extension'] ) ? '.' . $info['extension'] : '' );\n}, 20 );",
            "requires": [],
            "notes": "Change $wpide_max_file_name_length; WordPress still adds -1, -2 when a name is already taken."
        },
        {
            "id": "cli-delete-meta-key",
            "version": 1,
            "title": "Delete a Custom Field From Every Post (WP-CLI)",
            "description": "Adds a \"wp delete-meta-key\" command that removes one custom field key from all posts of a type, for example leftovers from an old SEO or page builder plugin, with a count before anything is deleted.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "custom-fields",
                "cleanup"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'delete-meta-key', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $key       = $args[0];\n    $post_type = sanitize_key( $assoc_args['post_type'] ?? 'any' );\n    $type_sql  = 'any' === $post_type ? '' : $wpdb->prepare( ' AND p.post_type = %s', $post_type );\n\n    $count = (int) $wpdb->get_var( $wpdb->prepare(\n        \"SELECT COUNT(*) FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s\" . $type_sql,\n        $key\n    ) );\n\n    if ( ! $count ) {\n        WP_CLI::success( sprintf( 'No \"%s\" fields found.', $key ) );\n        return;\n    }\n\n    if ( empty( $assoc_args['yes'] ) ) {\n        WP_CLI::log( sprintf( '%d \"%s\" field rows would be deleted. Add --yes to delete them.', $count, $key ) );\n        return;\n    }\n\n    $post_ids = $wpdb->get_col( $wpdb->prepare(\n        \"SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id WHERE pm.meta_key = %s\" . $type_sql,\n        $key\n    ) );\n    foreach ( $post_ids as $post_id ) {\n        delete_post_meta( (int) $post_id, $key );\n    }\n\n    WP_CLI::success( sprintf( 'Deleted \"%s\" from %d posts.', $key, count( $post_ids ) ) );\n}, array(\n    'shortdesc' => 'Deletes a custom field key from all posts.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'key', 'description' => 'Exact meta key.' ),\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'default' => 'any' ),\n        array( 'type' => 'flag', 'name' => 'yes', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp delete-meta-key _old_plugin_data --post_type=page --yes\"; back up the database first."
        },
        {
            "id": "cli-media-size-report",
            "version": 1,
            "title": "Media Library Disk Usage by Type and Year (WP-CLI)",
            "description": "Adds a \"wp media-usage\" command that totals how much disk space uploads use, grouped by file type or by upload year, including every generated image size, to see what fills your hosting space.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "media",
                "disk-space"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'media-usage', function ( $args, $assoc_args ) {\n    $by     = ( $assoc_args['by'] ?? 'type' ) === 'year' ? 'year' : 'type';\n    $totals = array();\n    $paged  = 1;\n\n    do {\n        $ids = get_posts( array(\n            'post_type'      => 'attachment',\n            'post_status'    => 'inherit',\n            'posts_per_page' => 500,\n            'paged'          => $paged++,\n            'fields'         => 'ids',\n            'no_found_rows'  => true,\n        ) );\n\n        foreach ( $ids as $id ) {\n            $file  = get_attached_file( $id );\n            $bytes = $file && file_exists( $file ) ? filesize( $file ) : 0;\n            $meta  = wp_get_attachment_metadata( $id );\n            foreach ( (array) ( $meta['sizes'] ?? array() ) as $size ) {\n                $path   = $file ? path_join( dirname( $file ), $size['file'] ) : '';\n                $bytes += $path && file_exists( $path ) ? filesize( $path ) : 0;\n            }\n            if ( ! empty( $meta['original_image'] ) && $file ) {\n                $original = path_join( dirname( $file ), $meta['original_image'] );\n                $bytes   += file_exists( $original ) ? filesize( $original ) : 0;\n            }\n\n            $group = 'year' === $by ? get_the_date( 'Y', $id ) : ( get_post_mime_type( $id ) ? get_post_mime_type( $id ) : 'unknown' );\n            $totals[ $group ]['files'] = ( $totals[ $group ]['files'] ?? 0 ) + 1;\n            $totals[ $group ]['bytes'] = ( $totals[ $group ]['bytes'] ?? 0 ) + $bytes;\n        }\n    } while ( count( $ids ) === 500 );\n\n    uasort( $totals, function ( $a, $b ) {\n        return $b['bytes'] <=> $a['bytes'];\n    } );\n\n    $rows = array();\n    foreach ( $totals as $group => $data ) {\n        $rows[] = array( $by => $group, 'files' => $data['files'], 'size' => size_format( $data['bytes'], 1 ) );\n    }\n\n    if ( ! $rows ) {\n        WP_CLI::success( 'The Media Library is empty.' );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( $by, 'files', 'size' ) );\n}, array(\n    'shortdesc' => 'Shows Media Library disk usage grouped by type or year.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'by', 'optional' => true, 'default' => 'type', 'options' => array( 'type', 'year' ) ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Run \"wp media-usage --by=year\" or \"--by=type\" (default); it reads file sizes from disk, so large libraries take a moment."
        },
        {
            "id": "cli-largest-meta-values",
            "version": 1,
            "title": "Find the Largest Custom Field Values (WP-CLI)",
            "description": "Adds a \"wp largest-meta\" command listing the biggest post, user and term meta rows with their key and owner, the quickest way to find plugins bloating the database with huge serialized data.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "database",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'largest-meta', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $tables = array(\n        'post' => array( $wpdb->postmeta, 'post_id' ),\n        'user' => array( $wpdb->usermeta, 'user_id' ),\n        'term' => array( $wpdb->termmeta, 'term_id' ),\n    );\n    $which = $assoc_args['table'] ?? 'post';\n    if ( ! isset( $tables[ $which ] ) ) {\n        WP_CLI::error( 'Use --table=post, user or term.' );\n    }\n    list( $table, $owner ) = $tables[ $which ];\n    $limit = max( 1, min( 200, (int) ( $assoc_args['limit'] ?? 15 ) ) );\n\n    $rows = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT $owner AS owner_id, meta_key, LENGTH(meta_value) AS bytes FROM $table ORDER BY bytes DESC LIMIT %d\",\n        $limit\n    ), ARRAY_A );\n\n    foreach ( $rows as &$row ) {\n        $row['size'] = size_format( (int) $row['bytes'], 1 );\n        unset( $row['bytes'] );\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'owner_id', 'meta_key', 'size' ) );\n}, array(\n    'shortdesc' => 'Lists the largest meta rows.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'table', 'optional' => true, 'default' => 'post', 'options' => array( 'post', 'user', 'term' ) ),\n        array( 'type' => 'assoc', 'name' => 'limit', 'optional' => true, 'default' => 15 ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp largest-meta --table=user --limit=20\"; read-only."
        },
        {
            "id": "cli-rename-shortcode",
            "version": 1,
            "title": "Rename a Shortcode Across All Content (WP-CLI)",
            "description": "Adds a \"wp rename-shortcode\" command that changes every use of one shortcode tag to another in posts and pages, attributes and closing tags included, useful when switching plugins.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "shortcodes",
                "migration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'rename-shortcode', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    list( $old, $new ) = array_map( 'sanitize_key', $args );\n    if ( ! $old || ! $new || $old === $new ) {\n        WP_CLI::error( 'Give two different shortcode tags.' );\n    }\n\n    // Matches [old ...], [old] and [/old] but not [old_more].\n    $pattern = '/\\[(\\/?)' . preg_quote( $old, '/' ) . '(?=[\\s\\]\\/])/';\n    $rows    = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT ID, post_content FROM {$wpdb->posts} WHERE post_type NOT IN ('revision') AND post_status NOT IN ('trash','auto-draft') AND post_content LIKE %s\",\n        '%[' . $wpdb->esc_like( $old ) . '%'\n    ) );\n\n    $changed = 0;\n    foreach ( $rows as $row ) {\n        $content = preg_replace( $pattern, '[$1' . $new, $row->post_content, -1, $count );\n        if ( ! $count ) {\n            continue;\n        }\n        $changed++;\n        if ( ! empty( $assoc_args['yes'] ) ) {\n            wp_update_post( array( 'ID' => (int) $row->ID, 'post_content' => wp_slash( $content ) ) );\n        }\n    }\n\n    if ( empty( $assoc_args['yes'] ) ) {\n        WP_CLI::log( sprintf( '%d posts use [%s] and would be updated. Add --yes to apply.', $changed, $old ) );\n        return;\n    }\n    WP_CLI::success( sprintf( 'Renamed [%s] to [%s] in %d posts.', $old, $new, $changed ) );\n}, array(\n    'shortdesc' => 'Renames a shortcode tag in all content.',\n    'synopsis'  => array(\n        array( 'type' => 'positional', 'name' => 'old' ),\n        array( 'type' => 'positional', 'name' => 'new' ),\n        array( 'type' => 'flag', 'name' => 'yes', 'optional' => true ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp rename-shortcode old_button button --yes\"; without --yes it only counts. Revisions are created so changes can be rolled back per post."
        },
        {
            "id": "cli-shortcodes-in-use",
            "version": 1,
            "title": "List Every Shortcode Used in Content (WP-CLI)",
            "description": "Adds a \"wp shortcodes-in-use\" command that scans all content and lists each shortcode tag found, how many posts use it and whether anything is still registered to handle it.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "shortcodes",
                "audit"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'shortcodes-in-use', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $usage = array();\n    $last  = 0;\n    do {\n        $rows = $wpdb->get_results( $wpdb->prepare(\n            \"SELECT ID, post_content FROM {$wpdb->posts} WHERE ID > %d AND post_type NOT IN ('revision','attachment','nav_menu_item')\n            AND post_status NOT IN ('trash','auto-draft') AND post_content LIKE '%%[%%' ORDER BY ID LIMIT 500\",\n            $last\n        ) );\n        foreach ( $rows as $row ) {\n            $last = (int) $row->ID;\n            // Strip block comments so JSON in them is not mistaken for shortcodes.\n            $text = preg_replace( '/<!--.*?-->/s', '', $row->post_content );\n            if ( preg_match_all( '/\\[([a-zA-Z][\\w-]*)(?=[\\s\\]\\/])/', $text, $matches ) ) {\n                foreach ( array_unique( $matches[1] ) as $tag ) {\n                    $usage[ $tag ] = ( $usage[ $tag ] ?? 0 ) + 1;\n                }\n            }\n        }\n    } while ( count( $rows ) === 500 );\n\n    if ( ! $usage ) {\n        WP_CLI::success( 'No shortcodes found in content.' );\n        return;\n    }\n\n    arsort( $usage );\n    $items = array();\n    foreach ( $usage as $tag => $posts ) {\n        $items[] = array( 'shortcode' => $tag, 'posts' => $posts, 'status' => shortcode_exists( $tag ) ? 'registered' : 'missing' );\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'shortcode', 'posts', 'status' ) );\n}, array(\n    'shortdesc' => 'Lists shortcode tags used in content.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Tags marked \"missing\" print as raw text on the site, usually because the plugin that provided them was removed."
        },
        {
            "id": "cli-heaviest-posts",
            "version": 1,
            "title": "Find the Heaviest Posts by Content Size (WP-CLI)",
            "description": "Adds a \"wp heaviest-posts\" command listing the posts and pages with the largest stored content, revisions excluded, to track down pasted base64 images or page builder bloat slowing the editor.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "database",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'heaviest-posts', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $limit = max( 1, min( 200, (int) ( $assoc_args['limit'] ?? 15 ) ) );\n    $rows  = $wpdb->get_results( $wpdb->prepare(\n        \"SELECT ID, post_type, post_status, post_title, LENGTH(post_content) AS bytes, post_content LIKE '%%data:image/%%' AS has_base64 FROM {$wpdb->posts}\n        WHERE post_type NOT IN ('revision','attachment','nav_menu_item','customize_changeset') ORDER BY bytes DESC LIMIT %d\",\n        $limit\n    ), ARRAY_A );\n\n    foreach ( $rows as &$row ) {\n        $row['size']       = size_format( (int) $row['bytes'], 1 );\n        $row['base64_img'] = $row['has_base64'] ? 'yes' : 'no';\n        unset( $row['bytes'], $row['has_base64'] );\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $rows, array( 'ID', 'post_type', 'post_status', 'size', 'base64_img', 'post_title' ) );\n}, array(\n    'shortdesc' => 'Lists posts with the largest content.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'limit', 'optional' => true, 'default' => 15 ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp heaviest-posts --limit=20 --format=csv\"."
        },
        {
            "id": "debug-query-list-panel",
            "version": 1,
            "title": "List Every Database Query on a Page for Administrators",
            "description": "When SAVEQUERIES is enabled, lets administrators add ?wpide_queries=1 to a front-end URL to see every SQL query of the page with its time and calling function, slowest first.",
            "category": "developer",
            "tags": [
                "debugging",
                "database",
                "sql"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'wp_footer', function () {\n    global $wpdb;\n\n    if ( ! isset( $_GET['wpide_queries'] ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    if ( ! defined( 'SAVEQUERIES' ) || ! SAVEQUERIES ) {\n        if ( empty( $wpdb->queries ) ) {\n            echo '<p style=\"margin:2rem;padding:1rem;background:#fff;color:#1d2327;border:1px solid #c3c4c7\">Enable SAVEQUERIES in wp-config.php to log queries.</p>';\n            return;\n        }\n    }\n\n    $queries = (array) $wpdb->queries;\n    usort( $queries, function ( $a, $b ) {\n        return $b[1] <=> $a[1];\n    } );\n    $total = array_sum( array_column( $queries, 1 ) );\n\n    echo '<details open style=\"margin:2rem;padding:1rem;background:#fff;color:#1d2327;border:1px solid #c3c4c7;font:12px/1.5 monospace;position:relative;z-index:99999;overflow:auto\">';\n    printf( '<summary><strong>%d queries in %s ms</strong></summary><table style=\"width:100%%;border-collapse:collapse\">', count( $queries ), esc_html( number_format_i18n( $total * 1000, 1 ) ) );\n    foreach ( $queries as $query ) {\n        $callers = array_map( 'trim', explode( ',', (string) $query[2] ) );\n        printf(\n            '<tr style=\"border-top:1px solid #eee;vertical-align:top\"><td style=\"padding:4px;white-space:nowrap\">%s ms</td><td style=\"padding:4px\"><code style=\"white-space:pre-wrap\">%s</code><br><small style=\"color:#646970\">%s</small></td></tr>',\n            esc_html( number_format_i18n( $query[1] * 1000, 2 ) ),\n            esc_html( $query[0] ),\n            esc_html( end( $callers ) )\n        );\n    }\n    echo '</table></details>';\n}, PHP_INT_MAX );",
            "requires": [],
            "notes": "Add define( 'SAVEQUERIES', true ); to wp-config.php while debugging only, as it uses extra memory on every request."
        },
        {
            "id": "debug-options-viewer",
            "version": 1,
            "title": "Searchable Options Viewer Under Tools",
            "description": "Adds a Tools > Options Viewer screen where administrators can search the options table by name and see each value decoded and pretty-printed, including serialized arrays that Settings > All hides.",
            "category": "developer",
            "tags": [
                "options",
                "debugging",
                "database"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    add_management_page( 'Options Viewer', 'Options Viewer', 'manage_options', 'wpide-options-viewer', function () {\n        global $wpdb;\n\n        $search = isset( $_GET['wpide_option_search'] ) ? sanitize_text_field( wp_unslash( $_GET['wpide_option_search'] ) ) : '';\n\n        echo '<div class=\"wrap\"><h1>Options Viewer</h1><form method=\"get\"><input type=\"hidden\" name=\"page\" value=\"wpide-options-viewer\">';\n        printf( '<label class=\"screen-reader-text\" for=\"wpide-option-search\">Option name</label><input type=\"search\" id=\"wpide-option-search\" name=\"wpide_option_search\" value=\"%s\" class=\"regular-text\" placeholder=\"Option name contains...\"> ', esc_attr( $search ) );\n        submit_button( 'Search', 'secondary', '', false );\n        echo '</form>';\n\n        if ( strlen( $search ) < 3 ) {\n            echo '<p>Type at least three characters of an option name.</p></div>';\n            return;\n        }\n\n        $rows = $wpdb->get_results( $wpdb->prepare(\n            \"SELECT option_name, option_value, autoload FROM {$wpdb->options} WHERE option_name LIKE %s ORDER BY option_name LIMIT 100\",\n            '%' . $wpdb->esc_like( $search ) . '%'\n        ) );\n\n        $mask = function ( $value ) use ( &$mask ) {\n            if ( is_array( $value ) || is_object( $value ) ) {\n                $out = array();\n                foreach ( (array) $value as $k => $v ) {\n                    $out[ $k ] = preg_match( '/key|token|secret|pass|salt|auth/i', (string) $k ) ? '(hidden)' : $mask( $v );\n                }\n                return $out;\n            }\n            return $value;\n        };\n\n        echo '<table class=\"widefat striped\" style=\"table-layout:fixed\"><thead><tr><th style=\"width:28%\">Name</th><th style=\"width:80px\">Autoload</th><th>Value</th></tr></thead><tbody>';\n        foreach ( $rows as $row ) {\n            $value = maybe_unserialize( $row->option_value );\n            $shown = preg_match( '/key|token|secret|pass|salt|auth/i', $row->option_name ) ? '(hidden)' : print_r( $mask( $value ), true );\n            printf(\n                '<tr><td><code>%s</code></td><td>%s</td><td><pre style=\"white-space:pre-wrap;word-break:break-all;margin:0;max-height:300px;overflow:auto\">%s</pre></td></tr>',\n                esc_html( $row->option_name ),\n                esc_html( $row->autoload ),\n                esc_html( mb_strimwidth( (string) $shown, 0, 5000, '...' ) )\n            );\n        }\n        echo '</tbody></table></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Read-only; values that look like keys, tokens or passwords are masked."
        },
        {
            "id": "dev-customizer-setting-example",
            "version": 1,
            "title": "Add Your Own Setting to the Customizer",
            "description": "Adds a \"Site extras\" section to Appearance > Customize with a footer note and a contact phone field that preview live, and makes them available with a [site_extra] shortcode, as a pattern for your own settings.",
            "category": "developer",
            "tags": [
                "customizer",
                "settings",
                "theme-mods"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'customize_register', function ( $wp_customize ) {\n    $wp_customize->add_section( 'wpide_site_extras', array(\n        'title'    => 'Site extras',\n        'priority' => 160,\n    ) );\n\n    $wp_customize->add_setting( 'wpide_footer_note', array(\n        'default'           => '',\n        'sanitize_callback' => 'wp_kses_post',\n        'transport'         => 'refresh',\n    ) );\n    $wp_customize->add_control( 'wpide_footer_note', array(\n        'label'   => 'Footer note',\n        'section' => 'wpide_site_extras',\n        'type'    => 'textarea',\n    ) );\n\n    $wp_customize->add_setting( 'wpide_phone', array(\n        'default'           => '',\n        'sanitize_callback' => 'sanitize_text_field',\n    ) );\n    $wp_customize->add_control( 'wpide_phone', array(\n        'label'   => 'Contact phone',\n        'section' => 'wpide_site_extras',\n        'type'    => 'tel',\n    ) );\n} );\n\nadd_action( 'init', function () {\n    add_shortcode( 'site_extra', function ( $atts ) {\n        $atts = shortcode_atts( array( 'key' => 'footer_note' ), $atts, 'site_extra' );\n        $key  = 'phone' === $atts['key'] ? 'wpide_phone' : 'wpide_footer_note';\n        return wp_kses_post( (string) get_theme_mod( $key, '' ) );\n    } );\n} );",
            "requires": [],
            "notes": "Use [site_extra key=\"phone\"] or get_theme_mod( 'wpide_phone' ) in templates; block themes need the Customize menu brought back to edit them."
        },
        {
            "id": "dev-js-settings-object",
            "version": 1,
            "title": "Site Settings Object for Your Front-End Scripts",
            "description": "Prints a small window.siteSettings object with the home URL, REST API root, AJAX URL, language and, for logged-in users, a REST nonce, so your custom JavaScript never hard-codes addresses.",
            "category": "developer",
            "tags": [
                "javascript",
                "rest-api",
                "helper"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_enqueue_scripts', function () {\n    $data = array(\n        'homeUrl'  => home_url( '/' ),\n        'restUrl'  => esc_url_raw( rest_url() ),\n        'ajaxUrl'  => admin_url( 'admin-ajax.php' ),\n        'locale'   => determine_locale(),\n        'loggedIn' => is_user_logged_in(),\n    );\n\n    // The nonce authenticates REST requests from logged-in users only.\n    if ( is_user_logged_in() ) {\n        $data['restNonce'] = wp_create_nonce( 'wp_rest' );\n    }\n\n    wp_register_script( 'wpide-site-settings', false, array(), '1.0', false );\n    wp_enqueue_script( 'wpide-site-settings' );\n    wp_add_inline_script( 'wpide-site-settings', 'window.siteSettings = ' . wp_json_encode( $data ) . ';', 'before' );\n} );",
            "requires": [],
            "notes": "Read it in your scripts as window.siteSettings.restUrl; add your own values to the $data array."
        },
        {
            "id": "dev-script-module-example",
            "version": 1,
            "title": "Load a JavaScript Module Only Where It Is Needed",
            "description": "Registers a native JavaScript module with WordPress's Script Modules API and loads it only on pages that contain a chosen block, so modern ES module code needs no bundler and costs nothing elsewhere.",
            "category": "developer",
            "tags": [
                "javascript",
                "modules",
                "performance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Module URL and the block that needs it.\n$wpide_module_url   = content_url( 'js/site.js' );\n$wpide_module_block = 'core/gallery';\n\nadd_action( 'init', function () use ( $wpide_module_url ) {\n    if ( function_exists( 'wp_register_script_module' ) ) {\n        wp_register_script_module( 'wpide/site', $wpide_module_url, array(), '1.0' );\n    }\n} );\n\nadd_filter( 'render_block_' . $wpide_module_block, function ( $content ) {\n    if ( function_exists( 'wp_enqueue_script_module' ) ) {\n        wp_enqueue_script_module( 'wpide/site' );\n    }\n    return $content;\n} );",
            "requires": [],
            "notes": "Needs WordPress 6.5+; put your module file at wp-content/js/site.js (or change $wpide_module_url) and set the block that triggers it."
        },
        {
            "id": "dev-remember-cache-helper",
            "version": 1,
            "title": "Cache Any Expensive Result With One Function",
            "description": "Adds a wpide_remember() helper that returns a cached value if there is one, or runs your function, stores the result for a set time and returns it, which makes caching slow queries and API calls a one-liner.",
            "category": "developer",
            "tags": [
                "caching",
                "transients",
                "helper"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! function_exists( 'wpide_remember' ) ) {\n    function wpide_remember( $key, $seconds, callable $callback ) {\n        $cache_key = 'wpide_rmb_' . md5( $key );\n        $cached    = get_transient( $cache_key );\n\n        // Wrapped so that false and empty results are cached too.\n        if ( is_array( $cached ) && array_key_exists( 'value', $cached ) ) {\n            return $cached['value'];\n        }\n\n        $value = call_user_func( $callback );\n        if ( ! is_wp_error( $value ) ) {\n            set_transient( $cache_key, array( 'value' => $value ), max( 1, (int) $seconds ) );\n        }\n\n        return $value;\n    }\n\n    function wpide_forget( $key ) {\n        return delete_transient( 'wpide_rmb_' . md5( $key ) );\n    }\n}",
            "requires": [],
            "notes": "Example $top = wpide_remember( 'top_posts', HOUR_IN_SECONDS, function () { return get_posts( ... ); } ); call wpide_forget( 'top_posts' ) to clear it."
        },
        {
            "id": "admin-color-scheme-per-role",
            "version": 1,
            "title": "Admin Color Scheme per User Role",
            "description": "Gives each role its own admin color scheme, for example blue for editors and coffee for shop managers, so people instantly see which kind of account they are using.",
            "category": "admin",
            "tags": [
                "admin-colors",
                "roles",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Role => admin color scheme.\n$wpide_role_colors = array(\n    'administrator' => 'midnight',\n    'editor'        => 'blue',\n    'author'        => 'ocean',\n    'shop_manager'  => 'coffee',\n);\n\nadd_filter( 'get_user_option_admin_color', function ( $color, $option, $user ) use ( $wpide_role_colors ) {\n    if ( ! $user instanceof WP_User ) {\n        return $color;\n    }\n    foreach ( (array) $user->roles as $role ) {\n        if ( isset( $wpide_role_colors[ $role ] ) ) {\n            return $wpide_role_colors[ $role ];\n        }\n    }\n    return $color;\n}, 10, 3 );",
            "requires": [],
            "notes": "Roles not listed keep each user's own choice; scheme names: fresh, light, modern, blue, coffee, ectoplasm, midnight, ocean, sunrise."
        },
        {
            "id": "admin-scheduled-count-bubble",
            "version": 1,
            "title": "Scheduled Posts Count in the Admin Menu",
            "description": "Shows a small counter next to Posts in the admin menu with the number of scheduled posts waiting to go live, so the publishing queue is visible from every screen.",
            "category": "admin",
            "tags": [
                "admin-menu",
                "scheduling",
                "counters"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_menu', function () {\n    global $menu;\n\n    $scheduled = (int) wp_count_posts( 'post' )->future;\n    if ( ! $scheduled ) {\n        return;\n    }\n\n    foreach ( (array) $menu as $i => $item ) {\n        if ( isset( $item[2] ) && 'edit.php' === $item[2] ) {\n            $menu[ $i ][0] .= sprintf(\n                ' <span class=\"awaiting-mod\" style=\"background:#2271b1\" title=\"Scheduled posts\"><span class=\"pending-count\">%d</span><span class=\"screen-reader-text\"> scheduled</span></span>',\n                $scheduled\n            );\n            break;\n        }\n    }\n}, 99 );",
            "requires": [],
            "notes": "Uses WordPress's cached post counts, so it adds no extra queries on most screens."
        },
        {
            "id": "admin-remember-list-filters",
            "version": 1,
            "title": "Remember Your Filters in Admin Lists",
            "description": "Brings back the status, category, author, search and sorting you last used when you return to the Posts, Pages or Media list, instead of starting from \"All\" every time.",
            "category": "admin",
            "tags": [
                "admin-lists",
                "filters",
                "productivity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Query arguments worth remembering.\n$wpide_remembered_args = array( 'post_status', 'cat', 'category_name', 'author', 'm', 's', 'orderby', 'order', 'tag', 'post_format', 'mode', 'attachment-filter' );\n\n$wpide_remember_filters = function () use ( $wpide_remembered_args ) {\n    global $pagenow;\n\n    $post_type = isset( $_GET['post_type'] ) ? sanitize_key( $_GET['post_type'] ) : ( 'upload.php' === $pagenow ? 'attachment' : 'post' );\n    $screen    = $pagenow . ':' . $post_type;\n    $saved     = (array) get_user_meta( get_current_user_id(), 'wpide_list_filters', true );\n    $current   = array_intersect_key( wp_unslash( $_GET ), array_flip( $wpide_remembered_args ) );\n\n    if ( $current ) {\n        $saved[ $screen ] = array_map( 'sanitize_text_field', $current );\n        update_user_meta( get_current_user_id(), 'wpide_list_filters', $saved );\n        return;\n    }\n\n    // A plain visit restores the last filters, unless \"All\" was clicked from the list itself.\n    $from_list = wp_get_referer() && false !== strpos( wp_get_referer(), $pagenow );\n    if ( ! empty( $saved[ $screen ] ) && ! $from_list && empty( $_GET['paged'] ) ) {\n        $args = $saved[ $screen ];\n        if ( 'attachment' !== $post_type && 'post' !== $post_type ) {\n            $args['post_type'] = $post_type;\n        }\n        wp_safe_redirect( add_query_arg( array_map( 'rawurlencode', $args ), admin_url( $pagenow ) ) );\n        exit;\n    }\n\n    if ( $from_list && isset( $saved[ $screen ] ) ) {\n        unset( $saved[ $screen ] );\n        update_user_meta( get_current_user_id(), 'wpide_list_filters', $saved );\n    }\n};\n\nadd_action( 'load-edit.php', $wpide_remember_filters );\nadd_action( 'load-upload.php', $wpide_remember_filters );",
            "requires": [],
            "notes": "Click the post type menu link twice, or \"All\", to clear the remembered filters; stored per user and per screen."
        },
        {
            "id": "admin-confirm-before-trash",
            "version": 1,
            "title": "Ask Before Moving Posts to the Trash",
            "description": "Shows a confirmation dialog when someone clicks \"Trash\" under a post, page or media item, or applies the Move to Trash bulk action, to prevent accidental removals.",
            "category": "admin",
            "tags": [
                "trash",
                "confirmation",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_trash_confirm = function () {\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var link = event.target.closest( '.row-actions .trash a, .row-actions .delete a, a.submitdelete' );\n        if ( link && ! window.confirm( 'Move this item to the trash?' ) ) {\n            event.preventDefault();\n        }\n    }, true );\n    document.addEventListener( 'submit', function ( event ) {\n        var form = event.target;\n        if ( ! form.id || form.id !== 'posts-filter' ) { return; }\n        var top = form.querySelector( '#bulk-action-selector-top' );\n        var bottom = form.querySelector( '#bulk-action-selector-bottom' );\n        var action = ( top && top.value !== '-1' ) ? top.value : ( bottom ? bottom.value : '' );\n        if ( action === 'trash' ) {\n            var count = form.querySelectorAll( 'tbody .check-column input:checked' ).length;\n            if ( count && ! window.confirm( 'Move ' + count + ' item(s) to the trash?' ) ) {\n                event.preventDefault();\n            }\n        }\n    }, true );\n    </script>\n    <?php\n};\nadd_action( 'admin_footer-edit.php', $wpide_trash_confirm );\nadd_action( 'admin_footer-upload.php', $wpide_trash_confirm );\nadd_action( 'admin_footer-post.php', $wpide_trash_confirm );",
            "requires": [],
            "notes": "Items in the trash can still be restored for 30 days; this only adds a safety prompt."
        },
        {
            "id": "admin-pages-alphabetical-default",
            "version": 1,
            "title": "Sort the Pages List Alphabetically by Default",
            "description": "Opens Pages sorted by title from A to Z instead of the default order, which makes finding a page on large sites much faster. Clicking a column header still sorts as usual.",
            "category": "admin",
            "tags": [
                "pages",
                "sorting",
                "admin-lists"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Post types listed A to Z by default.\n$wpide_alpha_post_types = array( 'page' );\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_alpha_post_types ) {\n    global $pagenow;\n\n    if ( ! is_admin() || 'edit.php' !== $pagenow || ! $query->is_main_query() || isset( $_GET['orderby'] ) ) {\n        return;\n    }\n    if ( in_array( $query->get( 'post_type' ), $wpide_alpha_post_types, true ) ) {\n        $query->set( 'orderby', 'title' );\n        $query->set( 'order', 'ASC' );\n    }\n} );",
            "requires": [],
            "notes": "Change $wpide_alpha_post_types to sort other post types alphabetically as well."
        },
        {
            "id": "admin-menu-search-box",
            "version": 1,
            "title": "Search Box for the Admin Menu",
            "description": "Adds a small filter box at the top of the admin menu that hides every menu and submenu item not matching what you type, handy on sites with dozens of plugin menus.",
            "category": "admin",
            "tags": [
                "admin-menu",
                "search",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_footer', function () {\n    ?>\n    <style>\n    #wpide-menu-filter{margin:8px 10px;width:calc(100% - 20px);box-sizing:border-box;min-height:28px}\n    .folded #wpide-menu-filter{display:none}\n    #adminmenu li.wpide-hide{display:none}\n    #adminmenu.wpide-filtering .wp-submenu{position:static!important;display:block!important;box-shadow:none!important;margin:0!important}\n    </style>\n    <script>\n    ( function () {\n        var menu = document.getElementById( 'adminmenu' );\n        if ( ! menu ) { return; }\n        var input = document.createElement( 'input' );\n        input.type = 'search';\n        input.id = 'wpide-menu-filter';\n        input.placeholder = 'Filter menu...';\n        input.setAttribute( 'aria-label', 'Filter admin menu' );\n        menu.parentNode.insertBefore( input, menu );\n\n        input.addEventListener( 'input', function () {\n            var term = input.value.trim().toLowerCase();\n            menu.classList.toggle( 'wpide-filtering', term !== '' );\n            menu.querySelectorAll( ':scope > li.menu-top' ).forEach( function ( item ) {\n                var top = ( item.querySelector( '.wp-menu-name' ) || item ).textContent.toLowerCase();\n                var subs = item.querySelectorAll( '.wp-submenu li:not(.wp-submenu-head)' );\n                var anySub = false;\n                subs.forEach( function ( sub ) {\n                    var match = term === '' || top.indexOf( term ) !== -1 || sub.textContent.toLowerCase().indexOf( term ) !== -1;\n                    sub.classList.toggle( 'wpide-hide', ! match );\n                    anySub = anySub || ( match && term !== '' );\n                } );\n                item.classList.toggle( 'wpide-hide', term !== '' && top.indexOf( term ) === -1 && ! anySub );\n            } );\n            menu.querySelectorAll( ':scope > li.wp-menu-separator' ).forEach( function ( sep ) { sep.classList.toggle( 'wpide-hide', term !== '' ); } );\n        } );\n        input.addEventListener( 'keydown', function ( event ) {\n            if ( event.key === 'Escape' ) { input.value = ''; input.dispatchEvent( new Event( 'input' ) ); }\n        } );\n    } )();\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Press Escape to clear the filter; the box is hidden while the menu is collapsed."
        },
        {
            "id": "admin-bar-bookmarks",
            "version": 1,
            "title": "Bookmark Admin Screens in the Admin Bar",
            "description": "Adds a star menu to the admin bar where each user can bookmark the admin screen they are on, such as a plugin's settings or a filtered list, and jump back to it from anywhere.",
            "category": "admin",
            "tags": [
                "admin-bar",
                "bookmarks",
                "productivity"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    $bookmarks = (array) get_user_meta( get_current_user_id(), 'wpide_bookmarks', true );\n    $bookmarks = array_filter( $bookmarks );\n    $here      = is_admin() ? ( is_ssl() ? 'https://' : 'http://' ) . ( $_SERVER['HTTP_HOST'] ?? '' ) . ( $_SERVER['REQUEST_URI'] ?? '' ) : '';\n    $here      = esc_url_raw( $here );\n\n    $admin_bar->add_node( array(\n        'id'    => 'wpide-bookmarks',\n        'title' => '<span class=\"ab-icon dashicons dashicons-star-filled\" style=\"margin-top:2px\" aria-hidden=\"true\"></span><span class=\"ab-label\">Bookmarks</span>',\n    ) );\n\n    foreach ( $bookmarks as $i => $bookmark ) {\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-bookmark-' . $i,\n            'parent' => 'wpide-bookmarks',\n            'title'  => esc_html( $bookmark['title'] ),\n            'href'   => esc_url( $bookmark['url'] ),\n        ) );\n    }\n\n    if ( $here ) {\n        $saved = in_array( $here, wp_list_pluck( $bookmarks, 'url' ), true );\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-bookmark-toggle',\n            'parent' => 'wpide-bookmarks',\n            'title'  => $saved ? 'Remove this page' : '+ Bookmark this page',\n            'href'   => wp_nonce_url( add_query_arg( array(\n                'action'   => 'wpide_bookmark',\n                'wpide_bm' => $saved ? 'remove' : 'add',\n                'url'      => rawurlencode( $here ),\n                'title'    => rawurlencode( wp_strip_all_tags( ! empty( $GLOBALS['title'] ) ? $GLOBALS['title'] : 'Admin page' ) ),\n            ), admin_url( 'admin-post.php' ) ), 'wpide_bookmark' ),\n        ) );\n    }\n}, 90 );\n\nadd_action( 'admin_post_wpide_bookmark', function () {\n    check_admin_referer( 'wpide_bookmark' );\n\n    $url   = isset( $_GET['url'] ) ? esc_url_raw( wp_unslash( $_GET['url'] ) ) : '';\n    $title = isset( $_GET['title'] ) ? sanitize_text_field( wp_unslash( $_GET['title'] ) ) : '';\n    if ( ! $url || 0 !== strpos( $url, admin_url() ) ) {\n        wp_die( 'Only admin screens can be bookmarked.', 400 );\n    }\n\n    $bookmarks = array_filter( (array) get_user_meta( get_current_user_id(), 'wpide_bookmarks', true ) );\n    $bookmarks = array_values( array_filter( $bookmarks, function ( $bookmark ) use ( $url ) {\n        return $bookmark['url'] !== $url;\n    } ) );\n    if ( isset( $_GET['wpide_bm'] ) && 'add' === $_GET['wpide_bm'] ) {\n        $bookmarks[] = array( 'url' => $url, 'title' => $title ? $title : $url );\n    }\n    update_user_meta( get_current_user_id(), 'wpide_bookmarks', array_slice( $bookmarks, -15 ) );\n\n    wp_safe_redirect( $url );\n    exit;\n} );",
            "requires": [],
            "notes": "Bookmarks are saved per user (up to 15); use \"Remove this page\" in the menu to delete one."
        },
        {
            "id": "admin-floating-edit-button",
            "version": 1,
            "title": "Floating \"Edit This Page\" Button on the Front End",
            "description": "Shows a small floating Edit button in the corner of posts and pages for users who can edit them, which is handy when the admin bar is hidden or on phones.",
            "category": "admin",
            "tags": [
                "front-end",
                "editing",
                "shortcuts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "// Corner for the button: left or right.\n$wpide_edit_button_side = 'right';\n\nadd_action( 'wp_footer', function () use ( $wpide_edit_button_side ) {\n    if ( ! is_singular() || ! current_user_can( 'edit_post', get_queried_object_id() ) ) {\n        return;\n    }\n\n    $link = get_edit_post_link( get_queried_object_id() );\n    if ( ! $link ) {\n        return;\n    }\n\n    printf(\n        '<a href=\"%1$s\" class=\"wpide-edit-fab\" style=\"position:fixed;bottom:20px;%2$s:20px;z-index:99998;display:inline-flex;align-items:center;gap:6px;padding:10px 16px;border-radius:999px;background:#1d2327;color:#fff;font:600 14px/1 system-ui,sans-serif;text-decoration:none;box-shadow:0 4px 14px rgba(0,0,0,.25)\"><span aria-hidden=\"true\">&#9998;</span> Edit %3$s</a><style>.wpide-edit-fab:hover,.wpide-edit-fab:focus{background:#2271b1!important;outline:2px solid #fff;outline-offset:2px}@media (prefers-reduced-motion:no-preference){.wpide-edit-fab{transition:background .2s}}@media print{.wpide-edit-fab{display:none!important}}</style>',\n        esc_url( $link ),\n        'left' === $wpide_edit_button_side ? 'left' : 'right',\n        esc_html( strtolower( get_post_type_object( get_post_type( get_queried_object_id() ) )->labels->singular_name ) )\n    );\n} );",
            "requires": [],
            "notes": "Change the corner with $wpide_edit_button_side (left or right); it respects the visitor's reduced motion setting."
        },
        {
            "id": "admin-comments-toggle-column",
            "version": 1,
            "title": "Comments On/Off Switch in the Posts List",
            "description": "Adds a column to the Posts list with a switch that opens or closes comments on each post in one click, without opening the editor or Quick Edit.",
            "category": "admin",
            "tags": [
                "comments",
                "admin-columns",
                "ajax"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'manage_post_posts_columns', function ( $columns ) {\n    $columns['wpide_comments_open'] = 'Comments';\n    return $columns;\n} );\n\nadd_action( 'manage_post_posts_custom_column', function ( $column, $post_id ) {\n    if ( 'wpide_comments_open' !== $column ) {\n        return;\n    }\n    $open = 'open' === get_post_field( 'comment_status', $post_id );\n    printf(\n        '<button type=\"button\" class=\"button button-small wpide-comment-toggle\" data-id=\"%d\" aria-pressed=\"%s\">%s</button>',\n        (int) $post_id,\n        $open ? 'true' : 'false',\n        $open ? 'Open' : 'Closed'\n    );\n}, 10, 2 );\n\nadd_action( 'wp_ajax_wpide_comment_toggle', function () {\n    check_ajax_referer( 'wpide_comment_toggle', 'nonce' );\n    $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0;\n    if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {\n        wp_send_json_error( null, 403 );\n    }\n    $status = 'open' === get_post_field( 'comment_status', $post_id ) ? 'closed' : 'open';\n    wp_update_post( array( 'ID' => $post_id, 'comment_status' => $status ) );\n    wp_send_json_success( array( 'open' => 'open' === $status ) );\n} );\n\nadd_action( 'admin_footer-edit.php', function () {\n    ?>\n    <script>\n    document.addEventListener( 'click', function ( event ) {\n        var button = event.target.closest( '.wpide-comment-toggle' );\n        if ( ! button ) { return; }\n        button.disabled = true;\n        var body = new FormData();\n        body.append( 'action', 'wpide_comment_toggle' );\n        body.append( 'nonce', <?php echo wp_json_encode( wp_create_nonce( 'wpide_comment_toggle' ) ); ?> );\n        body.append( 'post_id', button.dataset.id );\n        fetch( ajaxurl, { method: 'POST', credentials: 'same-origin', body: body } )\n            .then( function ( r ) { return r.json(); } )\n            .then( function ( r ) {\n                button.disabled = false;\n                if ( ! r.success ) { return; }\n                button.textContent = r.data.open ? 'Open' : 'Closed';\n                button.setAttribute( 'aria-pressed', r.data.open ? 'true' : 'false' );\n            } );\n    } );\n    </script>\n    <?php\n} );",
            "requires": [],
            "notes": "Only users who can edit the post can flip the switch."
        },
        {
            "id": "admin-bulk-remove-featured-image",
            "version": 1,
            "title": "Bulk Remove Featured Images",
            "description": "Adds a \"Remove featured image\" bulk action to post and page lists that clears the featured image from all selected items at once, without deleting the images from the Media Library.",
            "category": "admin",
            "tags": [
                "bulk-actions",
                "featured-image",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "foreach ( array( 'post', 'page' ) as $wpide_type ) {\n    add_filter( \"bulk_actions-edit-{$wpide_type}\", function ( $actions ) {\n        $actions['wpide_remove_thumbnail'] = 'Remove featured image';\n        return $actions;\n    } );\n\n    add_filter( \"handle_bulk_actions-edit-{$wpide_type}\", function ( $redirect, $action, $post_ids ) {\n        if ( 'wpide_remove_thumbnail' !== $action ) {\n            return $redirect;\n        }\n        $done = 0;\n        foreach ( $post_ids as $post_id ) {\n            if ( current_user_can( 'edit_post', $post_id ) && delete_post_thumbnail( $post_id ) ) {\n                $done++;\n            }\n        }\n        return add_query_arg( 'wpide_thumbs_removed', $done, $redirect );\n    }, 10, 3 );\n}\n\nadd_action( 'admin_notices', function () {\n    if ( isset( $_GET['wpide_thumbs_removed'] ) ) {\n        printf( '<div class=\"notice notice-success is-dismissible\"><p>Removed the featured image from %d items.</p></div>', absint( $_GET['wpide_thumbs_removed'] ) );\n    }\n} );",
            "requires": [],
            "notes": "The images stay in the Media Library and can be set again at any time."
        },
        {
            "id": "content-lock-published-for-authors",
            "version": 1,
            "title": "Stop Authors Editing Posts After They Are Published",
            "description": "Lets authors and contributors write and submit posts as usual, but once a post is published only editors and administrators can change it, which keeps approved content approved.",
            "category": "content",
            "tags": [
                "editorial",
                "permissions",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days after publishing during which authors may still edit (0 = never).\n$wpide_lock_after_days = 0;\n\nadd_filter( 'map_meta_cap', function ( $caps, $cap, $user_id, $args ) use ( $wpide_lock_after_days ) {\n    if ( ! in_array( $cap, array( 'edit_post', 'delete_post' ), true ) || empty( $args[0] ) ) {\n        return $caps;\n    }\n\n    $post = get_post( (int) $args[0] );\n    if ( ! $post || 'publish' !== $post->post_status || 'post' !== $post->post_type ) {\n        return $caps;\n    }\n\n    // Editors and administrators are never locked out.\n    if ( user_can( $user_id, 'edit_others_posts' ) ) {\n        return $caps;\n    }\n\n    if ( $wpide_lock_after_days && get_post_time( 'U', true, $post ) > time() - $wpide_lock_after_days * DAY_IN_SECONDS ) {\n        return $caps;\n    }\n\n    $caps[] = 'do_not_allow';\n    return $caps;\n}, 10, 4 );",
            "requires": [],
            "notes": "Set $wpide_lock_after_days to give authors a grace period, for example 1 day to fix typos."
        },
        {
            "id": "content-default-category-per-author",
            "version": 1,
            "title": "Default Category per Author",
            "description": "Pre-selects a category for new posts based on who writes them, for example \"Recipes\" for your food writer, so posts land in the right section without anyone remembering to tick it.",
            "category": "content",
            "tags": [
                "categories",
                "authors",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User login => category slug.\n$wpide_author_categories = array(\n    'admin' => 'harness-cat',\n    // 'jane' => 'recipes',\n);\n\nadd_action( 'wp_insert_post', function ( $post_id, $post, $update ) use ( $wpide_author_categories ) {\n    if ( $update || 'post' !== $post->post_type || 'auto-draft' !== $post->post_status ) {\n        return;\n    }\n\n    $user = get_userdata( (int) $post->post_author );\n    if ( ! $user || empty( $wpide_author_categories[ $user->user_login ] ) ) {\n        return;\n    }\n\n    $term = get_category_by_slug( $wpide_author_categories[ $user->user_login ] );\n    if ( $term ) {\n        wp_set_post_categories( $post_id, array( (int) $term->term_id ) );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Map user logins to category slugs in $wpide_author_categories; writers can still change it before publishing."
        },
        {
            "id": "content-limit-tags-per-post",
            "version": 1,
            "title": "Limit the Number of Tags per Post",
            "description": "Keeps only the first few tags on each post when it is saved, so tag lists stay focused and tag archives do not fill with one-off tags. Writers see a note when extra tags are dropped.",
            "category": "content",
            "tags": [
                "tags",
                "editorial",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Maximum number of tags per post.\n$wpide_max_tags = 5;\n\nadd_action( 'set_object_terms', function ( $object_id, $terms, $tt_ids, $taxonomy ) use ( $wpide_max_tags ) {\n    static $running = false;\n\n    if ( 'post_tag' !== $taxonomy || $running || count( $tt_ids ) <= $wpide_max_tags ) {\n        return;\n    }\n\n    $running = true;\n    $keep    = array_slice( array_map( 'intval', $tt_ids ), 0, $wpide_max_tags );\n    $ids     = array();\n    foreach ( $keep as $tt_id ) {\n        $term = get_term_by( 'term_taxonomy_id', $tt_id, 'post_tag' );\n        if ( $term ) {\n            $ids[] = (int) $term->term_id;\n        }\n    }\n    wp_set_object_terms( $object_id, $ids, 'post_tag' );\n    set_transient( 'wpide_tags_trimmed_' . get_current_user_id(), 1, 60 );\n    $running = false;\n}, 10, 4 );\n\nadd_action( 'admin_notices', function () use ( $wpide_max_tags ) {\n    if ( get_transient( 'wpide_tags_trimmed_' . get_current_user_id() ) ) {\n        delete_transient( 'wpide_tags_trimmed_' . get_current_user_id() );\n        printf( '<div class=\"notice notice-warning is-dismissible\"><p>Only the first %d tags were kept.</p></div>', (int) $wpide_max_tags );\n    }\n} );",
            "requires": [],
            "notes": "Change $wpide_max_tags; the order is the order in which the tags were added."
        },
        {
            "id": "content-minor-edit-keeps-date",
            "version": 1,
            "title": "\"Minor Edit\" Option That Keeps the Last Updated Date",
            "description": "Adds a \"Minor edit\" checkbox to the block editor; when ticked, fixing a typo does not change the post's last modified date, so \"Updated on\" labels and sitemaps only change for real updates.",
            "category": "content",
            "tags": [
                "block-editor",
                "dates",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_meta( 'post', '_wpide_minor_edit', array(\n        'type'          => 'boolean',\n        'single'        => true,\n        'default'       => false,\n        'show_in_rest'  => true,\n        'auth_callback' => function ( $allowed, $key, $post_id ) {\n            return current_user_can( 'edit_post', $post_id );\n        },\n    ) );\n} );\n\n// Remember the choice from the REST request before the post is saved.\nadd_filter( 'rest_pre_insert_post', function ( $prepared, $request ) {\n    $meta = $request->get_param( 'meta' );\n    $GLOBALS['wpide_minor_edit'] = is_array( $meta ) && ! empty( $meta['_wpide_minor_edit'] ) && ! empty( $prepared->ID );\n    return $prepared;\n}, 10, 2 );\n\nadd_filter( 'wp_insert_post_data', function ( $data, $postarr ) {\n    if ( empty( $GLOBALS['wpide_minor_edit'] ) || empty( $postarr['ID'] ) ) {\n        return $data;\n    }\n    $current = get_post( (int) $postarr['ID'] );\n    if ( $current && 'publish' === $current->post_status ) {\n        $data['post_modified']     = $current->post_modified;\n        $data['post_modified_gmt'] = $current->post_modified_gmt;\n    }\n    return $data;\n}, 99, 2 );\n\n// Untick the box after saving.\nadd_action( 'rest_after_insert_post', function ( $post ) {\n    if ( ! empty( $GLOBALS['wpide_minor_edit'] ) ) {\n        update_post_meta( $post->ID, '_wpide_minor_edit', false );\n        $GLOBALS['wpide_minor_edit'] = false;\n    }\n} );\n\nadd_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || 'post' !== $screen->post_type ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-minor-edit', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-minor-edit' );\n    wp_add_inline_script( 'wpide-minor-edit', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPostStatusInfo ) || ( wp.editPost && wp.editPost.PluginPostStatusInfo );\n    if ( ! Panel ) { return; }\n\n    function MinorEdit() {\n        var data = wp.data.useSelect( function ( select ) {\n            var editor = select( 'core/editor' );\n            return { published: editor.isCurrentPostPublished(), minor: !! ( editor.getEditedPostAttribute( 'meta' ) || {} )._wpide_minor_edit };\n        }, [] );\n        var editPost = wp.data.useDispatch( 'core/editor' ).editPost;\n        if ( ! data.published ) { return null; }\n\n        return el( Panel, null,\n            el( wp.components.CheckboxControl, {\n                label: 'Minor edit (keep the \"last updated\" date)',\n                checked: data.minor,\n                onChange: function ( value ) { editPost( { meta: { _wpide_minor_edit: value } } ); }\n            } )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-minor-edit', { render: MinorEdit } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "The checkbox resets after each save; works with the block editor and the REST API."
        },
        {
            "id": "content-strip-pasted-styles",
            "version": 1,
            "title": "Remove Inline Styles From Pasted Content on Save",
            "description": "Cleans up formatting pasted from Word, Google Docs and web pages when a post is saved, removing inline fonts, colors and sizes, font tags, bare spans and Office classes so text follows your theme.",
            "category": "content",
            "tags": [
                "formatting",
                "cleanup",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Leave administrators' content untouched.\n$wpide_clean_skip_admins = false;\n// Post types to clean.\n$wpide_clean_post_types = array( 'post', 'page' );\n\nadd_filter( 'wp_insert_post_data', function ( $data ) use ( $wpide_clean_skip_admins, $wpide_clean_post_types ) {\n    if ( ! in_array( $data['post_type'], $wpide_clean_post_types, true ) || ( $wpide_clean_skip_admins && current_user_can( 'manage_options' ) ) ) {\n        return $data;\n    }\n\n    $content = wp_unslash( $data['post_content'] );\n\n    // Process only the HTML between block comments, so block attributes stay valid.\n    $parts = preg_split( '/(<!--.*?-->)/s', $content, -1, PREG_SPLIT_DELIM_CAPTURE );\n    $depth = 0;\n    foreach ( $parts as $i => $part ) {\n        if ( 0 === strpos( $part, '<!--' ) ) {\n            if ( preg_match( '/^<!--\\s+wp:[^\\s]+\\s+\\{.*\"style\"/s', $part ) ) {\n                $depth++; // Styled block: keep its markup as is.\n            } elseif ( $depth && preg_match( '/^<!--\\s+\\/wp:/', $part ) ) {\n                $depth--;\n            }\n            continue;\n        }\n        if ( $depth ) {\n            continue;\n        }\n        // Keep only text alignment, which the classic editor stores as an inline style.\n        $part = preg_replace_callback( '/\\sstyle=(\"|\\')([^\"\\']*)\\1/i', function ( $m ) {\n            return preg_match( '/text-align\\s*:\\s*(left|right|center|justify)/i', $m[2], $align ) ? ' style=\"text-align:' . strtolower( $align[1] ) . '\"' : '';\n        }, $part );\n        $part = preg_replace( '/\\sclass=(\"|\\')\\s*Mso[^\"\\']*\\1/i', '', $part );\n        $part = preg_replace( '#</?font[^>]*>#i', '', $part );\n        $part = preg_replace( '#<span>(.*?)</span>#is', '$1', $part );\n        $parts[ $i ] = $part;\n    }\n\n    $data['post_content'] = wp_slash( implode( '', $parts ) );\n    return $data;\n}, 20 );",
            "requires": [],
            "notes": "Text alignment and blocks styled through their own settings are kept; administrators can opt out with $wpide_clean_skip_admins."
        },
        {
            "id": "content-duplicate-title-warning",
            "version": 1,
            "title": "Warn When a Post Title Is Already Used",
            "description": "Shows a warning in the block editor when another post or page already has the same title, so you avoid confusing duplicates in search results, menus and your own lists.",
            "category": "content",
            "tags": [
                "block-editor",
                "titles",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/title-check', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => function () {\n            return current_user_can( 'edit_posts' );\n        },\n        'args'                => array(\n            'title'   => array( 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field' ),\n            'exclude' => array( 'type' => 'integer', 'default' => 0 ),\n            'type'    => array( 'type' => 'string', 'default' => 'post', 'sanitize_callback' => 'sanitize_key' ),\n        ),\n        'callback'            => function ( WP_REST_Request $request ) {\n            global $wpdb;\n            $title = trim( $request['title'] );\n            if ( mb_strlen( $title ) < 3 ) {\n                return rest_ensure_response( array() );\n            }\n            $rows = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT ID, post_status FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s AND ID <> %d AND post_status IN ('publish','future','draft','pending','private') LIMIT 5\",\n                $title,\n                $request['type'],\n                (int) $request['exclude']\n            ) );\n            return rest_ensure_response( array_map( function ( $row ) {\n                return array( 'id' => (int) $row->ID, 'status' => $row->post_status, 'edit' => get_edit_post_link( $row->ID, 'raw' ) );\n            }, $rows ) );\n        },\n    ) );\n} );\n\nadd_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-title-check', false, array( 'wp-data', 'wp-api-fetch', 'wp-url', 'wp-notices', 'wp-dom-ready', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-title-check' );\n    wp_add_inline_script( 'wpide-title-check', <<<'JS'\nwp.domReady( function () {\n    var lastTitle = null, timer = null;\n    wp.data.subscribe( function () {\n        var editor = wp.data.select( 'core/editor' );\n        if ( ! editor || ! editor.getCurrentPostId() ) { return; }\n        var title = ( editor.getEditedPostAttribute( 'title' ) || '' ).trim();\n        if ( title === lastTitle ) { return; }\n        lastTitle = title;\n        clearTimeout( timer );\n        timer = setTimeout( function () {\n            var notices = wp.data.dispatch( 'core/notices' );\n            notices.removeNotice( 'wpide-duplicate-title' );\n            if ( title.length < 3 ) { return; }\n            wp.apiFetch( { path: wp.url.addQueryArgs( '/site/v1/title-check', { title: title, exclude: editor.getCurrentPostId(), type: editor.getCurrentPostType() } ) } ).then( function ( matches ) {\n                if ( matches.length ) {\n                    notices.createWarningNotice( 'Another item already uses this title (' + matches.map( function ( m ) { return '#' + m.id + ' ' + m.status; } ).join( ', ' ) + ').', { id: 'wpide-duplicate-title', isDismissible: true } );\n                }\n            } ).catch( function () {} );\n        }, 800 );\n    } );\n} );\nJS\n    );\n} );",
            "requires": [],
            "notes": "The check runs a moment after you stop typing and looks at published, scheduled and draft items of the same type."
        },
        {
            "id": "attached-files-list-shortcode",
            "version": 1,
            "title": "Attached Files List Shortcode",
            "description": "Adds an [attached_files] shortcode that lists every PDF, document or other file uploaded to the current post as download links with file type and size, ideal for resources and meeting minutes.",
            "category": "content",
            "tags": [
                "shortcodes",
                "downloads",
                "attachments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'attached_files', function ( $atts ) {\n        $atts    = shortcode_atts( array( 'type' => '', 'title' => '', 'images' => 'no', 'id' => 0 ), $atts, 'attached_files' );\n        $post_id = $atts['id'] ? absint( $atts['id'] ) : get_the_ID();\n        if ( ! $post_id ) {\n            return '';\n        }\n\n        $files = get_children( array(\n            'post_parent'    => $post_id,\n            'post_type'      => 'attachment',\n            'post_mime_type' => $atts['type'] ? sanitize_mime_type( $atts['type'] ) : '',\n            'orderby'        => 'menu_order title',\n            'order'          => 'ASC',\n            'numberposts'    => 100,\n        ) );\n\n        if ( 'yes' !== $atts['images'] ) {\n            $files = array_filter( $files, function ( $file ) {\n                return 0 !== strpos( $file->post_mime_type, 'image/' );\n            } );\n        }\n        if ( ! $files ) {\n            return '';\n        }\n\n        $html = '' !== $atts['title'] ? '<h3 class=\"wpide-files-title\">' . esc_html( $atts['title'] ) . '</h3>' : '';\n        $html .= '<ul class=\"wpide-attached-files\">';\n        foreach ( $files as $file ) {\n            $path = get_attached_file( $file->ID );\n            $meta = wp_get_attachment_metadata( $file->ID );\n            $size = ! empty( $meta['filesize'] ) ? (int) $meta['filesize'] : ( $path && file_exists( $path ) ? filesize( $path ) : 0 );\n            $html .= sprintf(\n                '<li><a href=\"%s\" download>%s</a> <small>(%s%s)</small></li>',\n                esc_url( wp_get_attachment_url( $file->ID ) ),\n                esc_html( get_the_title( $file ) ),\n                esc_html( strtoupper( pathinfo( (string) $path, PATHINFO_EXTENSION ) ) ),\n                $size ? esc_html( ', ' . size_format( $size, 1 ) ) : ''\n            );\n        }\n        return $html . '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "Example [attached_files type=\"application/pdf\" title=\"Downloads\"]; images are skipped unless you add images=\"yes\"."
        },
        {
            "id": "recently-updated-posts-shortcode",
            "version": 1,
            "title": "Recently Updated Posts Shortcode",
            "description": "Adds a [recently_updated] shortcode that lists the posts most recently revised, with the date they were updated, to show readers that older guides are being kept fresh.",
            "category": "content",
            "tags": [
                "shortcodes",
                "updates",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'recently_updated', function ( $atts ) {\n        $atts = shortcode_atts( array( 'count' => 5, 'post_type' => 'post' ), $atts, 'recently_updated' );\n\n        $cache_key = 'wpide_recent_upd_' . md5( wp_json_encode( $atts ) . get_option( 'wpide_recent_upd_ver', '1' ) );\n        $html      = get_transient( $cache_key );\n        if ( false !== $html ) {\n            return $html;\n        }\n\n        global $wpdb;\n        $types = array_filter( array_map( 'sanitize_key', explode( ',', $atts['post_type'] ) ) );\n        $in    = implode( ',', array_fill( 0, count( $types ), '%s' ) );\n        $ids   = $wpdb->get_col( $wpdb->prepare(\n            \"SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($in) AND post_password = ''\n            AND post_modified_gmt > DATE_ADD(post_date_gmt, INTERVAL 1 DAY) ORDER BY post_modified_gmt DESC LIMIT %d\",\n            array_merge( $types, array( max( 1, min( 20, (int) $atts['count'] ) ) ) )\n        ) );\n\n        $html = '';\n        if ( $ids ) {\n            _prime_post_caches( $ids, false, false );\n            $html = '<ul class=\"wpide-recently-updated\">';\n            foreach ( $ids as $id ) {\n                $html .= sprintf(\n                    '<li><a href=\"%s\">%s</a> <small>updated <time datetime=\"%s\">%s</time></small></li>',\n                    esc_url( get_permalink( $id ) ),\n                    esc_html( get_the_title( $id ) ),\n                    esc_attr( get_post_modified_time( 'c', true, $id ) ),\n                    esc_html( get_the_modified_date( '', $id ) )\n                );\n            }\n            $html .= '</ul>';\n        }\n\n        set_transient( $cache_key, $html, HOUR_IN_SECONDS );\n        return $html;\n    } );\n} );\n\n// A new version number makes every cached list stale at once.\nadd_action( 'save_post', function ( $post_id, $post ) {\n    if ( 'revision' !== $post->post_type && 'auto-draft' !== $post->post_status ) {\n        update_option( 'wpide_recent_upd_ver', (string) time(), false );\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "Example [recently_updated count=\"5\" post_type=\"post,page\"]; posts edited within a day of publishing are skipped so new posts do not crowd the list."
        },
        {
            "id": "authors-list-shortcode",
            "version": 1,
            "title": "Authors List Shortcode",
            "description": "Adds an [authors_list] shortcode that shows everyone who has published posts, with avatar, short bio and number of posts, linking to each author's archive, for \"Our writers\" pages.",
            "category": "content",
            "tags": [
                "shortcodes",
                "authors",
                "team"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'authors_list', function ( $atts ) {\n        $atts = shortcode_atts( array( 'orderby' => 'post_count', 'bio' => 'yes', 'post_type' => 'post' ), $atts, 'authors_list' );\n\n        $users = get_users( array(\n            'has_published_posts' => array( sanitize_key( $atts['post_type'] ) ),\n            'orderby'             => in_array( $atts['orderby'], array( 'post_count', 'display_name', 'registered' ), true ) ? $atts['orderby'] : 'post_count',\n            'order'               => 'post_count' === $atts['orderby'] ? 'DESC' : 'ASC',\n            'number'              => 50,\n        ) );\n        if ( ! $users ) {\n            return '';\n        }\n\n        $counts = count_many_users_posts( wp_list_pluck( $users, 'ID' ), sanitize_key( $atts['post_type'] ), true );\n        $html   = '<ul class=\"wpide-authors-list\" style=\"list-style:none;padding:0;display:grid;gap:1.25rem\">';\n        foreach ( $users as $user ) {\n            $bio   = 'yes' === $atts['bio'] ? get_the_author_meta( 'description', $user->ID ) : '';\n            $html .= sprintf(\n                '<li style=\"display:flex;gap:1rem;align-items:flex-start\">%s<div><a href=\"%s\"><strong>%s</strong></a> <small>(%s)</small>%s</div></li>',\n                get_avatar( $user->ID, 64, '', '', array( 'extra_attr' => 'style=\"border-radius:50%;flex:none\" loading=\"lazy\"' ) ),\n                esc_url( get_author_posts_url( $user->ID ) ),\n                esc_html( $user->display_name ),\n                esc_html( sprintf( _n( '%s post', '%s posts', (int) $counts[ $user->ID ] ), number_format_i18n( (int) $counts[ $user->ID ] ) ) ),\n                $bio ? '<p style=\"margin:.3em 0 0\">' . esc_html( wp_trim_words( $bio, 30 ) ) . '</p>' : ''\n            );\n        }\n        return $html . '</ul>';\n    } );\n} );",
            "requires": [],
            "notes": "Example [authors_list orderby=\"post_count\" bio=\"yes\"]; users with no published posts are left out."
        },
        {
            "id": "on-this-day-shortcode",
            "version": 1,
            "title": "\"On This Day\" Posts Shortcode",
            "description": "Adds an [on_this_day] shortcode that lists posts published on today's date in previous years, a simple way to resurface archive content on the homepage or in a sidebar.",
            "category": "content",
            "tags": [
                "shortcodes",
                "archives",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    add_shortcode( 'on_this_day', function ( $atts ) {\n        $atts = shortcode_atts( array( 'count' => 5, 'text' => 'On this day in past years:' ), $atts, 'on_this_day' );\n        $now  = current_datetime();\n\n        $cache_key = 'wpide_otd_' . $now->format( 'Ymd' ) . '_' . (int) $atts['count'];\n        $ids       = get_transient( $cache_key );\n        if ( false === $ids ) {\n            $ids = get_posts( array(\n                'post_type'      => 'post',\n                'posts_per_page' => max( 1, min( 20, (int) $atts['count'] ) ),\n                'fields'         => 'ids',\n                'no_found_rows'  => true,\n                'date_query'     => array(\n                    array( 'month' => (int) $now->format( 'n' ), 'day' => (int) $now->format( 'j' ) ),\n                    array( 'before' => $now->format( 'Y' ) . '-01-01' ),\n                ),\n            ) );\n            set_transient( $cache_key, $ids, strtotime( 'tomorrow', $now->getTimestamp() ) - $now->getTimestamp() + 60 );\n        }\n\n        if ( ! $ids ) {\n            return '';\n        }\n\n        $html = '<div class=\"wpide-on-this-day\"><p>' . esc_html( $atts['text'] ) . '</p><ul>';\n        foreach ( $ids as $id ) {\n            $html .= sprintf( '<li><a href=\"%s\">%s</a> <small>(%s)</small></li>', esc_url( get_permalink( $id ) ), esc_html( get_the_title( $id ) ), esc_html( get_the_date( 'Y', $id ) ) );\n        }\n        return $html . '</ul></div>';\n    } );\n} );",
            "requires": [],
            "notes": "Shows nothing on days with no matches; add text=\"...\" to change the intro line. The list is cached until midnight."
        },
        {
            "id": "search-result-preview-panel",
            "version": 1,
            "title": "Search Result Preview in the Block Editor",
            "description": "Adds a panel to the editor sidebar that previews how the post may look as a search engine result, with its title, address and description, updating live as you edit the title and excerpt.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "seo",
                "preview"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-serp-preview', false, array( 'wp-plugins', 'wp-element', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-serp-preview' );\n    wp_add_inline_script( 'wpide-serp-preview', 'var wpideSerp = ' . wp_json_encode( array( 'site' => get_bloginfo( 'name' ), 'home' => home_url( '/' ) ) ) . ';' . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginDocumentSettingPanel ) || ( wp.editPost && wp.editPost.PluginDocumentSettingPanel );\n    if ( ! Panel ) { return; }\n\n    function clip( text, max ) { return text.length > max ? text.slice( 0, max - 1 ).replace( /\\s+\\S*$/, '' ) + '…' : text; }\n\n    function SerpPreview() {\n        var data = wp.data.useSelect( function ( select ) {\n            var editor = select( 'core/editor' );\n            return {\n                title: editor.getEditedPostAttribute( 'title' ) || '',\n                excerpt: editor.getEditedPostAttribute( 'excerpt' ) || '',\n                content: editor.getEditedPostAttribute( 'content' ) || '',\n                link: editor.getPermalink() || wpideSerp.home\n            };\n        }, [] );\n        var text = ( data.excerpt || data.content ).replace( /<[^>]+>/g, ' ' ).replace( /\\s+/g, ' ' ).trim();\n        var url = data.link.replace( /^https?:\\/\\//, '' ).replace( /\\/$/, '' ).split( '/' ).join( ' › ' );\n\n        return el( Panel, { name: 'wpide-serp-preview', title: 'Search result preview' },\n            el( 'div', { style: { fontFamily: 'arial, sans-serif', lineHeight: 1.4 } },\n                el( 'div', { style: { fontSize: '12px', color: '#4d5156' } }, wpideSerp.site + ' · ' + url ),\n                el( 'div', { style: { fontSize: '17px', color: '#1a0dab', margin: '2px 0' } }, clip( ( data.title || '(no title)' ) + ' - ' + wpideSerp.site, 62 ) ),\n                el( 'div', { style: { fontSize: '13px', color: '#4d5156' } }, clip( text || 'Add an excerpt to control this text.', 158 ) )\n            )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-serp-preview', { render: SerpPreview } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Uses the excerpt (or the opening text) as the description; SEO plugins with their own preview may make this redundant."
        },
        {
            "id": "block-more-from-author",
            "version": 1,
            "title": "\"More From This Author\" Block",
            "description": "Registers a server-rendered block that lists the latest posts by the current post's author, excluding the post being read, to keep readers with writers they like.",
            "category": "gutenberg",
            "tags": [
                "blocks",
                "authors",
                "related-posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    wp_register_script( 'wpide-block-more-from-author', false, array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-server-side-render' ), '1.0', true );\n    wp_add_inline_script( 'wpide-block-more-from-author', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    wp.blocks.registerBlockType( 'wpide/more-from-author', {\n        apiVersion: 3,\n        title: 'More From This Author',\n        icon: 'admin-users',\n        category: 'theme',\n        usesContext: [ 'postId' ],\n        attributes: { count: { type: 'number', default: 3 } },\n        edit: function ( props ) {\n            return el( 'div', wp.blockEditor.useBlockProps(),\n                el( wp.blockEditor.InspectorControls, null,\n                    el( wp.components.PanelBody, { title: 'Settings' },\n                        el( wp.components.RangeControl, { label: 'Number of posts', min: 1, max: 10, value: props.attributes.count, onChange: function ( v ) { props.setAttributes( { count: v } ); } } )\n                    )\n                ),\n                el( wp.serverSideRender, { block: 'wpide/more-from-author', attributes: props.attributes, urlQueryArgs: { post_id: props.context.postId || 0 } } )\n            );\n        },\n        save: function () { return null; }\n    } );\n} )( window.wp );\nJS\n    );\n\n    register_block_type( 'wpide/more-from-author', array(\n        'api_version'           => 3,\n        'title'                 => 'More From This Author',\n        'category'              => 'theme',\n        'uses_context'          => array( 'postId' ),\n        'editor_script_handles' => array( 'wpide-block-more-from-author' ),\n        'attributes'            => array( 'count' => array( 'type' => 'number', 'default' => 3 ) ),\n        'render_callback'       => function ( $attributes, $content, $block ) {\n            $post_id = ! empty( $block->context['postId'] ) ? (int) $block->context['postId'] : get_the_ID();\n            $author  = (int) get_post_field( 'post_author', $post_id );\n            if ( ! $author ) {\n                return '';\n            }\n\n            $posts = get_posts( array(\n                'author'         => $author,\n                'post__not_in'   => array( $post_id ),\n                'posts_per_page' => max( 1, min( 10, (int) $attributes['count'] ) ),\n                'no_found_rows'  => true,\n            ) );\n            if ( ! $posts ) {\n                return '';\n            }\n\n            $items = '';\n            foreach ( $posts as $post ) {\n                $items .= sprintf( '<li><a href=\"%s\">%s</a></li>', esc_url( get_permalink( $post ) ), esc_html( get_the_title( $post ) ) );\n            }\n\n            return sprintf(\n                '<div %s><h2 class=\"wp-block-heading\">%s</h2><ul>%s</ul></div>',\n                get_block_wrapper_attributes( array( 'class' => 'wpide-more-from-author' ) ),\n                esc_html( sprintf( 'More from %s', get_the_author_meta( 'display_name', $author ) ) ),\n                $items\n            );\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Set how many posts to show in the block settings; place it in the single post template of a block theme or inside a post."
        },
        {
            "id": "patterns-from-folder",
            "version": 1,
            "title": "Load Block Patterns From a Folder of HTML Files",
            "description": "Registers every .html file in wp-content/patterns/ as a block pattern, using the file name as its title, so designers can add patterns by dropping in markup copied from the editor.",
            "category": "gutenberg",
            "tags": [
                "patterns",
                "block-editor",
                "files"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Folder with pattern files.\n$wpide_pattern_dir = WP_CONTENT_DIR . '/patterns';\n\nadd_action( 'init', function () use ( $wpide_pattern_dir ) {\n    $files = glob( trailingslashit( $wpide_pattern_dir ) . '*.html' );\n    if ( ! $files ) {\n        return;\n    }\n\n    register_block_pattern_category( 'wpide-site', array( 'label' => 'Site patterns' ) );\n\n    foreach ( $files as $file ) {\n        $slug    = sanitize_title( basename( $file, '.html' ) );\n        $content = (string) file_get_contents( $file );\n        if ( '' === trim( $content ) ) {\n            continue;\n        }\n        register_block_pattern( 'wpide-site/' . $slug, array(\n            'title'      => ucwords( str_replace( '-', ' ', $slug ) ),\n            'content'    => $content,\n            'categories' => array( 'wpide-site' ),\n        ) );\n    }\n} );",
            "requires": [],
            "notes": "Copy blocks in the editor (Options > Copy), paste them into a file such as hero-banner.html, and it appears under \"Site patterns\" in the inserter."
        },
        {
            "id": "content-only-editing-for-editors",
            "version": 1,
            "title": "Content-Only Editing of Pages for Non-Administrators",
            "description": "Locks the layout of pages in the block editor for everyone except administrators, so editors can change text and images but cannot move, delete or restyle blocks and break the design.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "permissions",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types whose layout is locked.\n$wpide_content_only_types = array( 'page' );\n\nadd_filter( 'block_editor_settings_all', function ( $settings, $context ) use ( $wpide_content_only_types ) {\n    if ( current_user_can( 'manage_options' ) || empty( $context->post ) || ! in_array( $context->post->post_type, $wpide_content_only_types, true ) ) {\n        return $settings;\n    }\n\n    $settings['templateLock'] = 'contentOnly';\n\n    return $settings;\n}, 10, 2 );",
            "requires": [],
            "notes": "Applies to the post types in $wpide_content_only_types; administrators edit normally."
        },
        {
            "id": "excerpt-length-counter",
            "version": 1,
            "title": "Excerpt Length Counter in the Block Editor",
            "description": "Shows a live character and word count under a short preview of the excerpt in the editor sidebar, colored when it is too short or too long for search results and post cards.",
            "category": "gutenberg",
            "tags": [
                "block-editor",
                "excerpts",
                "writing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Ideal excerpt length in characters.\n$wpide_excerpt_min = 120;\n$wpide_excerpt_max = 160;\n\nadd_action( 'enqueue_block_editor_assets', function () use ( $wpide_excerpt_min, $wpide_excerpt_max ) {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || ! post_type_supports( $screen->post_type, 'excerpt' ) ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-excerpt-counter', false, array( 'wp-plugins', 'wp-element', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-excerpt-counter' );\n    wp_add_inline_script( 'wpide-excerpt-counter', sprintf( 'var wpideExcerptLimits = { min: %d, max: %d };', $wpide_excerpt_min, $wpide_excerpt_max ) . <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPostStatusInfo ) || ( wp.editPost && wp.editPost.PluginPostStatusInfo );\n    if ( ! Panel ) { return; }\n\n    function ExcerptCounter() {\n        var excerpt = wp.data.useSelect( function ( select ) { return select( 'core/editor' ).getEditedPostAttribute( 'excerpt' ) || ''; }, [] );\n        var chars = excerpt.trim().length;\n        var words = excerpt.trim() ? excerpt.trim().split( /\\s+/ ).length : 0;\n        var color = ! chars ? '#757575' : ( chars < wpideExcerptLimits.min || chars > wpideExcerptLimits.max ? '#d63638' : '#00a32a' );\n        var hint = ! chars ? 'No excerpt yet' : ( chars < wpideExcerptLimits.min ? 'a little short' : ( chars > wpideExcerptLimits.max ? 'may be cut off' : 'good length' ) );\n\n        return el( Panel, null,\n            el( 'div', { style: { display: 'flex', justifyContent: 'space-between', width: '100%' } },\n                el( 'span', null, 'Excerpt' ),\n                el( 'span', { style: { color: color } }, chars + ' chars, ' + words + ' words (' + hint + ')' )\n            )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-excerpt-counter', { render: ExcerptCounter } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Adjust $wpide_excerpt_min and $wpide_excerpt_max (characters) to match your theme's cards."
        },
        {
            "id": "media-upload-guidance",
            "version": 1,
            "title": "Image Guidelines Note in the Upload Screens",
            "description": "Shows a short note with your image rules, such as recommended sizes, file types and the need for alt text, right inside the media uploader and Media > Add New screen.",
            "category": "media",
            "tags": [
                "uploads",
                "guidelines",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Guidance shown under the upload area.\n$wpide_upload_guidance = '<strong>Before uploading:</strong> photos at least 1600px wide, under 500 KB, JPEG or WebP. Name files descriptively and always fill in the alt text.';\n\nadd_action( 'post-upload-ui', function () use ( $wpide_upload_guidance ) {\n    printf(\n        '<p class=\"wpide-upload-guidance\" style=\"max-width:600px;margin:1em auto;padding:.75em 1em;background:#f0f6fc;border-left:4px solid #2271b1;text-align:left\">%s</p>',\n        wp_kses( $wpide_upload_guidance, array( 'strong' => array(), 'em' => array(), 'a' => array( 'href' => true, 'target' => true ), 'br' => array() ) )\n    );\n} );",
            "requires": [],
            "notes": "Edit $wpide_upload_guidance; basic HTML such as strong and a links is allowed."
        },
        {
            "id": "media-export-list-csv",
            "version": 1,
            "title": "Export the Media Library List to CSV",
            "description": "Adds an \"Export to CSV\" bulk action to the Media Library list view that downloads the selected files with their URL, title, alt text, caption, type, size, dimensions and upload date.",
            "category": "media",
            "tags": [
                "media-library",
                "export",
                "csv"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_filter( 'bulk_actions-upload', function ( $actions ) {\n    $actions['wpide_export_media'] = 'Export to CSV';\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-upload', function ( $redirect, $action, $ids ) {\n    if ( 'wpide_export_media' !== $action || ! current_user_can( 'upload_files' ) ) {\n        return $redirect;\n    }\n\n    $rows = array( array( 'ID', 'URL', 'Title', 'Alt text', 'Caption', 'Type', 'File size', 'Width', 'Height', 'Uploaded' ) );\n    foreach ( $ids as $id ) {\n        if ( 'attachment' !== get_post_type( $id ) ) {\n            continue;\n        }\n        $meta   = wp_get_attachment_metadata( $id );\n        $file   = get_attached_file( $id );\n        $rows[] = array(\n            $id,\n            wp_get_attachment_url( $id ),\n            get_the_title( $id ),\n            get_post_meta( $id, '_wp_attachment_image_alt', true ),\n            wp_get_attachment_caption( $id ),\n            get_post_mime_type( $id ),\n            ! empty( $meta['filesize'] ) ? $meta['filesize'] : ( $file && file_exists( $file ) ? filesize( $file ) : '' ),\n            $meta['width'] ?? '',\n            $meta['height'] ?? '',\n            get_the_date( 'Y-m-d', $id ),\n        );\n    }\n\n    if ( defined( 'WP_CLI' ) && WP_CLI ) {\n        return $redirect;\n    }\n\n    nocache_headers();\n    header( 'Content-Type: text/csv; charset=utf-8' );\n    header( 'Content-Disposition: attachment; filename=media-' . gmdate( 'Y-m-d' ) . '.csv' );\n    $out = fopen( 'php://output', 'w' );\n    fwrite( $out, \"\\xEF\\xBB\\xBF\" );\n    foreach ( $rows as $row ) {\n        fputcsv( $out, array_map( function ( $cell ) {\n            return is_string( $cell ) && preg_match( '/^[=+\\-@]/', $cell ) ? \"'\" . $cell : $cell;\n        }, $row ) );\n    }\n    fclose( $out );\n    exit;\n}, 10, 3 );",
            "requires": [],
            "notes": "Handy for accessibility audits and migrations; switch the library to list view to see bulk actions."
        },
        {
            "id": "media-download-selected-zip",
            "version": 1,
            "title": "Download Selected Media Files as a ZIP",
            "description": "Adds a \"Download as ZIP\" bulk action to the Media Library list view that packs the selected original files into one archive, handy for sending a batch of photos or documents to someone.",
            "category": "media",
            "tags": [
                "media-library",
                "download",
                "zip"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Largest total size to zip, in megabytes.\n$wpide_zip_limit_mb = 200;\n\nadd_filter( 'bulk_actions-upload', function ( $actions ) {\n    if ( class_exists( 'ZipArchive' ) ) {\n        $actions['wpide_zip_media'] = 'Download as ZIP';\n    }\n    return $actions;\n} );\n\nadd_filter( 'handle_bulk_actions-upload', function ( $redirect, $action, $ids ) use ( $wpide_zip_limit_mb ) {\n    if ( 'wpide_zip_media' !== $action || ! class_exists( 'ZipArchive' ) || ! current_user_can( 'upload_files' ) ) {\n        return $redirect;\n    }\n\n    $files = array();\n    $total = 0;\n    foreach ( $ids as $id ) {\n        $path = function_exists( 'wp_get_original_image_path' ) && wp_attachment_is_image( $id ) ? wp_get_original_image_path( $id ) : get_attached_file( $id );\n        if ( $path && is_readable( $path ) && current_user_can( 'read_post', $id ) ) {\n            $files[] = $path;\n            $total  += filesize( $path );\n        }\n    }\n\n    if ( ! $files || $total > $wpide_zip_limit_mb * MB_IN_BYTES ) {\n        return add_query_arg( 'wpide_zip', $files ? 'too_big' : 'empty', $redirect );\n    }\n    if ( defined( 'WP_CLI' ) && WP_CLI ) {\n        return $redirect;\n    }\n\n    $zip_path = wp_tempnam( 'media.zip' );\n    $zip      = new ZipArchive();\n    if ( true !== $zip->open( $zip_path, ZipArchive::OVERWRITE ) ) {\n        return add_query_arg( 'wpide_zip', 'failed', $redirect );\n    }\n    $used = array();\n    foreach ( $files as $path ) {\n        $name = wp_basename( $path );\n        // Avoid collisions between files with the same name in different months.\n        $name = isset( $used[ $name ] ) ? pathinfo( $name, PATHINFO_FILENAME ) . '-' . count( $used ) . '.' . pathinfo( $name, PATHINFO_EXTENSION ) : $name;\n        $used[ $name ] = true;\n        $zip->addFile( $path, $name );\n    }\n    $zip->close();\n\n    nocache_headers();\n    header( 'Content-Type: application/zip' );\n    header( 'Content-Disposition: attachment; filename=media-' . gmdate( 'Y-m-d' ) . '.zip' );\n    header( 'Content-Length: ' . filesize( $zip_path ) );\n    readfile( $zip_path );\n    wp_delete_file( $zip_path );\n    exit;\n}, 10, 3 );\n\nadd_action( 'admin_notices', function () use ( $wpide_zip_limit_mb ) {\n    if ( empty( $_GET['wpide_zip'] ) ) {\n        return;\n    }\n    $messages = array(\n        'too_big' => sprintf( 'The selected files are larger than %d MB. Select fewer files.', $wpide_zip_limit_mb ),\n        'empty'   => 'None of the selected files could be read.',\n        'failed'  => 'The ZIP file could not be created.',\n    );\n    $key = sanitize_key( $_GET['wpide_zip'] );\n    if ( isset( $messages[ $key ] ) ) {\n        printf( '<div class=\"notice notice-error is-dismissible\"><p>%s</p></div>', esc_html( $messages[ $key ] ) );\n    }\n} );",
            "requires": [],
            "notes": "Needs the PHP Zip extension; limited to 200 MB of files per download to protect the server."
        },
        {
            "id": "media-folders-per-user",
            "version": 1,
            "title": "Store Each User's Uploads in Their Own Folder",
            "description": "Saves uploads into a folder named after the uploader, such as uploads/users/jane/2025/03/, which keeps multi-author and client sites tidy and makes per-person cleanups easy.",
            "category": "media",
            "tags": [
                "uploads",
                "folders",
                "users"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "$wpide_user_folder = function ( $dirs ) {\n    $user = wp_get_current_user();\n    if ( ! $user->exists() ) {\n        return $dirs;\n    }\n\n    $folder = '/users/' . sanitize_file_name( $user->user_nicename );\n    if ( 0 === strpos( $dirs['subdir'], $folder ) ) {\n        return $dirs;\n    }\n\n    $dirs['subdir'] = $folder . $dirs['subdir'];\n    $dirs['path']   = $dirs['basedir'] . $dirs['subdir'];\n    $dirs['url']    = $dirs['baseurl'] . $dirs['subdir'];\n\n    return $dirs;\n};\n\n// Only change the folder while a file is being uploaded.\n$wpide_user_folder_on = function ( $file ) use ( $wpide_user_folder ) {\n    add_filter( 'upload_dir', $wpide_user_folder );\n    return $file;\n};\n$wpide_user_folder_off = function ( $upload ) use ( $wpide_user_folder ) {\n    remove_filter( 'upload_dir', $wpide_user_folder );\n    return $upload;\n};\nadd_filter( 'wp_handle_upload_prefilter', $wpide_user_folder_on );\nadd_filter( 'wp_handle_sideload_prefilter', $wpide_user_folder_on );\nadd_filter( 'wp_handle_upload', $wpide_user_folder_off );\nadd_filter( 'wp_handle_sideload', $wpide_user_folder_off );",
            "requires": [],
            "notes": "Only affects new uploads; existing files stay where they are. Uses the user's nicename, never their email."
        },
        {
            "id": "media-register-oembed-provider",
            "version": 1,
            "title": "Add an Embed Provider WordPress Does Not Support",
            "description": "Teaches WordPress to embed links from an extra service that offers oEmbed, such as a podcast host, video platform or design tool, so pasting its URL on its own line shows the player.",
            "category": "media",
            "tags": [
                "embeds",
                "oembed",
                "media"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// URL pattern (regex) and the service's oEmbed endpoint.\n$wpide_oembed_pattern  = '#https?://(www\\.)?example-video\\.com/watch/.*#i';\n$wpide_oembed_endpoint = 'https://example-video.com/oembed';\n\nadd_action( 'init', function () use ( $wpide_oembed_pattern, $wpide_oembed_endpoint ) {\n    wp_oembed_add_provider( $wpide_oembed_pattern, $wpide_oembed_endpoint, true );\n} );",
            "requires": [],
            "notes": "Replace the example with the service's URL pattern and oEmbed endpoint from its documentation; only add services you trust, as they inject HTML into your pages."
        },
        {
            "id": "media-protect-images-in-use",
            "version": 1,
            "title": "Protect Featured Images From Deletion",
            "description": "Stops anyone from permanently deleting an image while it is still the featured image of a post or page, and shows which post uses it, so articles never end up with a missing picture.",
            "category": "media",
            "tags": [
                "media-library",
                "featured-image",
                "safety"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'pre_delete_attachment', function ( $delete, $post ) {\n    global $wpdb;\n\n    if ( ! $post instanceof WP_Post || 'attachment' !== $post->post_type ) {\n        return $delete;\n    }\n\n    $used_by = (int) $wpdb->get_var( $wpdb->prepare(\n        \"SELECT pm.post_id FROM {$wpdb->postmeta} pm INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id\n        WHERE pm.meta_key = '_thumbnail_id' AND pm.meta_value = %d AND p.post_status NOT IN ('trash','auto-draft') AND p.post_type <> 'revision' LIMIT 1\",\n        $post->ID\n    ) );\n\n    if ( ! $used_by ) {\n        return $delete;\n    }\n\n    if ( wp_doing_ajax() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || ( defined( 'WP_CLI' ) && WP_CLI ) ) {\n        return false; // The deletion simply fails.\n    }\n\n    wp_die(\n        esc_html( sprintf( 'This image is the featured image of \"%s\". Choose another featured image before deleting it.', get_the_title( $used_by ) ) ),\n        'Image in use',\n        array( 'response' => 409, 'back_link' => true )\n    );\n}, 10, 2 );",
            "requires": [],
            "notes": "Remove the image as featured image first (or use another), then it can be deleted."
        },
        {
            "id": "dash-writing-stats-widget",
            "version": 1,
            "title": "Your Writing Stats Widget",
            "description": "Adds a personal dashboard widget showing how many posts and words you published this month and this year, plus your current number of drafts, a small motivation boost for writers.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "writing",
                "statistics"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'edit_posts' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_writing_stats', 'Your writing stats', function () {\n        global $wpdb;\n\n        $user_id = get_current_user_id();\n        $stats   = get_transient( 'wpide_writing_stats_' . $user_id );\n\n        if ( false === $stats ) {\n            $year_start  = wp_date( 'Y-01-01 00:00:00' );\n            $month_start = wp_date( 'Y-m-01 00:00:00' );\n            $rows        = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT post_date, post_content FROM {$wpdb->posts} WHERE post_author = %d AND post_type = 'post' AND post_status = 'publish' AND post_date >= %s\",\n                $user_id,\n                $year_start\n            ) );\n            $stats = array( 'month_posts' => 0, 'month_words' => 0, 'year_posts' => 0, 'year_words' => 0 );\n            foreach ( $rows as $row ) {\n                $words = str_word_count( wp_strip_all_tags( strip_shortcodes( $row->post_content ) ) );\n                $stats['year_posts']++;\n                $stats['year_words'] += $words;\n                if ( $row->post_date >= $month_start ) {\n                    $stats['month_posts']++;\n                    $stats['month_words'] += $words;\n                }\n            }\n            $stats['drafts'] = (int) $wpdb->get_var( $wpdb->prepare( \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_author = %d AND post_type = 'post' AND post_status = 'draft'\", $user_id ) );\n            set_transient( 'wpide_writing_stats_' . $user_id, $stats, HOUR_IN_SECONDS );\n        }\n\n        $cells = array(\n            'Posts this month' => $stats['month_posts'],\n            'Words this month' => $stats['month_words'],\n            'Posts this year'  => $stats['year_posts'],\n            'Words this year'  => $stats['year_words'],\n            'Drafts waiting'   => $stats['drafts'],\n        );\n        echo '<div style=\"display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:8px\">';\n        foreach ( $cells as $label => $value ) {\n            printf( '<div style=\"border:1px solid #dcdcde;border-radius:4px;padding:10px;text-align:center\"><div style=\"font-size:20px;font-weight:600\">%s</div><div style=\"color:#646970\">%s</div></div>', esc_html( number_format_i18n( $value ) ), esc_html( $label ) );\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Words are counted from published post content; figures are cached per user for an hour."
        },
        {
            "id": "dash-comments-trend-chart",
            "version": 1,
            "title": "Comments per Day Chart Widget",
            "description": "Adds a dashboard widget with a simple bar chart of approved comments for each of the last 30 days, so you can see at a glance whether reader engagement is growing.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "comments",
                "chart"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'wp_dashboard_setup', function () {\n    if ( ! current_user_can( 'moderate_comments' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_comments_trend', 'Comments in the last 30 days', function () {\n        global $wpdb;\n\n        $rows = get_transient( 'wpide_comments_trend' );\n        if ( false === $rows ) {\n            $rows = $wpdb->get_results( $wpdb->prepare(\n                \"SELECT DATE(comment_date) AS day, COUNT(*) AS total FROM {$wpdb->comments}\n                WHERE comment_approved = '1' AND comment_type IN ('comment','') AND comment_date >= %s GROUP BY day\",\n                wp_date( 'Y-m-d 00:00:00', strtotime( '-29 days', current_time( 'timestamp' ) ) )\n            ), OBJECT_K );\n            set_transient( 'wpide_comments_trend', $rows, HOUR_IN_SECONDS );\n        }\n\n        $days = array();\n        for ( $i = 29; $i >= 0; $i-- ) {\n            $day          = wp_date( 'Y-m-d', strtotime( \"-$i days\", current_time( 'timestamp' ) ) );\n            $days[ $day ] = isset( $rows[ $day ] ) ? (int) $rows[ $day ]->total : 0;\n        }\n        $max   = max( 1, max( $days ) );\n        $total = array_sum( $days );\n\n        printf( '<p><strong>%s</strong> comments in 30 days</p>', esc_html( number_format_i18n( $total ) ) );\n        echo '<div style=\"display:flex;align-items:flex-end;gap:2px;height:80px\" role=\"img\" aria-label=\"' . esc_attr( sprintf( '%d comments in the last 30 days', $total ) ) . '\">';\n        foreach ( $days as $day => $count ) {\n            printf(\n                '<div title=\"%s: %d\" style=\"flex:1;height:%d%%;min-height:2px;background:%s;border-radius:2px 2px 0 0\"></div>',\n                esc_attr( mysql2date( get_option( 'date_format' ), $day ) ),\n                (int) $count,\n                (int) round( $count / $max * 100 ),\n                $count ? '#2271b1' : '#dcdcde'\n            );\n        }\n        echo '</div>';\n    } );\n} );",
            "requires": [],
            "notes": "Pure HTML and CSS; data is cached for an hour."
        },
        {
            "id": "dash-external-dashboard-embed",
            "version": 1,
            "title": "Embed an External Analytics Dashboard",
            "description": "Adds a dashboard widget that shows a shared dashboard from another service, such as a public Plausible, Looker Studio or uptime status page, so clients see their numbers without another login.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "analytics",
                "embed"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Public embed or share link (https only).\n$wpide_embed_url    = 'https://plausible.io/share/example.com?auth=YOUR_SHARED_LINK_KEY&embed=true&theme=light';\n$wpide_embed_title  = 'Site analytics';\n$wpide_embed_height = 600;\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_embed_url, $wpide_embed_title, $wpide_embed_height ) {\n    if ( ! current_user_can( 'edit_posts' ) || 0 !== strpos( $wpide_embed_url, 'https://' ) ) {\n        return;\n    }\n\n    wp_add_dashboard_widget( 'wpide_external_embed', $wpide_embed_title, function () use ( $wpide_embed_url, $wpide_embed_title, $wpide_embed_height ) {\n        printf(\n            '<iframe src=\"%s\" title=\"%s\" loading=\"lazy\" referrerpolicy=\"no-referrer\" style=\"width:100%%;height:%dpx;border:0\"></iframe><p><a href=\"%s\" target=\"_blank\" rel=\"noopener\">Open in a new tab</a></p>',\n            esc_url( $wpide_embed_url ),\n            esc_attr( $wpide_embed_title ),\n            (int) $wpide_embed_height,\n            esc_url( $wpide_embed_url )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Paste the service's public share or embed link into $wpide_embed_url; the service must allow being shown in a frame."
        },
        {
            "id": "dash-launch-countdown-widget",
            "version": 1,
            "title": "Countdown to a Launch or Deadline Widget",
            "description": "Adds a dashboard widget counting down the days and hours to a date you set, such as a site launch, campaign start or content deadline, to keep the whole team focused.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "countdown",
                "deadlines"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Target date and time (site time zone) and its label.\n$wpide_countdown_date  = '2026-12-01 09:00';\n$wpide_countdown_label = 'Website launch';\n\nadd_action( 'wp_dashboard_setup', function () use ( $wpide_countdown_date, $wpide_countdown_label ) {\n    wp_add_dashboard_widget( 'wpide_launch_countdown', 'Countdown: ' . esc_html( $wpide_countdown_label ), function () use ( $wpide_countdown_date, $wpide_countdown_label ) {\n        $target = date_create_immutable( $wpide_countdown_date, wp_timezone() );\n        if ( ! $target ) {\n            echo '<p>Set a valid date in the snippet.</p>';\n            return;\n        }\n\n        $left = $target->getTimestamp() - time();\n        if ( $left <= 0 ) {\n            printf( '<p style=\"font-size:16px\"><strong>%s</strong> was on %s.</p>', esc_html( $wpide_countdown_label ), esc_html( wp_date( get_option( 'date_format' ), $target->getTimestamp() ) ) );\n            return;\n        }\n\n        $days  = (int) floor( $left / DAY_IN_SECONDS );\n        $hours = (int) floor( ( $left % DAY_IN_SECONDS ) / HOUR_IN_SECONDS );\n\n        printf(\n            '<div style=\"text-align:center;padding:10px 0\"><div style=\"font-size:40px;font-weight:700;line-height:1\">%d</div><div>days and %d hours until</div><div style=\"font-size:16px;font-weight:600;margin-top:4px\">%s</div><div style=\"color:#646970\">%s</div></div>',\n            $days,\n            $hours,\n            esc_html( $wpide_countdown_label ),\n            esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $target->getTimestamp() ) )\n        );\n    } );\n} );",
            "requires": [],
            "notes": "Set the date and time in your site's time zone; after the date passes the widget shows a \"done\" message."
        },
        {
            "id": "dash-since-last-visit-widget",
            "version": 1,
            "title": "\"Since Your Last Visit\" Dashboard Widget",
            "description": "Adds a dashboard widget listing what happened since you last logged in to the admin: new posts by others, new comments and new users, so you catch up in seconds.",
            "category": "dashboard",
            "tags": [
                "dashboard-widget",
                "activity",
                "catch-up"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "add_action( 'admin_init', function () {\n    if ( wp_doing_ajax() ) {\n        return;\n    }\n    $user_id = get_current_user_id();\n    $last    = (int) get_user_meta( $user_id, 'wpide_last_admin_visit', true );\n\n    // Move the \"previous visit\" marker forward after an hour away.\n    if ( $last < time() - HOUR_IN_SECONDS ) {\n        update_user_meta( $user_id, 'wpide_prev_admin_visit', $last ? $last : time() );\n        update_user_meta( $user_id, 'wpide_last_admin_visit', time() );\n    }\n} );\n\nadd_action( 'wp_dashboard_setup', function () {\n    wp_add_dashboard_widget( 'wpide_since_last_visit', 'Since your last visit', function () {\n        $since = (int) get_user_meta( get_current_user_id(), 'wpide_prev_admin_visit', true );\n        if ( ! $since ) {\n            echo '<p>Welcome! Activity will appear here from your next visit.</p>';\n            return;\n        }\n        $after = gmdate( 'Y-m-d H:i:s', $since );\n\n        $posts = get_posts( array(\n            'post_type'      => array( 'post', 'page' ),\n            'posts_per_page' => 8,\n            'no_found_rows'  => true,\n            'author__not_in' => array( get_current_user_id() ),\n            'date_query'     => array( array( 'column' => 'post_date_gmt', 'after' => $after ) ),\n        ) );\n        $comments = get_comments( array( 'status' => 'approve', 'count' => true, 'date_query' => array( array( 'column' => 'comment_date_gmt', 'after' => $after ) ) ) );\n        $pending  = get_comments( array( 'status' => 'hold', 'count' => true, 'date_query' => array( array( 'column' => 'comment_date_gmt', 'after' => $after ) ) ) );\n        $users    = current_user_can( 'list_users' ) ? count( get_users( array( 'fields' => 'ID', 'date_query' => array( array( 'column' => 'user_registered', 'after' => $after ) ) ) ) ) : 0;\n\n        printf( '<p>Since %s ago:</p><ul>', esc_html( human_time_diff( $since ) ) );\n        printf( '<li><strong>%d</strong> new comments%s</li>', (int) $comments, $pending ? ' (<a href=\"' . esc_url( admin_url( 'edit-comments.php?comment_status=moderated' ) ) . '\">' . (int) $pending . ' awaiting moderation</a>)' : '' );\n        if ( current_user_can( 'list_users' ) ) {\n            printf( '<li><strong>%d</strong> new users</li>', (int) $users );\n        }\n        printf( '<li><strong>%d</strong> new posts and pages by others</li></ul>', count( $posts ) );\n        if ( $posts ) {\n            echo '<ul style=\"margin-left:1em;list-style:disc\">';\n            foreach ( $posts as $post ) {\n                printf( '<li><a href=\"%s\">%s</a> <small>by %s</small></li>', esc_url( get_permalink( $post ) ), esc_html( get_the_title( $post ) ), esc_html( get_the_author_meta( 'display_name', $post->post_author ) ) );\n            }\n            echo '</ul>';\n        }\n    } );\n} );",
            "requires": [],
            "notes": "Your last visit is recorded at most once an hour, so short breaks do not reset the list."
        },
        {
            "id": "dev-app-passwords-on-local",
            "version": 1,
            "title": "Allow Application Passwords on Local Sites Without HTTPS",
            "description": "Turns on Application Passwords for local and development copies of the site that run on plain http, so you can test REST API integrations and apps locally the same way as in production.",
            "category": "developer",
            "tags": [
                "rest-api",
                "application-passwords",
                "local-development"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_is_application_passwords_available', function ( $available ) {\n    return in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ? true : $available;\n} );",
            "requires": [],
            "notes": "Only applies when WP_ENVIRONMENT_TYPE is local or development; production keeps WordPress's HTTPS requirement."
        },
        {
            "id": "dev-quiet-deprecation-log",
            "version": 1,
            "title": "Keep Plugin Deprecation Notices Out of debug.log",
            "description": "Stops PHP deprecation notices coming from plugins and themes from filling debug.log, so the real warnings and errors you are looking for stand out while debugging.",
            "category": "developer",
            "tags": [
                "debugging",
                "debug-log",
                "php"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Folders whose deprecation notices are silenced.\n$wpide_quiet_paths = array( WP_PLUGIN_DIR, get_theme_root() );\n\n$wpide_previous_handler = set_error_handler( function ( $errno, $errstr, $errfile = '', $errline = 0 ) use ( $wpide_quiet_paths, &$wpide_previous_handler ) {\n    if ( in_array( $errno, array( E_DEPRECATED, E_USER_DEPRECATED ), true ) ) {\n        foreach ( $wpide_quiet_paths as $path ) {\n            if ( 0 === strpos( (string) $errfile, $path ) ) {\n                return true; // Handled: do not log.\n            }\n        }\n    }\n    // Hand everything else to the previous handler, or to PHP itself.\n    return $wpide_previous_handler ? call_user_func( $wpide_previous_handler, $errno, $errstr, $errfile, $errline ) : false;\n} );",
            "requires": [],
            "notes": "Only E_DEPRECATED and E_USER_DEPRECATED from wp-content are silenced; WordPress core notices and all other errors are logged as before."
        },
        {
            "id": "cli-export-posts-json",
            "version": 1,
            "title": "Export Posts to a JSON File (WP-CLI)",
            "description": "Adds a \"wp export-json\" command that writes published posts with their title, slug, dates, content, excerpt, categories, tags, featured image and author to a JSON file, for static sites, apps or archiving.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "export",
                "json"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'export-json', function ( $args, $assoc_args ) {\n    $post_type = sanitize_key( $assoc_args['post_type'] ?? 'post' );\n    $limit     = isset( $assoc_args['limit'] ) ? max( 1, (int) $assoc_args['limit'] ) : -1;\n    $items     = array();\n    $paged     = 1;\n\n    do {\n        $posts = get_posts( array(\n            'post_type'      => $post_type,\n            'post_status'    => 'publish',\n            'posts_per_page' => -1 === $limit ? 200 : min( 200, $limit ),\n            'paged'          => $paged++,\n            'no_found_rows'  => true,\n            'orderby'        => 'ID',\n            'order'          => 'ASC',\n        ) );\n\n        foreach ( $posts as $post ) {\n            $items[] = array(\n                'id'       => $post->ID,\n                'title'    => get_the_title( $post ),\n                'slug'     => $post->post_name,\n                'url'      => get_permalink( $post ),\n                'date'     => get_post_time( 'c', true, $post ),\n                'modified' => get_post_modified_time( 'c', true, $post ),\n                'author'   => get_the_author_meta( 'display_name', $post->post_author ),\n                'excerpt'  => wp_strip_all_tags( get_the_excerpt( $post ) ),\n                'content'  => apply_filters( 'the_content', $post->post_content ),\n                'image'    => get_the_post_thumbnail_url( $post, 'full' ) ? get_the_post_thumbnail_url( $post, 'full' ) : null,\n                'terms'    => array(\n                    'categories' => wp_list_pluck( (array) get_the_category( $post->ID ), 'name' ),\n                    'tags'       => is_array( get_the_tags( $post->ID ) ) ? wp_list_pluck( get_the_tags( $post->ID ), 'name' ) : array(),\n                ),\n            );\n            if ( -1 !== $limit && count( $items ) >= $limit ) {\n                break 2;\n            }\n        }\n    } while ( count( $posts ) === 200 );\n\n    $json = wp_json_encode( $items, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );\n\n    if ( empty( $assoc_args['file'] ) ) {\n        WP_CLI::line( $json );\n        return;\n    }\n\n    if ( false === file_put_contents( $assoc_args['file'], $json ) ) {\n        WP_CLI::error( 'Could not write the file.' );\n    }\n    WP_CLI::success( sprintf( 'Exported %d items to %s.', count( $items ), $assoc_args['file'] ) );\n}, array(\n    'shortdesc' => 'Exports published posts as JSON.',\n    'synopsis'  => array(\n        array( 'type' => 'assoc', 'name' => 'post_type', 'optional' => true, 'default' => 'post' ),\n        array( 'type' => 'assoc', 'name' => 'limit', 'optional' => true ),\n        array( 'type' => 'assoc', 'name' => 'file', 'optional' => true, 'description' => 'Path of the JSON file to write.' ),\n    ),\n) );",
            "requires": [],
            "notes": "Example \"wp export-json --post_type=page --file=pages.json\"; without --file the JSON is printed."
        },
        {
            "id": "rest-site-identity-endpoint",
            "version": 1,
            "title": "Site Identity REST Endpoint for Apps and Headless Sites",
            "description": "Adds a public /wp-json/site/v1/identity endpoint with the site name, tagline, logo, site icon, language and home URL, so headless front ends and apps can brand themselves from WordPress.",
            "category": "developer",
            "tags": [
                "rest-api",
                "headless",
                "branding"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/identity', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true',\n        'callback'            => function () {\n            $logo_id  = (int) get_theme_mod( 'custom_logo' );\n            $response = new WP_REST_Response( array(\n                'name'        => get_bloginfo( 'name' ),\n                'description' => get_bloginfo( 'description' ),\n                'url'         => home_url( '/' ),\n                'language'    => get_bloginfo( 'language' ),\n                'logo'        => $logo_id ? wp_get_attachment_image_url( $logo_id, 'full' ) : null,\n                'icon'        => get_site_icon_url( 512 ) ? get_site_icon_url( 512 ) : null,\n                'timezone'    => wp_timezone_string(),\n            ) );\n            $response->header( 'Cache-Control', 'public, max-age=3600' );\n            return $response;\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Only public information is returned; the response can be cached by browsers for an hour."
        },
        {
            "id": "dev-git-branch-admin-bar",
            "version": 1,
            "title": "Show the Current Git Branch in the Admin Bar",
            "description": "Shows which Git branch and commit the site or theme is running in the admin bar for administrators, so developers always know what code is deployed on each copy.",
            "category": "developer",
            "tags": [
                "git",
                "admin-bar",
                "deployment"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'admin_bar_menu', function ( $admin_bar ) {\n    if ( ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    foreach ( array( ABSPATH, trailingslashit( get_stylesheet_directory() ) ) as $root ) {\n        $head = $root . '.git/HEAD';\n        if ( ! is_readable( $head ) ) {\n            continue;\n        }\n\n        $ref = trim( (string) file_get_contents( $head ) );\n        if ( 0 === strpos( $ref, 'ref:' ) ) {\n            $branch = basename( trim( substr( $ref, 4 ) ) );\n            $file   = $root . '.git/' . trim( substr( $ref, 4 ) );\n            $commit = is_readable( $file ) ? substr( trim( (string) file_get_contents( $file ) ), 0, 7 ) : '';\n        } else {\n            $branch = 'detached';\n            $commit = substr( $ref, 0, 7 );\n        }\n\n        $admin_bar->add_node( array(\n            'id'     => 'wpide-git',\n            'parent' => 'top-secondary',\n            'title'  => '<span class=\"ab-icon dashicons dashicons-randomize\" style=\"margin-top:2px\" aria-hidden=\"true\"></span>' . esc_html( $branch . ( $commit ? ' @ ' . $commit : '' ) ),\n            'meta'   => array( 'title' => esc_attr( 'Git repository: ' . str_replace( ABSPATH, '/', $root ) ) ),\n        ) );\n        return;\n    }\n}, 100 );",
            "requires": [],
            "notes": "Looks for a .git folder in the site root, then in the active theme; nothing is shown when neither exists. No shell commands are run."
        },
        {
            "id": "dev-load-missing-media-from-live",
            "version": 1,
            "title": "Load Missing Media From the Live Site on Local Copies",
            "description": "On local, development and staging copies, images and files that are not in the local uploads folder are loaded from the live site instead, so you do not need to download gigabytes of uploads.",
            "category": "developer",
            "tags": [
                "local-development",
                "media",
                "staging"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Production site address, without a trailing slash.\n$wpide_live_url = 'https://www.example.com';\n\n$wpide_live_media = function ( $url ) use ( $wpide_live_url ) {\n    static $uploads = null;\n\n    if ( 'production' === wp_get_environment_type() || ! is_string( $url ) || '' === $url ) {\n        return $url;\n    }\n    if ( null === $uploads ) {\n        $uploads = wp_get_upload_dir();\n    }\n    if ( 0 !== strpos( $url, $uploads['baseurl'] ) ) {\n        return $url;\n    }\n\n    $relative = substr( $url, strlen( $uploads['baseurl'] ) );\n    $local    = $uploads['basedir'] . strtok( $relative, '?' );\n    if ( file_exists( $local ) ) {\n        return $url;\n    }\n\n    $live_base = untrailingslashit( $wpide_live_url ) . wp_parse_url( $uploads['baseurl'], PHP_URL_PATH );\n    return $live_base . $relative;\n};\n\nadd_filter( 'wp_get_attachment_url', $wpide_live_media );\n\nadd_filter( 'wp_calculate_image_srcset', function ( $sources ) use ( $wpide_live_media ) {\n    foreach ( (array) $sources as $width => $source ) {\n        $sources[ $width ]['url'] = $wpide_live_media( $source['url'] );\n    }\n    return $sources;\n} );\n\nadd_filter( 'the_content', function ( $content ) use ( $wpide_live_media ) {\n    if ( 'production' === wp_get_environment_type() || false === strpos( $content, '/uploads/' ) ) {\n        return $content;\n    }\n    return preg_replace_callback( '#(?<=[\"\\'\\s(])https?://[^\"\\'\\s)]+/uploads/[^\"\\'\\s)]+#i', function ( $match ) use ( $wpide_live_media ) {\n        return $wpide_live_media( $match[0] );\n    }, $content );\n}, 99 );",
            "requires": [],
            "notes": "Set $wpide_live_url to your production address; it never runs on production and only rewrites URLs whose file is missing locally."
        },
        {
            "id": "admin-copy-as-other-post-type",
            "version": 1,
            "title": "Copy a Post as a Page (or Any Other Post Type)",
            "description": "Adds a \"Copy as page\" link under posts, and \"Copy as post\" under pages, that creates a draft of the other type with the same content and featured image, while the original stays untouched.",
            "category": "admin",
            "tags": [
                "duplicate",
                "post-types",
                "row-actions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Source post type => target post type.\n$wpide_copy_targets = array(\n    'post' => 'page',\n    'page' => 'post',\n);\n\n$wpide_copy_link = function ( $actions, $post ) use ( $wpide_copy_targets ) {\n    $target = $wpide_copy_targets[ $post->post_type ] ?? '';\n    $object = $target ? get_post_type_object( $target ) : null;\n    if ( $object && current_user_can( $object->cap->create_posts ) && current_user_can( 'read_post', $post->ID ) ) {\n        $actions['wpide_copy_as'] = sprintf(\n            '<a href=\"%s\">Copy as %s</a>',\n            esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_copy_as&post=' . $post->ID . '&to=' . $target ), 'wpide_copy_as_' . $post->ID ) ),\n            esc_html( strtolower( $object->labels->singular_name ) )\n        );\n    }\n    return $actions;\n};\nadd_filter( 'post_row_actions', $wpide_copy_link, 10, 2 );\nadd_filter( 'page_row_actions', $wpide_copy_link, 10, 2 );\n\nadd_action( 'admin_post_wpide_copy_as', function () use ( $wpide_copy_targets ) {\n    $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;\n    check_admin_referer( 'wpide_copy_as_' . $post_id );\n\n    $post   = get_post( $post_id );\n    $target = isset( $_GET['to'] ) ? sanitize_key( $_GET['to'] ) : '';\n    $object = get_post_type_object( $target );\n    if ( ! $post || ! $object || ! in_array( $target, $wpide_copy_targets, true ) || ! current_user_can( $object->cap->create_posts ) || ! current_user_can( 'read_post', $post_id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    $new_id = wp_insert_post( wp_slash( array(\n        'post_type'    => $target,\n        'post_status'  => 'draft',\n        'post_title'   => $post->post_title,\n        'post_content' => $post->post_content,\n        'post_excerpt' => $post->post_excerpt,\n        'post_author'  => get_current_user_id(),\n    ) ), true );\n\n    if ( is_wp_error( $new_id ) ) {\n        wp_die( esc_html( $new_id->get_error_message() ) );\n    }\n    if ( has_post_thumbnail( $post ) && post_type_supports( $target, 'thumbnail' ) ) {\n        set_post_thumbnail( $new_id, get_post_thumbnail_id( $post ) );\n    }\n    foreach ( array_intersect( get_object_taxonomies( $post->post_type ), get_object_taxonomies( $target ) ) as $taxonomy ) {\n        wp_set_object_terms( $new_id, wp_get_object_terms( $post_id, $taxonomy, array( 'fields' => 'ids' ) ), $taxonomy );\n    }\n\n    wp_safe_redirect( get_edit_post_link( $new_id, 'url' ) );\n    exit;\n} );",
            "requires": [],
            "notes": "Change $wpide_copy_targets to offer other post types, for example 'post' => 'product'; categories and tags are only copied when the target type uses them."
        },
        {
            "id": "admin-inactive-plugins-reminder",
            "version": 1,
            "title": "Reminder to Remove Inactive Plugins",
            "description": "Shows administrators a gentle notice on the Dashboard and Plugins screens when plugins have been left inactive, since unused plugins still need updates and can be a security risk.",
            "category": "admin",
            "tags": [
                "plugins",
                "maintenance",
                "security"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "// Plugins that may stay inactive (folder/main-file.php).\n$wpide_inactive_allowed = array( 'hello.php' );\n\nadd_action( 'admin_notices', function () use ( $wpide_inactive_allowed ) {\n    $screen = get_current_screen();\n    if ( ! current_user_can( 'delete_plugins' ) || ! $screen || ! in_array( $screen->id, array( 'dashboard', 'plugins' ), true ) ) {\n        return;\n    }\n\n    if ( isset( $_GET['wpide_dismiss_inactive'] ) && check_admin_referer( 'wpide_dismiss_inactive' ) ) {\n        set_transient( 'wpide_inactive_dismissed_' . get_current_user_id(), 1, 30 * DAY_IN_SECONDS );\n    }\n    if ( get_transient( 'wpide_inactive_dismissed_' . get_current_user_id() ) ) {\n        return;\n    }\n\n    if ( ! function_exists( 'get_plugins' ) ) {\n        require_once ABSPATH . 'wp-admin/includes/plugin.php';\n    }\n    $inactive = array();\n    foreach ( get_plugins() as $file => $data ) {\n        if ( ! is_plugin_active( $file ) && ! in_array( $file, $wpide_inactive_allowed, true ) && ! is_plugin_active_for_network( $file ) ) {\n            $inactive[] = $data['Name'];\n        }\n    }\n    if ( ! $inactive ) {\n        return;\n    }\n\n    printf(\n        '<div class=\"notice notice-info\"><p><strong>%d inactive plugin(s):</strong> %s. If you no longer need them, deleting them reduces maintenance and risk. <a href=\"%s\">Review plugins</a> &middot; <a href=\"%s\">Dismiss for 30 days</a></p></div>',\n        count( $inactive ),\n        esc_html( implode( ', ', array_slice( $inactive, 0, 8 ) ) . ( count( $inactive ) > 8 ? '...' : '' ) ),\n        esc_url( admin_url( 'plugins.php?plugin_status=inactive' ) ),\n        esc_url( wp_nonce_url( add_query_arg( 'wpide_dismiss_inactive', 1 ), 'wpide_dismiss_inactive' ) )\n    );\n} );",
            "requires": [],
            "notes": "Add plugins you keep inactive on purpose to $wpide_inactive_allowed; the notice can be dismissed for 30 days."
        },
        {
            "id": "content-share-draft-link",
            "version": 1,
            "title": "Share a Draft With a Secret Link",
            "description": "Adds a \"Share draft\" link under unpublished posts that creates a secret preview address, so a client or reviewer can read the draft without a WordPress account. Links expire after a set number of days.",
            "category": "content",
            "tags": [
                "drafts",
                "preview",
                "collaboration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Days a shared link stays valid.\n$wpide_share_days = 7;\n\nadd_filter( 'post_row_actions', function ( $actions, $post ) {\n    if ( ! in_array( $post->post_status, array( 'draft', 'pending', 'future' ), true ) || ! current_user_can( 'edit_post', $post->ID ) ) {\n        return $actions;\n    }\n    $token   = get_post_meta( $post->ID, '_wpide_share_token', true );\n    $expires = (int) get_post_meta( $post->ID, '_wpide_share_expires', true );\n    if ( $token && $expires > time() ) {\n        $actions['wpide_share'] = sprintf(\n            '<a href=\"%s\" target=\"_blank\" rel=\"noopener\">Shared link</a> | <a href=\"%s\">Stop sharing</a>',\n            esc_url( add_query_arg( array( 'p' => $post->ID, 'wpide_share' => $token ), home_url( '/' ) ) ),\n            esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_share_draft&stop=1&post=' . $post->ID ), 'wpide_share_' . $post->ID ) )\n        );\n    } else {\n        $actions['wpide_share'] = sprintf( '<a href=\"%s\">Share draft</a>', esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=wpide_share_draft&post=' . $post->ID ), 'wpide_share_' . $post->ID ) ) );\n    }\n    return $actions;\n}, 10, 2 );\n\nadd_action( 'admin_post_wpide_share_draft', function () use ( $wpide_share_days ) {\n    $post_id = isset( $_GET['post'] ) ? absint( $_GET['post'] ) : 0;\n    check_admin_referer( 'wpide_share_' . $post_id );\n    if ( ! current_user_can( 'edit_post', $post_id ) ) {\n        wp_die( 'Not allowed.', 403 );\n    }\n\n    if ( ! empty( $_GET['stop'] ) ) {\n        delete_post_meta( $post_id, '_wpide_share_token' );\n        delete_post_meta( $post_id, '_wpide_share_expires' );\n    } else {\n        update_post_meta( $post_id, '_wpide_share_token', wp_generate_password( 24, false ) );\n        update_post_meta( $post_id, '_wpide_share_expires', time() + $wpide_share_days * DAY_IN_SECONDS );\n    }\n\n    wp_safe_redirect( wp_get_referer() ? wp_get_referer() : admin_url( 'edit.php' ) );\n    exit;\n} );\n\n// Let visitors with a valid token see the draft.\nadd_filter( 'posts_results', function ( $posts, $query ) {\n    if ( is_admin() || empty( $_GET['wpide_share'] ) || 1 !== count( $posts ) || ( ! $query->is_main_query() && ! $query->get( 'p' ) ) ) {\n        return $posts;\n    }\n\n    $post    = $posts[0];\n    $token   = (string) get_post_meta( $post->ID, '_wpide_share_token', true );\n    $expires = (int) get_post_meta( $post->ID, '_wpide_share_expires', true );\n\n    if ( '' !== $token && $expires > time() && hash_equals( $token, sanitize_text_field( wp_unslash( $_GET['wpide_share'] ) ) ) && in_array( $post->post_status, array( 'draft', 'pending', 'future' ), true ) ) {\n        $post->post_status = 'publish'; // In memory only, for this request.\n        add_filter( 'wp_robots', 'wp_robots_no_robots' );\n        nocache_headers();\n    }\n    return $posts;\n}, 10, 2 );\n\n// Drafts are not public, so the main query needs to be allowed to find them first.\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( ! is_admin() && $query->is_main_query() && ! empty( $_GET['wpide_share'] ) && $query->get( 'p' ) ) {\n        $query->set( 'post_status', array( 'publish', 'draft', 'pending', 'future' ) );\n    }\n} );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    if ( 'publish' === $new_status ) {\n        delete_post_meta( $post->ID, '_wpide_share_token' );\n        delete_post_meta( $post->ID, '_wpide_share_expires' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Change $wpide_share_days; publishing the post or clicking \"Stop sharing\" disables the link. Shared drafts are marked noindex."
        },
        {
            "id": "content-preview-banner",
            "version": 1,
            "title": "\"Preview\" Banner on Unpublished Content",
            "description": "Shows a clear banner at the top of the page whenever someone previews a draft, pending or scheduled post, so reviewers never mistake a preview for the live page or share it by accident.",
            "category": "content",
            "tags": [
                "preview",
                "drafts",
                "editorial"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_action( 'wp_body_open', function () {\n    if ( ! is_preview() || ! is_user_logged_in() ) {\n        return;\n    }\n\n    $post   = get_post();\n    $status = $post ? get_post_status_object( $post->post_status ) : null;\n    $text   = 'Preview: this page is not published yet';\n    if ( $post && 'future' === $post->post_status ) {\n        $text = 'Preview: scheduled for ' . get_the_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $post );\n    } elseif ( $status && 'publish' === $post->post_status ) {\n        $text = 'Preview of unsaved changes';\n    } elseif ( $status ) {\n        $text = 'Preview: ' . $status->label;\n    }\n\n    printf(\n        '<div role=\"status\" style=\"position:sticky;top:0;z-index:99997;padding:10px 16px;background:#dba617;color:#1d2327;font:600 14px/1.4 system-ui,sans-serif;text-align:center\">%s%s</div>',\n        esc_html( $text ),\n        $post && current_user_can( 'edit_post', $post->ID ) ? ' &middot; <a href=\"' . esc_url( get_edit_post_link( $post->ID ) ) . '\" style=\"color:inherit\">Back to editing</a>' : ''\n    );\n} );",
            "requires": [],
            "notes": "Only shown to logged-in users viewing a preview; scheduled posts also show their publish date."
        },
        {
            "id": "content-require-title",
            "version": 1,
            "title": "Require a Title Before Publishing",
            "description": "Prevents posts and pages from being published or scheduled without a title, avoiding \"(no title)\" entries in menus, feeds, search results and social shares.",
            "category": "content",
            "tags": [
                "editorial",
                "titles",
                "publishing"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types that need a title.\n$wpide_title_required_types = array( 'post', 'page' );\n\nforeach ( $wpide_title_required_types as $wpide_type ) {\n    add_filter( \"rest_pre_insert_{$wpide_type}\", function ( $prepared, $request ) {\n        $status = $request->get_param( 'status' ) ? $request->get_param( 'status' ) : get_post_status( (int) ( $prepared->ID ?? 0 ) );\n        if ( ! in_array( $status, array( 'publish', 'future' ), true ) ) {\n            return $prepared;\n        }\n        $title = null !== $request->get_param( 'title' ) ? $request->get_param( 'title' ) : get_post_field( 'post_title', (int) ( $prepared->ID ?? 0 ), 'raw' );\n        $title = is_array( $title ) ? ( $title['raw'] ?? '' ) : (string) $title;\n\n        return '' === trim( wp_strip_all_tags( $title ) )\n            ? new WP_Error( 'wpide_title_required', 'Please add a title before publishing.', array( 'status' => 400 ) )\n            : $prepared;\n    }, 10, 2 );\n}\n\n// Classic editor and other code paths.\nadd_filter( 'wp_insert_post_data', function ( $data ) use ( $wpide_title_required_types ) {\n    if ( in_array( $data['post_type'], $wpide_title_required_types, true ) && in_array( $data['post_status'], array( 'publish', 'future' ), true ) && '' === trim( wp_strip_all_tags( wp_unslash( $data['post_title'] ) ) ) && ! wp_is_serving_rest_request() ) {\n        $data['post_status'] = 'draft';\n    }\n    return $data;\n} );",
            "requires": [],
            "notes": "Drafts without a title can still be saved; add other post types to $wpide_title_required_types."
        },
        {
            "id": "content-hide-featured-image-per-post",
            "version": 1,
            "title": "Hide the Featured Image on Selected Posts",
            "description": "Adds a \"Hide featured image on this post\" checkbox to the editor, so a post can keep its image for archives, social shares and related posts but not repeat it at the top of the article.",
            "category": "content",
            "tags": [
                "featured-image",
                "editor",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    register_post_meta( '', '_wpide_hide_thumbnail', array(\n        'type'          => 'boolean',\n        'single'        => true,\n        'default'       => false,\n        'show_in_rest'  => true,\n        'auth_callback' => function ( $allowed, $key, $post_id ) {\n            return current_user_can( 'edit_post', $post_id );\n        },\n    ) );\n} );\n\n$wpide_hide_thumb_here = function ( $post_id ) {\n    // Only on the post's own page, so archives and related posts keep the image.\n    return is_singular() && (int) $post_id === get_queried_object_id() && get_post_meta( $post_id, '_wpide_hide_thumbnail', true );\n};\n\nadd_filter( 'post_thumbnail_html', function ( $html, $post_id ) use ( $wpide_hide_thumb_here ) {\n    return $wpide_hide_thumb_here( $post_id ) && in_the_loop() ? '' : $html;\n}, 10, 2 );\n\nadd_filter( 'render_block_core/post-featured-image', function ( $content, $block, $instance ) use ( $wpide_hide_thumb_here ) {\n    $post_id = isset( $instance->context['postId'] ) ? (int) $instance->context['postId'] : get_the_ID();\n    return $wpide_hide_thumb_here( $post_id ) ? '' : $content;\n}, 10, 3 );\n\nadd_action( 'enqueue_block_editor_assets', function () {\n    $screen = get_current_screen();\n    if ( ! $screen || 'post' !== $screen->base || ! post_type_supports( $screen->post_type, 'thumbnail' ) ) {\n        return;\n    }\n\n    wp_register_script( 'wpide-hide-thumb', false, array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-editor' ), '1.0', true );\n    wp_enqueue_script( 'wpide-hide-thumb' );\n    wp_add_inline_script( 'wpide-hide-thumb', <<<'JS'\n( function ( wp ) {\n    var el = wp.element.createElement;\n    var Panel = ( wp.editor && wp.editor.PluginPostStatusInfo ) || ( wp.editPost && wp.editPost.PluginPostStatusInfo );\n    if ( ! Panel ) { return; }\n\n    function HideThumb() {\n        var hidden = wp.data.useSelect( function ( select ) { return !! ( select( 'core/editor' ).getEditedPostAttribute( 'meta' ) || {} )._wpide_hide_thumbnail; }, [] );\n        var editPost = wp.data.useDispatch( 'core/editor' ).editPost;\n        return el( Panel, null,\n            el( wp.components.CheckboxControl, {\n                label: 'Hide featured image on this post',\n                checked: hidden,\n                onChange: function ( value ) { editPost( { meta: { _wpide_hide_thumbnail: value } } ); }\n            } )\n        );\n    }\n\n    wp.plugins.registerPlugin( 'wpide-hide-thumb', { render: HideThumb } );\n} )( window.wp );\nJS\n    );\n} );",
            "requires": [],
            "notes": "Works with themes that print the featured image with the standard functions and with the Post Featured Image block in block themes."
        },
        {
            "id": "content-https-links-in-content",
            "version": 1,
            "title": "Upgrade http:// Links to Your Own Site to https://",
            "description": "Rewrites old http:// links and images pointing to your own domain to https:// when content is displayed, fixing \"not secure\" mixed content warnings after moving to SSL without editing every post.",
            "category": "content",
            "tags": [
                "https",
                "mixed-content",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "$wpide_https_fix = function ( $content ) {\n    if ( ! is_ssl() && 'https' !== wp_parse_url( home_url(), PHP_URL_SCHEME ) ) {\n        return $content;\n    }\n    $host = wp_parse_url( home_url(), PHP_URL_HOST );\n    if ( ! $host || false === stripos( $content, 'http://' . $host ) && false === stripos( $content, 'http://www.' . $host ) ) {\n        return $content;\n    }\n    return preg_replace( '#http://((?:www\\.)?' . preg_quote( preg_replace( '/^www\\./', '', $host ), '#' ) . ')(?=[/\"\\'\\s:]|$)#i', 'https://$1', $content );\n};\n\nadd_filter( 'the_content', $wpide_https_fix, 99 );\nadd_filter( 'widget_text_content', $wpide_https_fix, 99 );\nadd_filter( 'widget_block_content', $wpide_https_fix, 99 );",
            "requires": [],
            "notes": "Only your own domain is changed; a database search and replace is still the permanent fix."
        },
        {
            "id": "rest-image-sizes-endpoint",
            "version": 1,
            "title": "Image Sizes REST Endpoint for Headless Front Ends",
            "description": "Adds a public /wp-json/site/v1/image-sizes endpoint listing every registered image size with its width, height and crop setting, so headless front ends can pick the right size for each layout.",
            "category": "developer",
            "tags": [
                "rest-api",
                "image-sizes",
                "headless"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'rest_api_init', function () {\n    register_rest_route( 'site/v1', '/image-sizes', array(\n        'methods'             => WP_REST_Server::READABLE,\n        'permission_callback' => '__return_true',\n        'callback'            => function () {\n            $sizes = array();\n            foreach ( wp_get_registered_image_subsizes() as $name => $size ) {\n                $sizes[] = array(\n                    'name'   => $name,\n                    'width'  => (int) $size['width'],\n                    'height' => (int) $size['height'],\n                    'crop'   => is_array( $size['crop'] ) ? $size['crop'] : (bool) $size['crop'],\n                );\n            }\n            $response = new WP_REST_Response( $sizes );\n            $response->header( 'Cache-Control', 'public, max-age=86400' );\n            return $response;\n        },\n    ) );\n} );",
            "requires": [],
            "notes": "Read-only and cached by browsers for a day; sizes added by themes and plugins are included."
        },
        {
            "id": "debug-block-markers-in-source",
            "version": 1,
            "title": "Mark Each Block in the Page Source for Administrators",
            "description": "Wraps every rendered block in HTML comments with its name, such as core/group or a plugin's block, when an administrator views a page with ?wpide_blocks=1, to see which block produced which markup.",
            "category": "developer",
            "tags": [
                "debugging",
                "blocks",
                "block-themes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "frontend_only",
            "insert_number": 1,
            "code": "add_filter( 'render_block', function ( $content, $block ) {\n    if ( empty( $block['blockName'] ) || ! isset( $_GET['wpide_blocks'] ) || ! current_user_can( 'manage_options' ) ) {\n        return $content;\n    }\n\n    $name  = sanitize_text_field( $block['blockName'] );\n    $attrs = ! empty( $block['attrs'] ) ? ' ' . wp_json_encode( array_keys( $block['attrs'] ) ) : '';\n\n    // Strip \"--\" so attribute names cannot close the comment early.\n    return \"\\n<!-- block: \" . str_replace( '--', '', $name . $attrs ) . \" -->\\n\" . $content . \"\\n<!-- /block: \" . str_replace( '--', '', $name ) . \" -->\\n\";\n}, 999, 2 );",
            "requires": [],
            "notes": "View the page source after adding ?wpide_blocks=1 to the address; visitors never see the comments."
        },
        {
            "id": "debug-php-errors-for-admins",
            "version": 1,
            "title": "Show PHP Errors to Administrators With ?debug=1",
            "description": "Lets administrators see PHP warnings and notices on screen by adding ?wpide_debug=1 to any address, while visitors and other users never see them, even with WP_DEBUG_DISPLAY off.",
            "category": "developer",
            "tags": [
                "debugging",
                "php",
                "errors"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'init', function () {\n    if ( ! isset( $_GET['wpide_debug'] ) || ! current_user_can( 'manage_options' ) ) {\n        return;\n    }\n\n    // phpcs:disable WordPress.PHP.IniSet, WordPress.PHP.DevelopmentFunctions\n    ini_set( 'display_errors', '1' );\n    error_reporting( E_ALL );\n    // phpcs:enable\n}, 1 );",
            "requires": [],
            "notes": "Errors raised before WordPress knows who is logged in (very early in loading) are not shown; check debug.log for those."
        },
        {
            "id": "admin-in-menus-column",
            "version": 1,
            "title": "\"In Menus\" Column for Pages",
            "description": "Adds a column to the Pages list showing which navigation menus link to each page, so editors know before renaming, moving or deleting a page that it appears in the site's menus.",
            "category": "admin",
            "tags": [
                "admin-columns",
                "menus",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "admin_only",
            "insert_number": 1,
            "code": "$wpide_menu_map = null;\n\nadd_filter( 'manage_page_posts_columns', function ( $columns ) {\n    $columns['wpide_in_menus'] = 'In menus';\n    return $columns;\n} );\n\nadd_action( 'manage_page_posts_custom_column', function ( $column, $post_id ) use ( &$wpide_menu_map ) {\n    if ( 'wpide_in_menus' !== $column ) {\n        return;\n    }\n\n    // Build the page => menus map once per screen.\n    if ( null === $wpide_menu_map ) {\n        $wpide_menu_map = array();\n        foreach ( wp_get_nav_menus() as $menu ) {\n            foreach ( (array) wp_get_nav_menu_items( $menu->term_id ) as $item ) {\n                if ( 'post_type' === $item->type && 'page' === $item->object ) {\n                    $wpide_menu_map[ (int) $item->object_id ][ $menu->term_id ] = $menu->name;\n                }\n            }\n        }\n    }\n\n    $menus = $wpide_menu_map[ (int) $post_id ] ?? array();\n    if ( ! $menus ) {\n        echo '&ndash;';\n        return;\n    }\n    $links = array();\n    foreach ( $menus as $menu_id => $name ) {\n        $links[] = current_user_can( 'edit_theme_options' )\n            ? '<a href=\"' . esc_url( admin_url( 'nav-menus.php?action=edit&menu=' . (int) $menu_id ) ) . '\">' . esc_html( $name ) . '</a>'\n            : esc_html( $name );\n    }\n    echo implode( ', ', $links ); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.\n}, 10, 2 );",
            "requires": [],
            "notes": "Covers classic menus (Appearance > Menus); links inside Navigation blocks of block themes are not listed."
        },
        {
            "id": "content-auto-tags-from-keywords",
            "version": 1,
            "title": "Tag Posts Automatically From a Keyword List",
            "description": "Adds tags to posts when they are saved if the title or content mentions keywords you define, for example tagging every post that mentions \"WooCommerce\" or \"Gutenberg\", so tagging stays consistent.",
            "category": "content",
            "tags": [
                "tags",
                "automation",
                "taxonomies"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Keyword (whole word) => tag name.\n$wpide_keyword_tags = array(\n    'woocommerce'  => 'WooCommerce',\n    'block editor' => 'Gutenberg',\n    'gutenberg'    => 'Gutenberg',\n    'seo'          => 'SEO',\n);\n\n// Runs after the editor has saved its own tags, so they are never overwritten.\nadd_action( 'wp_after_insert_post', function ( $post_id, $post ) use ( $wpide_keyword_tags ) {\n    if ( 'post' !== $post->post_type || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) || 'auto-draft' === $post->post_status ) {\n        return;\n    }\n\n    $text = wp_strip_all_tags( $post->post_title . ' ' . $post->post_content );\n    $add  = array();\n    foreach ( $wpide_keyword_tags as $keyword => $tag ) {\n        if ( preg_match( '/\\b' . preg_quote( $keyword, '/' ) . '\\b/i', $text ) ) {\n            $add[] = $tag;\n        }\n    }\n\n    if ( $add ) {\n        wp_set_post_tags( $post_id, array_unique( $add ), true ); // Append, keep existing tags.\n    }\n}, 20, 2 );",
            "requires": [],
            "notes": "Map keywords to tag names in $wpide_keyword_tags; matching is whole-word and case-insensitive, and existing tags are never removed."
        },
        {
            "id": "media-alt-from-photo-description",
            "version": 1,
            "title": "Use a Photo's Embedded Description as Its Alt Text",
            "description": "Fills the alt text of newly uploaded photos from the description or headline embedded by cameras, photo agencies and Lightroom (IPTC data), when it exists and no alt text is set.",
            "category": "media",
            "tags": [
                "alt-text",
                "iptc",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'wp_generate_attachment_metadata', function ( $metadata, $attachment_id, $context = 'create' ) {\n    if ( 'create' !== $context || ! wp_attachment_is_image( $attachment_id ) || '' !== (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true ) ) {\n        return $metadata;\n    }\n\n    $info = $metadata['image_meta'] ?? array();\n    $text = '';\n    foreach ( array( 'caption', 'title' ) as $field ) {\n        if ( ! empty( $info[ $field ] ) && ! preg_match( '/^(DSC|IMG|DCIM|P\\d{6,})[_-]?\\d*/i', $info[ $field ] ) ) {\n            $text = $info[ $field ];\n            break;\n        }\n    }\n\n    if ( '' !== trim( $text ) ) {\n        update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( wp_trim_words( $text, 25, '' ) ) );\n    }\n\n    return $metadata;\n}, 10, 3 );",
            "requires": [],
            "notes": "Agency captions can be long; the alt text is trimmed to 25 words. Photos without embedded text are left for you to describe."
        },
        {
            "id": "cli-find-http-links",
            "version": 1,
            "title": "Find Insecure http:// Links in Content (WP-CLI)",
            "description": "Adds a \"wp find-http-links\" command that lists posts containing http:// images, scripts or links, marking which point to your own domain, to clean up mixed content after moving to HTTPS.",
            "category": "developer",
            "tags": [
                "wp-cli",
                "https",
                "mixed-content"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! defined( 'WP_CLI' ) || ! WP_CLI ) {\n    return;\n}\n\nWP_CLI::add_command( 'find-http-links', function ( $args, $assoc_args ) {\n    global $wpdb;\n\n    $host  = (string) wp_parse_url( home_url(), PHP_URL_HOST );\n    $bare  = preg_replace( '/^www\\./', '', $host );\n    $items = array();\n\n    $rows = $wpdb->get_results(\n        \"SELECT ID, post_type, post_title, post_content FROM {$wpdb->posts}\n        WHERE post_status IN ('publish','draft','future','private','pending') AND post_type NOT IN ('revision','nav_menu_item')\n        AND ( post_content LIKE '%src=\\\"http://%' OR post_content LIKE '%href=\\\"http://%' OR post_content LIKE '%src=''http://%' OR post_content LIKE '%href=''http://%' )\"\n    );\n\n    foreach ( $rows as $row ) {\n        if ( ! preg_match_all( '/(?:src|href)=[\"\\']http:\\/\\/([^\\/\"\\']+)/i', $row->post_content, $matches ) ) {\n            continue;\n        }\n        $own   = 0;\n        $other = 0;\n        foreach ( $matches[1] as $domain ) {\n            if ( preg_replace( '/^www\\./', '', strtolower( $domain ) ) === strtolower( $bare ) ) {\n                $own++;\n            } else {\n                $other++;\n            }\n        }\n        if ( ! empty( $assoc_args['own'] ) && ! $own ) {\n            continue;\n        }\n        $items[] = array( 'ID' => $row->ID, 'type' => $row->post_type, 'own_domain' => $own, 'other_domains' => $other, 'title' => $row->post_title );\n    }\n\n    if ( ! $items ) {\n        WP_CLI::success( 'No http:// links found.' );\n        return;\n    }\n\n    WP_CLI\\Utils\\format_items( $assoc_args['format'] ?? 'table', $items, array( 'ID', 'type', 'own_domain', 'other_domains', 'title' ) );\n}, array(\n    'shortdesc' => 'Lists content with http:// links and images.',\n    'synopsis'  => array(\n        array( 'type' => 'flag', 'name' => 'own', 'optional' => true, 'description' => 'Only links to this site.' ),\n        array( 'type' => 'assoc', 'name' => 'format', 'optional' => true, 'default' => 'table', 'options' => array( 'table', 'csv', 'json', 'count' ) ),\n    ),\n) );",
            "requires": [],
            "notes": "Add --own to list only links to your own domain, which can usually be switched to https:// safely."
        },
        {
            "id": "media-image-size-for-one-post-type",
            "version": 1,
            "title": "Generate a Custom Image Size Only for One Post Type",
            "description": "Registers an image size, such as a wide portfolio banner, but only creates it for images uploaded to that post type, so every other upload is not stored in yet another size.",
            "category": "media",
            "tags": [
                "image-sizes",
                "post-types",
                "disk-space"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Image size and the only post type that gets it.\n$wpide_type_size_name      = 'portfolio-banner';\n$wpide_type_size_width     = 1600;\n$wpide_type_size_height    = 700;\n$wpide_type_size_post_type = 'portfolio';\n\nadd_action( 'after_setup_theme', function () use ( $wpide_type_size_name, $wpide_type_size_width, $wpide_type_size_height ) {\n    add_image_size( $wpide_type_size_name, $wpide_type_size_width, $wpide_type_size_height, true );\n} );\n\nadd_filter( 'intermediate_image_sizes_advanced', function ( $sizes, $metadata, $attachment_id = 0 ) use ( $wpide_type_size_name, $wpide_type_size_post_type ) {\n    $parent = $attachment_id ? (int) wp_get_post_parent_id( $attachment_id ) : 0;\n    if ( ! $parent && isset( $_REQUEST['post_id'] ) ) {\n        $parent = absint( $_REQUEST['post_id'] );\n    }\n\n    if ( ! $parent || get_post_type( $parent ) !== $wpide_type_size_post_type ) {\n        unset( $sizes[ $wpide_type_size_name ] );\n    }\n\n    return $sizes;\n}, 10, 3 );",
            "requires": [],
            "notes": "The size is created when you upload from inside a post of that type (the image is \"attached\" to it); set the name, dimensions and post type below."
        },
        {
            "id": "feed-cors-header",
            "version": 1,
            "title": "Let Other Sites Read Your Feeds With JavaScript",
            "description": "Sends a CORS header with your RSS and Atom feeds, so web apps, widgets and dashboards on other domains can load them with fetch() instead of hitting a browser error.",
            "category": "rss",
            "tags": [
                "feeds",
                "cors",
                "headers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Sites allowed to read your feeds with JavaScript. '*' allows any site; or list origins such as 'https://app.example.com'.\n$wpide_feed_cors_origins = array( '*' );\n\nadd_action( 'template_redirect', function () use ( $wpide_feed_cors_origins ) {\n    if ( ! is_feed() || headers_sent() ) {\n        return;\n    }\n\n    if ( in_array( '*', $wpide_feed_cors_origins, true ) ) {\n        header( 'Access-Control-Allow-Origin: *' );\n    } else {\n        $origin  = isset( $_SERVER['HTTP_ORIGIN'] ) ? untrailingslashit( esc_url_raw( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) ) : '';\n        $allowed = array_map( 'untrailingslashit', $wpide_feed_cors_origins );\n\n        header( 'Vary: Origin', false );\n\n        if ( '' === $origin || ! in_array( $origin, $allowed, true ) ) {\n            return;\n        }\n\n        header( 'Access-Control-Allow-Origin: ' . $origin );\n    }\n\n    header( 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS' );\n}, 1 );",
            "requires": [],
            "notes": "Set the origins list to specific sites, such as your app's address, to allow only them; feeds are public, so '*' is safe for most sites."
        },
        {
            "id": "redirect-feed-to-feed-service",
            "version": 1,
            "title": "Send Feed Readers to Your Feed Service",
            "description": "Redirects visitors and feed readers from your main feed to its address at a feed service such as FeedPress or Follow.it, while the service itself still reads your real feed.",
            "category": "rss",
            "tags": [
                "feeds",
                "redirects",
                "subscribers"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Your feed's address at the feed service (FeedPress, Follow.it, Feedburner ...). Leave empty to turn the redirect off.\n$wpide_feed_service_url = '';\n// Parts of the service's user agent; these requests get your real feed so the service can read it.\n$wpide_feed_service_agents = array( 'feedpress', 'feedburner', 'follow.it', 'feedvalidator' );\n\nadd_action( 'template_redirect', function () use ( $wpide_feed_service_url, $wpide_feed_service_agents ) {\n    if ( '' === $wpide_feed_service_url || ! is_feed() || is_comment_feed() || is_archive() || is_search() || is_singular() ) {\n        return;\n    }\n\n    // The service can also read the original feed at /feed/?origin-feed=1.\n    if ( isset( $_GET['origin-feed'] ) ) {\n        return;\n    }\n\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';\n\n    foreach ( $wpide_feed_service_agents as $needle ) {\n        if ( '' !== $agent && false !== strpos( $agent, strtolower( $needle ) ) ) {\n            return;\n        }\n    }\n\n    // 302 keeps subscribers on your own feed address, so you can switch services later.\n    wp_redirect( esc_url_raw( $wpide_feed_service_url ), 302 ); // phpcs:ignore WordPress.Security.SafeRedirect -- external address set by the site owner.\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "Off until you fill in the service address. Point the service at /feed/?origin-feed=1 if its user agent is not in the list."
        },
        {
            "id": "custom-fields-in-feed-items",
            "version": 1,
            "title": "Custom Field Values in Feed Items",
            "description": "Adds the custom fields you choose, such as an event date or location, to each RSS item as its own element, for newsletter tools, aggregators and partner sites that read your feed.",
            "category": "rss",
            "tags": [
                "feeds",
                "custom-fields",
                "xml"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Custom field key => element name in the feed (lowercase letters, digits and dashes). Empty = off. Examples:\n// 'event_date'  => 'event-date',\n// 'location'    => 'location',\n$wpide_feed_fields = array();\n// Prefix of the elements, for example <site:event-date>.\n$wpide_feed_fields_prefix = 'site';\n\nif ( $wpide_feed_fields ) {\n    add_action( 'rss2_ns', function () use ( $wpide_feed_fields_prefix ) {\n        echo 'xmlns:' . esc_attr( sanitize_key( $wpide_feed_fields_prefix ) ) . '=\"' . esc_url( home_url( '/' ) ) . '\"' . \"\\n\\t\";\n    } );\n\n    add_action( 'rss2_item', function () use ( $wpide_feed_fields, $wpide_feed_fields_prefix ) {\n        $post_id = get_the_ID();\n\n        if ( ! $post_id || post_password_required( $post_id ) ) {\n            return;\n        }\n\n        $prefix = sanitize_key( $wpide_feed_fields_prefix );\n\n        foreach ( $wpide_feed_fields as $key => $element ) {\n            $element = preg_replace( '/[^a-z0-9-]/', '', strtolower( (string) $element ) );\n            $value   = get_post_meta( $post_id, $key, true );\n\n            if ( '' === $element || ! is_scalar( $value ) || '' === trim( (string) $value ) ) {\n                continue;\n            }\n\n            printf( \"\\t\\t<%1\\$s:%2\\$s>%3\\$s</%1\\$s:%2\\$s>\\n\", $prefix, $element, esc_xml( wp_strip_all_tags( (string) $value ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput -- names are sanitized above.\n        }\n    } );\n}",
            "requires": [],
            "notes": "The field list is empty by default; add field keys and the element names to use. Password-protected posts are skipped."
        },
        {
            "id": "feed-channel-copyright",
            "version": 1,
            "title": "Copyright and Contact Details in Your Feeds",
            "description": "Adds a copyright notice to your RSS and Atom feeds, plus optional editor and webmaster contact addresses, which feed directories and readers show alongside your feed.",
            "category": "rss",
            "tags": [
                "feeds",
                "copyright",
                "metadata"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Copyright line for the feed. {year} and {site} are replaced. Leave empty to skip it.\n$wpide_feed_copyright = '© {year} {site}. All rights reserved.';\n// Contact addresses shown to feed readers, as \"email@example.com (Name)\". Leave empty to skip them.\n$wpide_feed_editor    = '';\n$wpide_feed_webmaster = '';\n\n$wpide_feed_copyright_text = function () use ( $wpide_feed_copyright ) {\n    return strtr( $wpide_feed_copyright, array(\n        '{year}' => wp_date( 'Y' ),\n        '{site}' => get_bloginfo( 'name' ),\n    ) );\n};\n\nadd_action( 'rss2_head', function () use ( $wpide_feed_copyright_text, $wpide_feed_editor, $wpide_feed_webmaster ) {\n    $copyright = $wpide_feed_copyright_text();\n\n    if ( '' !== $copyright ) {\n        echo \"\\t<copyright>\" . esc_xml( $copyright ) . \"</copyright>\\n\";\n    }\n\n    if ( '' !== $wpide_feed_editor ) {\n        echo \"\\t<managingEditor>\" . esc_xml( $wpide_feed_editor ) . \"</managingEditor>\\n\";\n    }\n\n    if ( '' !== $wpide_feed_webmaster ) {\n        echo \"\\t<webMaster>\" . esc_xml( $wpide_feed_webmaster ) . \"</webMaster>\\n\";\n    }\n} );\n\nadd_action( 'atom_head', function () use ( $wpide_feed_copyright_text ) {\n    $copyright = $wpide_feed_copyright_text();\n\n    if ( '' !== $copyright ) {\n        echo \"\\t<rights>\" . esc_xml( $copyright ) . \"</rights>\\n\";\n    }\n} );",
            "requires": [],
            "notes": "{year} and {site} in the copyright text are replaced automatically; leave the email lines empty to skip them."
        },
        {
            "id": "reading-time-in-feed-items",
            "version": 1,
            "title": "Reading Time in Feed Items",
            "description": "Shows an estimated reading time such as \"4 min read\" at the start of each item in your feeds, so subscribers know how long an article takes before they open it.",
            "category": "rss",
            "tags": [
                "feeds",
                "reading-time",
                "newsletter"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Average reading speed in words per minute.\n$wpide_feed_wpm = 220;\n// Label; %d is replaced by the minutes.\n$wpide_feed_reading_label = '%d min read';\n\n$wpide_feed_minutes = function () use ( $wpide_feed_wpm ) {\n    static $cache = array();\n\n    $post_id = get_the_ID();\n\n    if ( ! $post_id ) {\n        return 0;\n    }\n\n    if ( ! isset( $cache[ $post_id ] ) ) {\n        $text  = wp_strip_all_tags( strip_shortcodes( (string) get_post_field( 'post_content', $post_id ) ) );\n        $words = preg_split( '/\\s+/u', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );\n\n        $cache[ $post_id ] = $words ? max( 1, (int) ceil( count( $words ) / max( 1, (int) $wpide_feed_wpm ) ) ) : 0;\n    }\n\n    return $cache[ $post_id ];\n};\n\nadd_filter( 'the_excerpt_rss', function ( $excerpt ) use ( $wpide_feed_minutes, $wpide_feed_reading_label ) {\n    $minutes = $wpide_feed_minutes();\n\n    return $minutes ? esc_html( sprintf( $wpide_feed_reading_label, $minutes ) ) . ' · ' . $excerpt : $excerpt;\n} );\n\nadd_filter( 'the_content_feed', function ( $content ) use ( $wpide_feed_minutes, $wpide_feed_reading_label ) {\n    $minutes = $wpide_feed_minutes();\n\n    return $minutes ? '<p><em>' . esc_html( sprintf( $wpide_feed_reading_label, $minutes ) ) . '</em></p>' . $content : $content;\n} );",
            "requires": [],
            "notes": "Change the words-per-minute value to match your audience; shortcodes and HTML are left out of the word count."
        },
        {
            "id": "most-discussed-posts-feed",
            "version": 1,
            "title": "Most Discussed Posts Feed",
            "description": "Publishes a second feed at /feed/popular/ listing your posts with the most comments from recent months, handy for \"popular this month\" newsletters and sidebar widgets on other sites.",
            "category": "rss",
            "tags": [
                "feeds",
                "popular",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Feed name: the feed lives at /feed/popular/ (or ?feed=popular with plain permalinks).\n$wpide_popular_feed_name = 'popular';\n// Only count posts published in the last number of days (0 = all time) and how many to list.\n$wpide_popular_feed_days  = 90;\n$wpide_popular_feed_items = 10;\n\nadd_action( 'init', function () use ( $wpide_popular_feed_name, $wpide_popular_feed_days, $wpide_popular_feed_items ) {\n    add_feed( $wpide_popular_feed_name, function () use ( $wpide_popular_feed_days, $wpide_popular_feed_items ) {\n        $cache_key = 'wpide_popular_feed_' . md5( $wpide_popular_feed_days . '|' . $wpide_popular_feed_items );\n        $ids       = get_transient( $cache_key );\n\n        if ( ! is_array( $ids ) ) {\n            $args = array(\n                'post_type'           => 'post',\n                'post_status'         => 'publish',\n                'posts_per_page'      => max( 1, (int) $wpide_popular_feed_items ),\n                'orderby'             => array( 'comment_count' => 'DESC', 'date' => 'DESC' ),\n                'has_password'        => false,\n                'ignore_sticky_posts' => true,\n                'no_found_rows'       => true,\n                'fields'              => 'ids',\n            );\n\n            if ( $wpide_popular_feed_days > 0 ) {\n                $args['date_query'] = array( array( 'after' => absint( $wpide_popular_feed_days ) . ' days ago' ) );\n            }\n\n            $ids = get_posts( $args );\n            set_transient( $cache_key, $ids, HOUR_IN_SECONDS );\n        }\n\n        $GLOBALS['wp_query'] = new WP_Query( array(\n            'post_type'           => 'post',\n            'post__in'            => $ids ? $ids : array( 0 ),\n            'orderby'             => 'post__in',\n            'posts_per_page'      => count( $ids ) ? count( $ids ) : 1,\n            'ignore_sticky_posts' => true,\n            'no_found_rows'       => true,\n        ) );\n\n        add_filter( 'get_wp_title_rss', function ( $title ) {\n            return $title . ' – Most discussed';\n        } );\n\n        load_template( ABSPATH . WPINC . '/feed-rss2.php' );\n    } );\n} );",
            "requires": [],
            "notes": "After activating, visit Settings > Permalinks and click Save so /feed/popular/ works; the list is cached for an hour."
        },
        {
            "id": "feed-addresses-from-other-platforms",
            "version": 1,
            "title": "Keep Old Feed Addresses Working After Moving to WordPress",
            "description": "Redirects feed addresses used by Blogger, Ghost, Jekyll, Hugo and other platforms, such as /rss.xml or /feeds/posts/default, to your WordPress feed, so existing subscribers keep receiving posts.",
            "category": "rss",
            "tags": [
                "feeds",
                "migration",
                "redirects"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Old feed addresses (from Blogger, Ghost, Jekyll, Squarespace ...) that should lead to your WordPress feed.\n$wpide_old_feed_paths = array( '/rss.xml', '/feed.xml', '/atom.xml', '/index.xml', '/index.rdf', '/rss', '/feeds/posts/default' );\n\nadd_action( 'template_redirect', function () use ( $wpide_old_feed_paths ) {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $base = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );\n\n    if ( '' === $path ) {\n        return;\n    }\n\n    // Path relative to the site, so subdirectory installs work too.\n    $relative = '/' . ltrim( substr( $path, strlen( untrailingslashit( $base ) ) ), '/' );\n    $relative = strtolower( untrailingslashit( $relative ) );\n\n    foreach ( $wpide_old_feed_paths as $old ) {\n        if ( strtolower( untrailingslashit( $old ) ) === $relative ) {\n            wp_safe_redirect( get_feed_link(), 301 );\n            exit;\n        }\n    }\n} );",
            "requires": [],
            "notes": "Only addresses that would otherwise be a 404 are redirected; add any other old feed paths to the list."
        },
        {
            "id": "category-name-before-feed-titles",
            "version": 1,
            "title": "Category Name Before Feed Item Titles",
            "description": "Puts the post's category in front of each title in your feeds, for example \"[Recipes] Easy Pancakes\", so subscribers can scan topics at a glance.",
            "category": "rss",
            "tags": [
                "feeds",
                "categories",
                "titles"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Format of the prefix; %s is the category name.\n$wpide_feed_title_prefix = '[%s] ';\n\nadd_filter( 'the_title_rss', function ( $title ) use ( $wpide_feed_title_prefix ) {\n    if ( 'post' !== get_post_type() ) {\n        return $title;\n    }\n\n    $default = (int) get_option( 'default_category' );\n\n    foreach ( get_the_category() as $category ) {\n        if ( (int) $category->term_id !== $default ) {\n            return sprintf( $wpide_feed_title_prefix, wp_strip_all_tags( $category->name ) ) . $title;\n        }\n    }\n\n    return $title;\n}, 9 ); // Before WordPress escapes feed titles (priority 10).",
            "requires": [],
            "notes": "The default \"Uncategorized\" category is skipped; change the format string to use another style, such as \"Recipes: \"."
        },
        {
            "id": "exclude-authors-from-feeds",
            "version": 1,
            "title": "Keep Certain Authors' Posts Out of Feeds",
            "description": "Leaves posts by the authors you list, such as guest writers or an automated account, out of your main feeds, while their author feed and the posts on the site stay available.",
            "category": "rss",
            "tags": [
                "feeds",
                "authors",
                "exclude"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// User IDs or usernames whose posts stay out of the main feeds. Empty = off. Example: array( 7, 'guest-writer' ).\n$wpide_feed_hidden_authors = array();\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_feed_hidden_authors ) {\n    if ( ! $wpide_feed_hidden_authors || is_admin() || ! $query->is_main_query() || ! $query->is_feed() || $query->is_author() || $query->is_comment_feed() ) {\n        return;\n    }\n\n    $ids = array();\n\n    foreach ( $wpide_feed_hidden_authors as $author ) {\n        $user = is_numeric( $author ) ? get_user_by( 'id', (int) $author ) : get_user_by( 'login', $author );\n\n        if ( $user ) {\n            $ids[] = (int) $user->ID;\n        }\n    }\n\n    if ( $ids ) {\n        $query->set( 'author__not_in', array_unique( array_merge( (array) $query->get( 'author__not_in' ), $ids ) ) );\n    }\n} );",
            "requires": [],
            "notes": "The author list is empty by default; add user IDs or usernames."
        },
        {
            "id": "sponsor-message-in-feed-items",
            "version": 1,
            "title": "Sponsor Message Inside Feed Items",
            "description": "Places a short sponsor or promotion message after the first paragraph of every item in your feeds, where RSS readers and RSS-to-email newsletters will show it.",
            "category": "rss",
            "tags": [
                "feeds",
                "sponsorship",
                "monetization"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Message placed after the first paragraph of each feed item (HTML allowed). Empty = off.\n// Example: 'This newsletter is supported by <a href=\"https://example.com/\">Example Co</a>.'\n$wpide_feed_message = '';\n// Limit to posts in these category slugs; empty = all posts.\n$wpide_feed_message_categories = array();\n\nadd_filter( 'the_content_feed', function ( $content ) use ( $wpide_feed_message, $wpide_feed_message_categories ) {\n    if ( '' === trim( $wpide_feed_message ) || ( $wpide_feed_message_categories && ! in_category( $wpide_feed_message_categories ) ) ) {\n        return $content;\n    }\n\n    $box = '<p style=\"padding:12px;border-left:4px solid #ccc;background:#f6f7f7;\"><em>' . wp_kses_post( $wpide_feed_message ) . '</em></p>';\n    $pos = stripos( $content, '</p>' );\n\n    return false === $pos ? $content . $box : substr_replace( $content, '</p>' . $box, $pos, 4 );\n} );",
            "requires": [],
            "notes": "Nothing is added until you write the message; you can limit it to posts in certain categories."
        },
        {
            "id": "hashtags-from-tags-in-feeds",
            "version": 1,
            "title": "Hashtags From Tags in Feed Items",
            "description": "Turns each post's tags into hashtags such as #HomeOffice at the end of the feed summary, so RSS-to-social tools like Buffer, Zapier or IFTTT post them automatically.",
            "category": "rss",
            "tags": [
                "feeds",
                "hashtags",
                "social"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Where the hashtags go: 'description' (the summary most RSS-to-social tools post) or 'both' to add them to the full content too.\n$wpide_feed_hashtags_in = 'description';\n// Maximum number of hashtags per item.\n$wpide_feed_hashtags_max = 4;\n\n$wpide_feed_hashtags = function () use ( $wpide_feed_hashtags_max ) {\n    $tags = get_the_tags();\n\n    if ( ! $tags || is_wp_error( $tags ) ) {\n        return '';\n    }\n\n    $out = array();\n\n    foreach ( array_slice( $tags, 0, max( 1, (int) $wpide_feed_hashtags_max ) ) as $tag ) {\n        // \"Home office\" becomes #HomeOffice; accents and other alphabets are kept.\n        $words = preg_split( '/[^\\p{L}\\p{N}]+/u', $tag->name, -1, PREG_SPLIT_NO_EMPTY );\n        $words = array_map( function ( $word ) {\n            return function_exists( 'mb_strtoupper' ) ? mb_strtoupper( mb_substr( $word, 0, 1 ) ) . mb_substr( $word, 1 ) : ucfirst( $word );\n        }, (array) $words );\n\n        if ( $words ) {\n            $out[] = '#' . implode( '', $words );\n        }\n    }\n\n    return implode( ' ', array_unique( $out ) );\n};\n\nadd_filter( 'the_excerpt_rss', function ( $excerpt ) use ( $wpide_feed_hashtags ) {\n    $tags = $wpide_feed_hashtags();\n\n    return '' === $tags ? $excerpt : $excerpt . ' ' . esc_html( $tags );\n} );\n\nif ( 'both' === $wpide_feed_hashtags_in ) {\n    add_filter( 'the_content_feed', function ( $content ) use ( $wpide_feed_hashtags ) {\n        $tags = $wpide_feed_hashtags();\n\n        return '' === $tags ? $content : $content . '<p>' . esc_html( $tags ) . '</p>';\n    } );\n}",
            "requires": [],
            "notes": "Set the place to 'both' to add the hashtags to the full content as well; the number of hashtags is capped at four by default."
        },
        {
            "id": "widget-visibility-by-page-type",
            "version": 1,
            "title": "Show Widgets Only on Certain Page Types",
            "description": "Adds a \"Show on\" setting to every classic widget, so you can limit it to the homepage, single posts, blog and archive pages, or everywhere except those.",
            "category": "widgets",
            "tags": [
                "widgets",
                "visibility",
                "conditional"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Choices for the \"Show on\" setting added to every classic widget.\n$wpide_widget_contexts = array(\n    ''            => 'All pages',\n    'front'       => 'Homepage only',\n    'not_front'   => 'Everywhere except the homepage',\n    'singular'    => 'Single posts and pages',\n    'posts'       => 'Single blog posts only',\n    'lists'       => 'Blog, archive and search pages',\n    'not_lists'   => 'Everywhere except blog, archive and search pages',\n);\n\nadd_action( 'in_widget_form', function ( $widget, $return, $instance ) use ( $wpide_widget_contexts ) {\n    $value = isset( $instance['wpide_show_on'] ) ? $instance['wpide_show_on'] : '';\n    $id    = $widget->get_field_id( 'wpide_show_on' );\n\n    echo '<p><label for=\"' . esc_attr( $id ) . '\">Show on:</label> <select class=\"widefat\" id=\"' . esc_attr( $id ) . '\" name=\"' . esc_attr( $widget->get_field_name( 'wpide_show_on' ) ) . '\">';\n\n    foreach ( $wpide_widget_contexts as $key => $label ) {\n        echo '<option value=\"' . esc_attr( $key ) . '\"' . selected( $value, $key, false ) . '>' . esc_html( $label ) . '</option>';\n    }\n\n    echo '</select></p>';\n}, 10, 3 );\n\nadd_filter( 'widget_update_callback', function ( $instance, $new_instance ) use ( $wpide_widget_contexts ) {\n    $value = isset( $new_instance['wpide_show_on'] ) ? sanitize_key( $new_instance['wpide_show_on'] ) : '';\n\n    $instance['wpide_show_on'] = isset( $wpide_widget_contexts[ $value ] ) ? $value : '';\n\n    return $instance;\n}, 10, 2 );\n\nadd_filter( 'widget_display_callback', function ( $instance ) {\n    if ( ! is_array( $instance ) || empty( $instance['wpide_show_on'] ) || is_admin() ) {\n        return $instance;\n    }\n\n    $is_list = is_home() || is_archive() || is_search();\n\n    switch ( $instance['wpide_show_on'] ) {\n        case 'front':\n            $show = is_front_page();\n            break;\n        case 'not_front':\n            $show = ! is_front_page();\n            break;\n        case 'singular':\n            $show = is_singular();\n            break;\n        case 'posts':\n            $show = is_singular( 'post' );\n            break;\n        case 'lists':\n            $show = $is_list;\n            break;\n        case 'not_lists':\n            $show = ! $is_list;\n            break;\n        default:\n            $show = true;\n    }\n\n    return $show ? $instance : false;\n} );",
            "requires": [],
            "notes": "Works with classic widgets and the Legacy Widget block; block widgets do not get the setting."
        },
        {
            "id": "schedule-widgets-by-date",
            "version": 1,
            "title": "Schedule Widgets With Start and End Dates",
            "description": "Adds \"Show from\" and \"Show until\" date fields to every classic widget, so a promotion, event notice or holiday message appears and disappears on its own.",
            "category": "widgets",
            "tags": [
                "widgets",
                "schedule",
                "promotions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'in_widget_form', function ( $widget, $return, $instance ) {\n    foreach ( array( 'wpide_show_from' => 'Show from', 'wpide_show_until' => 'Show until' ) as $key => $label ) {\n        $id = $widget->get_field_id( $key );\n\n        echo '<p><label for=\"' . esc_attr( $id ) . '\">' . esc_html( $label ) . ':</label> <input type=\"datetime-local\" class=\"widefat\" id=\"' . esc_attr( $id ) . '\" name=\"' . esc_attr( $widget->get_field_name( $key ) ) . '\" value=\"' . esc_attr( isset( $instance[ $key ] ) ? $instance[ $key ] : '' ) . '\" /></p>';\n    }\n\n    echo '<p class=\"description\">Leave empty for no limit. Times use the site time zone.</p>';\n}, 10, 3 );\n\nadd_filter( 'widget_update_callback', function ( $instance, $new_instance ) {\n    foreach ( array( 'wpide_show_from', 'wpide_show_until' ) as $key ) {\n        $value = isset( $new_instance[ $key ] ) ? sanitize_text_field( $new_instance[ $key ] ) : '';\n\n        $instance[ $key ] = preg_match( '/^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$/', $value ) ? $value : '';\n    }\n\n    return $instance;\n}, 10, 2 );\n\nadd_filter( 'widget_display_callback', function ( $instance ) {\n    if ( ! is_array( $instance ) || ( empty( $instance['wpide_show_from'] ) && empty( $instance['wpide_show_until'] ) ) || is_admin() ) {\n        return $instance;\n    }\n\n    $now  = current_datetime()->format( 'Y-m-d\\TH:i' );\n    $from = isset( $instance['wpide_show_from'] ) ? $instance['wpide_show_from'] : '';\n    $till = isset( $instance['wpide_show_until'] ) ? $instance['wpide_show_until'] : '';\n\n    // Same-format strings compare correctly as text.\n    if ( ( '' !== $from && $now < $from ) || ( '' !== $till && $now >= $till ) ) {\n        return false;\n    }\n\n    return $instance;\n} );",
            "requires": [],
            "notes": "Times use the time zone in Settings > General; a page cache may keep showing a widget until the cache is cleared."
        },
        {
            "id": "most-discussed-posts-widget",
            "version": 1,
            "title": "Most Discussed Posts Widget",
            "description": "Adds a \"Most Discussed Posts\" widget that lists the posts with the most comments, all-time or from the last few weeks, with optional comment counts.",
            "category": "widgets",
            "tags": [
                "widgets",
                "popular-posts",
                "comments"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Most_Discussed_Widget' ) ) {\n    class WPIDE_Most_Discussed_Widget extends WP_Widget {\n        public function __construct() {\n            parent::__construct( 'wpide_most_discussed', 'Most Discussed Posts', array(\n                'description'                 => 'Posts with the most comments, optionally from recent weeks only.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;\n            $days   = isset( $instance['days'] ) ? absint( $instance['days'] ) : 0;\n            $key    = 'wpide_discussed_' . $number . '_' . $days;\n            $ids    = get_transient( $key );\n\n            if ( ! is_array( $ids ) ) {\n                $query = array(\n                    'post_type'           => 'post',\n                    'posts_per_page'      => $number,\n                    'orderby'             => array( 'comment_count' => 'DESC', 'date' => 'DESC' ),\n                    'has_password'        => false,\n                    'ignore_sticky_posts' => true,\n                    'no_found_rows'       => true,\n                    'fields'              => 'ids',\n                );\n\n                if ( $days ) {\n                    $query['date_query'] = array( array( 'after' => $days . ' days ago' ) );\n                }\n\n                $ids = get_posts( $query );\n                set_transient( $key, $ids, HOUR_IN_SECONDS );\n            }\n\n            if ( ! $ids ) {\n                return;\n            }\n\n            _prime_post_caches( $ids, false, false );\n\n            $title = isset( $instance['title'] ) && '' !== $instance['title'] ? $instance['title'] : 'Most Discussed';\n            $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- theme markup.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<ol class=\"wpide-most-discussed\">';\n\n            foreach ( $ids as $id ) {\n                $count = (int) get_comments_number( $id );\n\n                echo '<li><a href=\"' . esc_url( get_permalink( $id ) ) . '\">' . esc_html( get_the_title( $id ) ) . '</a>';\n\n                if ( ! empty( $instance['show_count'] ) ) {\n                    echo ' <small>(' . esc_html( sprintf( _n( '%s comment', '%s comments', $count ), number_format_i18n( $count ) ) ) . ')</small>';\n                }\n\n                echo '</li>';\n            }\n\n            echo '</ol>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title  = isset( $instance['title'] ) ? $instance['title'] : '';\n            $number = ! empty( $instance['number'] ) ? absint( $instance['number'] ) : 5;\n            $days   = isset( $instance['days'] ) ? absint( $instance['days'] ) : 0;\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'number' ) ) . '\">Number of posts:</label> <input class=\"tiny-text\" type=\"number\" min=\"1\" max=\"20\" id=\"' . esc_attr( $this->get_field_id( 'number' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'number' ) ) . '\" value=\"' . esc_attr( $number ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'days' ) ) . '\">Only posts from the last (days, 0 = all time):</label> <input class=\"tiny-text\" type=\"number\" min=\"0\" max=\"3650\" id=\"' . esc_attr( $this->get_field_id( 'days' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'days' ) ) . '\" value=\"' . esc_attr( $days ) . '\" /></p>';\n            echo '<p><input type=\"checkbox\" class=\"checkbox\" id=\"' . esc_attr( $this->get_field_id( 'show_count' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'show_count' ) ) . '\"' . checked( ! empty( $instance['show_count'] ), true, false ) . ' /> <label for=\"' . esc_attr( $this->get_field_id( 'show_count' ) ) . '\">Show comment counts</label></p>';\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title'      => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',\n                'number'     => isset( $new_instance['number'] ) ? max( 1, min( 20, absint( $new_instance['number'] ) ) ) : 5,\n                'days'       => isset( $new_instance['days'] ) ? min( 3650, absint( $new_instance['days'] ) ) : 0,\n                'show_count' => ! empty( $new_instance['show_count'] ),\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Most_Discussed_Widget' );\n} );",
            "requires": [],
            "notes": "Add it under Appearance > Widgets (as a Legacy Widget in the block editor); results are cached for an hour."
        },
        {
            "id": "about-the-author-widget",
            "version": 1,
            "title": "About the Author Widget",
            "description": "Adds an \"About the Author\" sidebar widget that shows the current post author's photo, bio and a link to their other posts, and hides itself on other pages.",
            "category": "widgets",
            "tags": [
                "widgets",
                "authors",
                "bio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_About_Author_Widget' ) ) {\n    class WPIDE_About_Author_Widget extends WP_Widget {\n        public function __construct() {\n            parent::__construct( 'wpide_about_author', 'About the Author', array(\n                'description'                 => 'The current post author\\'s photo, bio and a link to their posts. Shows on single posts only.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            if ( ! is_singular( 'post' ) ) {\n                return;\n            }\n\n            $author_id = (int) get_post_field( 'post_author', get_queried_object_id() );\n            $user      = $author_id ? get_userdata( $author_id ) : false;\n\n            if ( ! $user ) {\n                return;\n            }\n\n            $title = isset( $instance['title'] ) && '' !== $instance['title'] ? $instance['title'] : 'About the Author';\n            $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n            $size  = ! empty( $instance['size'] ) ? absint( $instance['size'] ) : 80;\n            $bio   = get_the_author_meta( 'description', $author_id );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- theme markup.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<div class=\"wpide-about-author\" style=\"text-align:center;\">';\n            echo get_avatar( $author_id, $size, '', '', array( 'class' => 'wpide-about-author__avatar', 'extra_attr' => 'style=\"border-radius:50%;margin:0 auto .5em;display:block;\"' ) );\n            echo '<p style=\"margin:0 0 .5em;\"><strong>' . esc_html( $user->display_name ) . '</strong></p>';\n\n            if ( '' !== $bio ) {\n                echo '<p style=\"margin:0 0 .5em;\">' . wp_kses_post( $bio ) . '</p>';\n            }\n\n            echo '<p style=\"margin:0;\"><a href=\"' . esc_url( get_author_posts_url( $author_id ) ) . '\">' . esc_html( sprintf( 'More by %s', $user->display_name ) ) . '</a></p>';\n            echo '</div>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title = isset( $instance['title'] ) ? $instance['title'] : '';\n            $size  = ! empty( $instance['size'] ) ? absint( $instance['size'] ) : 80;\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'size' ) ) . '\">Photo size (px):</label> <input class=\"tiny-text\" type=\"number\" min=\"32\" max=\"256\" id=\"' . esc_attr( $this->get_field_id( 'size' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'size' ) ) . '\" value=\"' . esc_attr( $size ) . '\" /></p>';\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title' => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',\n                'size'  => isset( $new_instance['size'] ) ? max( 32, min( 256, absint( $new_instance['size'] ) ) ) : 80,\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_About_Author_Widget' );\n} );",
            "requires": [],
            "notes": "The bio comes from Users > Profile > Biographical Info; the photo is the author's Gravatar or avatar."
        },
        {
            "id": "recent-posts-widget-custom-post-types",
            "version": 1,
            "title": "Recent Posts Widget Includes Custom Post Types",
            "description": "Makes the Recent Posts widget list your custom post types, such as portfolio items, recipes or events, together with blog posts.",
            "category": "widgets",
            "tags": [
                "widgets",
                "recent-posts",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Content types listed by the Recent Posts widget and the Latest Posts block in widget areas.\n// Empty = blog posts plus every public custom post type.\n$wpide_recent_widget_types = array();\n\nadd_filter( 'widget_posts_args', function ( $args ) use ( $wpide_recent_widget_types ) {\n    $types = $wpide_recent_widget_types;\n\n    if ( ! $types ) {\n        $types = array_merge( array( 'post' ), array_values( get_post_types( array( 'public' => true, '_builtin' => false ) ) ) );\n    }\n\n    $args['post_type'] = array_values( array_filter( $types, 'post_type_exists' ) );\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Leave the list empty to include every public custom post type, or name the exact types to show."
        },
        {
            "id": "recent-comments-widget-no-pingbacks",
            "version": 1,
            "title": "Hide Pingbacks From the Recent Comments Widget",
            "description": "Leaves pingbacks and trackbacks out of the Recent Comments widget and the Latest Comments block, so they show only real conversations.",
            "category": "widgets",
            "tags": [
                "widgets",
                "comments",
                "pingbacks"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'widget_comments_args', function ( $args ) {\n    // Only real comments: no pingbacks, trackbacks or other notes such as order notes.\n    $args['type'] = 'comment';\n\n    return $args;\n} );",
            "requires": [],
            "notes": "Also filters out other special comment types, such as WooCommerce order notes."
        },
        {
            "id": "categories-widget-top-level-by-count",
            "version": 1,
            "title": "Categories Widget: Top Level Only, Busiest First",
            "description": "Makes the Categories widget list only top-level categories and sorts them by number of posts, so a long category tree becomes a short list of your main sections.",
            "category": "widgets",
            "tags": [
                "widgets",
                "categories",
                "sorting"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// true = list only top-level categories (no subcategories).\n$wpide_cat_widget_top_level = true;\n// true = order categories by number of posts, busiest first, instead of A to Z.\n$wpide_cat_widget_by_count = true;\n\n$wpide_cat_widget_args = function ( $args ) use ( $wpide_cat_widget_top_level, $wpide_cat_widget_by_count ) {\n    if ( $wpide_cat_widget_top_level ) {\n        $args['depth']  = 1;\n        $args['parent'] = 0;\n    }\n\n    if ( $wpide_cat_widget_by_count ) {\n        $args['orderby'] = 'count';\n        $args['order']   = 'DESC';\n    }\n\n    return $args;\n};\n\nadd_filter( 'widget_categories_args', $wpide_cat_widget_args );\nadd_filter( 'widget_categories_dropdown_args', $wpide_cat_widget_args );",
            "requires": [],
            "notes": "Switch either setting off at the top of the snippet; works for both the list and the dropdown version of the widget."
        },
        {
            "id": "widget-title-links",
            "version": 1,
            "title": "Link Widget Titles to a Page",
            "description": "Adds a \"Title link\" field to every classic widget, turning its title into a link, for example from \"Latest News\" to your news page.",
            "category": "widgets",
            "tags": [
                "widgets",
                "titles",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'in_widget_form', function ( $widget, $return, $instance ) {\n    $id = $widget->get_field_id( 'wpide_title_link' );\n\n    echo '<p><label for=\"' . esc_attr( $id ) . '\">Title link (optional):</label> <input type=\"url\" class=\"widefat\" id=\"' . esc_attr( $id ) . '\" name=\"' . esc_attr( $widget->get_field_name( 'wpide_title_link' ) ) . '\" value=\"' . esc_attr( isset( $instance['wpide_title_link'] ) ? $instance['wpide_title_link'] : '' ) . '\" placeholder=\"https://\" /></p>';\n}, 10, 3 );\n\nadd_filter( 'widget_update_callback', function ( $instance, $new_instance ) {\n    $instance['wpide_title_link'] = isset( $new_instance['wpide_title_link'] ) ? esc_url_raw( trim( $new_instance['wpide_title_link'] ) ) : '';\n\n    return $instance;\n}, 10, 2 );\n\nadd_filter( 'widget_title', function ( $title, $instance = array() ) {\n    if ( is_admin() || '' === (string) $title || empty( $instance['wpide_title_link'] ) ) {\n        return $title;\n    }\n\n    return '<a href=\"' . esc_url( $instance['wpide_title_link'] ) . '\">' . $title . '</a>';\n}, 20, 2 );",
            "requires": [],
            "notes": "Core widgets show the link; third-party widgets that escape their own titles may show the title as plain text."
        },
        {
            "id": "social-profiles-widget",
            "version": 1,
            "title": "Social Profiles Widget",
            "description": "Adds a \"Social Profiles\" widget with links to your profiles on Facebook, Instagram, X, LinkedIn, YouTube, TikTok, Bluesky, Mastodon and more, as an accessible list of text links.",
            "category": "widgets",
            "tags": [
                "widgets",
                "social",
                "links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Social_Profiles_Widget' ) ) {\n    class WPIDE_Social_Profiles_Widget extends WP_Widget {\n        // Networks offered in the widget form, in display order.\n        const NETWORKS = array(\n            'facebook'  => 'Facebook',\n            'instagram' => 'Instagram',\n            'x'         => 'X',\n            'linkedin'  => 'LinkedIn',\n            'youtube'   => 'YouTube',\n            'tiktok'    => 'TikTok',\n            'pinterest' => 'Pinterest',\n            'bluesky'   => 'Bluesky',\n            'mastodon'  => 'Mastodon',\n            'threads'   => 'Threads',\n            'github'    => 'GitHub',\n        );\n\n        public function __construct() {\n            parent::__construct( 'wpide_social_profiles', 'Social Profiles', array(\n                'description'                 => 'Links to your profiles on social networks.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $links = array();\n\n            foreach ( self::NETWORKS as $key => $label ) {\n                if ( ! empty( $instance[ $key ] ) ) {\n                    // rel=\"me\" lets Mastodon and similar services verify the link back to your site.\n                    $links[] = '<li><a href=\"' . esc_url( $instance[ $key ] ) . '\" rel=\"me noopener\" target=\"_blank\">' . esc_html( $label ) . '<span class=\"screen-reader-text\"> (opens in a new tab)</span></a></li>';\n                }\n            }\n\n            if ( ! $links ) {\n                return;\n            }\n\n            $title = isset( $instance['title'] ) ? $instance['title'] : 'Follow Us';\n            $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- theme markup.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<ul class=\"wpide-social-profiles\" style=\"display:flex;flex-wrap:wrap;gap:.5em;list-style:none;margin:0;padding:0;\">' . implode( '', $links ) . '</ul>'; // phpcs:ignore WordPress.Security.EscapeOutput -- escaped above.\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title = isset( $instance['title'] ) ? $instance['title'] : 'Follow Us';\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n\n            foreach ( self::NETWORKS as $key => $label ) {\n                echo '<p><label for=\"' . esc_attr( $this->get_field_id( $key ) ) . '\">' . esc_html( $label ) . ' URL:</label><input class=\"widefat\" type=\"url\" id=\"' . esc_attr( $this->get_field_id( $key ) ) . '\" name=\"' . esc_attr( $this->get_field_name( $key ) ) . '\" value=\"' . esc_attr( isset( $instance[ $key ] ) ? $instance[ $key ] : '' ) . '\" /></p>';\n            }\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            $clean = array( 'title' => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '' );\n\n            foreach ( array_keys( self::NETWORKS ) as $key ) {\n                $clean[ $key ] = isset( $new_instance[ $key ] ) ? esc_url_raw( trim( $new_instance[ $key ] ), array( 'https', 'http' ) ) : '';\n            }\n\n            return $clean;\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Social_Profiles_Widget' );\n} );",
            "requires": [],
            "notes": "Leave a network empty to hide it; links carry rel=\"me\" so Mastodon can verify your site."
        },
        {
            "id": "featured-post-widget",
            "version": 1,
            "title": "Featured Post Widget",
            "description": "Adds a \"Featured Post\" widget that highlights one post or page you pick, shown as a card with its featured image, excerpt and a read-more link.",
            "category": "widgets",
            "tags": [
                "widgets",
                "featured",
                "cards"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Featured_Post_Widget' ) ) {\n    class WPIDE_Featured_Post_Widget extends WP_Widget {\n        public function __construct() {\n            parent::__construct( 'wpide_featured_post', 'Featured Post', array(\n                'description'                 => 'One post or page you choose, shown as a card with its image and excerpt.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $post = ! empty( $instance['post_id'] ) ? get_post( absint( $instance['post_id'] ) ) : null;\n\n            if ( ! $post || 'publish' !== $post->post_status || post_password_required( $post ) || ( is_singular() && get_queried_object_id() === $post->ID ) ) {\n                return;\n            }\n\n            $title = isset( $instance['title'] ) ? $instance['title'] : '';\n            $title = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n            $link  = get_permalink( $post );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- theme markup.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<article class=\"wpide-featured-post\">';\n\n            if ( has_post_thumbnail( $post ) ) {\n                echo '<a href=\"' . esc_url( $link ) . '\" tabindex=\"-1\" aria-hidden=\"true\">' . get_the_post_thumbnail( $post, 'medium_large', array( 'alt' => '', 'style' => 'display:block;width:100%;height:auto;border-radius:6px;margin-bottom:.5em;' ) ) . '</a>';\n            }\n\n            echo '<h3 style=\"margin:0 0 .35em;font-size:1.1em;\"><a href=\"' . esc_url( $link ) . '\">' . esc_html( get_the_title( $post ) ) . '</a></h3>';\n            echo '<p style=\"margin:0 0 .5em;\">' . esc_html( wp_trim_words( get_the_excerpt( $post ), 25 ) ) . '</p>';\n            echo '<a href=\"' . esc_url( $link ) . '\">' . esc_html( ! empty( $instance['more'] ) ? $instance['more'] : 'Read more' ) . '<span class=\"screen-reader-text\">: ' . esc_html( get_the_title( $post ) ) . '</span></a>';\n            echo '</article>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title   = isset( $instance['title'] ) ? $instance['title'] : 'Featured';\n            $post_id = ! empty( $instance['post_id'] ) ? absint( $instance['post_id'] ) : 0;\n            $more    = isset( $instance['more'] ) ? $instance['more'] : 'Read more';\n            $choices = get_posts( array(\n                'post_type'      => array( 'post', 'page' ),\n                'posts_per_page' => 100,\n                'no_found_rows'  => true,\n                'orderby'        => 'date',\n            ) );\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'post_id' ) ) . '\">Post or page:</label><select class=\"widefat\" id=\"' . esc_attr( $this->get_field_id( 'post_id' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'post_id' ) ) . '\"><option value=\"0\">— Select —</option>';\n\n            foreach ( $choices as $choice ) {\n                echo '<option value=\"' . esc_attr( $choice->ID ) . '\"' . selected( $post_id, $choice->ID, false ) . '>' . esc_html( get_the_title( $choice ) . ' (' . $choice->post_type . ')' ) . '</option>';\n            }\n\n            echo '</select></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'more' ) ) . '\">Link text:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'more' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'more' ) ) . '\" value=\"' . esc_attr( $more ) . '\" /></p>';\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title'   => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',\n                'post_id' => isset( $new_instance['post_id'] ) ? absint( $new_instance['post_id'] ) : 0,\n                'more'    => isset( $new_instance['more'] ) ? sanitize_text_field( $new_instance['more'] ) : '',\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Featured_Post_Widget' );\n} );",
            "requires": [],
            "notes": "The widget hides itself on the featured post's own page and when the post is unpublished."
        },
        {
            "id": "random-quote-widget",
            "version": 1,
            "title": "Random Quote Widget",
            "description": "Adds a \"Random Quote\" widget that shows a different quote, tip or testimonial from your list on each page view, with the source shown below it.",
            "category": "widgets",
            "tags": [
                "widgets",
                "quotes",
                "testimonials"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "if ( ! class_exists( 'WPIDE_Random_Quote_Widget' ) ) {\n    class WPIDE_Random_Quote_Widget extends WP_Widget {\n        public function __construct() {\n            parent::__construct( 'wpide_random_quote', 'Random Quote', array(\n                'description'                 => 'Shows one quote picked at random from your list on each page view.',\n                'customize_selective_refresh' => true,\n            ) );\n        }\n\n        public function widget( $args, $instance ) {\n            $lines = isset( $instance['quotes'] ) ? array_values( array_filter( array_map( 'trim', explode( \"\\n\", $instance['quotes'] ) ) ) ) : array();\n\n            if ( ! $lines ) {\n                return;\n            }\n\n            // \"Quote text — Who said it\": the part after the last \" — \" or \" - \" is the source.\n            $parts  = preg_split( '/\\s+[—–-]\\s+(?=[^—–-]*$)/u', $lines[ array_rand( $lines ) ], 2 );\n            $quote  = $parts[0];\n            $source = isset( $parts[1] ) ? $parts[1] : '';\n            $title  = isset( $instance['title'] ) ? $instance['title'] : '';\n            $title  = apply_filters( 'widget_title', $title, $instance, $this->id_base );\n\n            echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput -- theme markup.\n\n            if ( '' !== (string) $title ) {\n                echo $args['before_title'] . esc_html( $title ) . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput\n            }\n\n            echo '<figure class=\"wpide-random-quote\" style=\"margin:0;\"><blockquote style=\"margin:0;\"><p>' . esc_html( $quote ) . '</p></blockquote>';\n\n            if ( '' !== $source ) {\n                echo '<figcaption>— ' . esc_html( $source ) . '</figcaption>';\n            }\n\n            echo '</figure>';\n            echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput\n        }\n\n        public function form( $instance ) {\n            $title  = isset( $instance['title'] ) ? $instance['title'] : '';\n            $quotes = isset( $instance['quotes'] ) ? $instance['quotes'] : '';\n\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\">Title:</label><input class=\"widefat\" type=\"text\" id=\"' . esc_attr( $this->get_field_id( 'title' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'title' ) ) . '\" value=\"' . esc_attr( $title ) . '\" /></p>';\n            echo '<p><label for=\"' . esc_attr( $this->get_field_id( 'quotes' ) ) . '\">Quotes, one per line (\"Quote — Source\"):</label><textarea class=\"widefat\" rows=\"8\" id=\"' . esc_attr( $this->get_field_id( 'quotes' ) ) . '\" name=\"' . esc_attr( $this->get_field_name( 'quotes' ) ) . '\">' . esc_textarea( $quotes ) . '</textarea></p>';\n\n            return '';\n        }\n\n        public function update( $new_instance, $old_instance ) {\n            return array(\n                'title'  => isset( $new_instance['title'] ) ? sanitize_text_field( $new_instance['title'] ) : '',\n                'quotes' => isset( $new_instance['quotes'] ) ? sanitize_textarea_field( $new_instance['quotes'] ) : '',\n            );\n        }\n    }\n}\n\nadd_action( 'widgets_init', function () {\n    register_widget( 'WPIDE_Random_Quote_Widget' );\n} );",
            "requires": [],
            "notes": "Write one quote per line as \"Quote — Source\"; with a page cache, the quote changes when the cache refreshes."
        },
        {
            "id": "widget-area-above-footer",
            "version": 1,
            "title": "Widget Area Above the Footer",
            "description": "Registers an \"Above the Footer\" widget area and shows its widgets in a responsive row just above the site footer, in classic and block themes.",
            "category": "widgets",
            "tags": [
                "widgets",
                "footer",
                "layout"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Name and ID of the new widget area.\n$wpide_prefooter_name = 'Above the Footer';\n$wpide_prefooter_id   = 'wpide-above-footer';\n\nadd_action( 'widgets_init', function () use ( $wpide_prefooter_name, $wpide_prefooter_id ) {\n    register_sidebar( array(\n        'name'          => $wpide_prefooter_name,\n        'id'            => $wpide_prefooter_id,\n        'description'   => 'Shown on every page, just above the site footer.',\n        'before_widget' => '<div id=\"%1$s\" class=\"widget %2$s\">',\n        'after_widget'  => '</div>',\n        'before_title'  => '<h2 class=\"widget-title\">',\n        'after_title'   => '</h2>',\n    ) );\n} );\n\n$wpide_prefooter_html = function () use ( $wpide_prefooter_id ) {\n    if ( ! is_active_sidebar( $wpide_prefooter_id ) ) {\n        return '';\n    }\n\n    ob_start();\n    dynamic_sidebar( $wpide_prefooter_id );\n\n    $style = '<style>.wpide-above-footer{display:grid;gap:1.5em;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));max-width:var(--wp--style--global--wide-size,1200px);margin:2em auto;padding:0 16px}</style>';\n\n    return $style . '<aside class=\"wpide-above-footer\" aria-label=\"' . esc_attr( 'Above the footer' ) . '\">' . ob_get_clean() . '</aside>';\n};\n\n// Classic themes: printed when the theme loads footer.php.\nadd_action( 'get_footer', function () use ( $wpide_prefooter_html ) {\n    if ( ! wp_is_block_theme() ) {\n        echo $wpide_prefooter_html(); // phpcs:ignore WordPress.Security.EscapeOutput -- widget output.\n    }\n} );\n\n// Block themes: added before the footer template part.\nadd_filter( 'render_block_core/template-part', function ( $content, $block ) use ( $wpide_prefooter_html ) {\n    static $done = false;\n\n    $slug = isset( $block['attrs']['slug'] ) ? (string) $block['attrs']['slug'] : '';\n    $area = isset( $block['attrs']['area'] ) ? (string) $block['attrs']['area'] : '';\n\n    if ( $done || is_admin() || ( 'footer' !== $area && 0 !== strpos( $slug, 'footer' ) ) ) {\n        return $content;\n    }\n\n    $done = true;\n\n    return $wpide_prefooter_html() . $content;\n}, 10, 2 );",
            "requires": [],
            "notes": "Classic themes print it when they load footer.php; block themes get it before the footer template part."
        },
        {
            "id": "collapse-double-slashes-in-urls",
            "version": 1,
            "title": "Fix URLs With Double Slashes",
            "description": "Permanently redirects addresses that contain double slashes, such as /blog//my-post/, to the clean version, avoiding duplicate pages from broken links and badly built URLs.",
            "category": "redirects",
            "tags": [
                "redirects",
                "urls",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';\n    $pos = strpos( $uri, '?' );\n\n    $path  = false === $pos ? $uri : substr( $uri, 0, $pos );\n    $query = false === $pos ? '' : substr( $uri, $pos );\n\n    if ( false === strpos( $path, '//' ) ) {\n        return;\n    }\n\n    // /blog//my-post/ and /blog///my-post/ become /blog/my-post/.\n    $home = wp_parse_url( home_url() );\n    $base = $home['scheme'] . '://' . $home['host'] . ( isset( $home['port'] ) ? ':' . $home['port'] : '' );\n\n    wp_safe_redirect( $base . preg_replace( '#/{2,}#', '/', $path ) . $query, 301 );\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "The query string is kept as it is."
        },
        {
            "id": "country-redirects",
            "version": 1,
            "title": "Redirect Visitors by Country",
            "description": "Sends visitors from the countries you choose to a regional site or page, using the country header added by Cloudflare, CloudFront or your host, with search engine crawlers left alone.",
            "category": "redirects",
            "tags": [
                "redirects",
                "geolocation",
                "international"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Two-letter country code => address to send those visitors to. Empty = off. Examples:\n// 'CA' => 'https://example.ca/',\n// 'DE' => '/de/',\n$wpide_country_redirects = array();\n// true = only redirect visitors who land on the homepage; false = every page.\n$wpide_country_homepage_only = true;\n// Server headers that carry the visitor's country (Cloudflare, CloudFront, nginx GeoIP, many hosts).\n$wpide_country_headers = array( 'HTTP_CF_IPCOUNTRY', 'HTTP_CLOUDFRONT_VIEWER_COUNTRY', 'HTTP_X_COUNTRY_CODE', 'GEOIP_COUNTRY_CODE' );\n\nadd_action( 'template_redirect', function () use ( $wpide_country_redirects, $wpide_country_homepage_only, $wpide_country_headers ) {\n    if ( ! $wpide_country_redirects || is_user_logged_in() || is_feed() || is_robots() || ( $wpide_country_homepage_only && ! is_front_page() ) ) {\n        return;\n    }\n\n    // ?stay=1 remembers that the visitor wants this version of the site for 30 days.\n    if ( isset( $_GET['stay'] ) ) {\n        setcookie( 'wpide_geo_stay', '1', time() + 30 * DAY_IN_SECONDS, COOKIEPATH ? COOKIEPATH : '/', COOKIE_DOMAIN, is_ssl(), true );\n        return;\n    }\n\n    if ( isset( $_COOKIE['wpide_geo_stay'] ) ) {\n        return;\n    }\n\n    $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) ) : '';\n\n    // Never redirect search engine crawlers, so every version stays indexable.\n    if ( '' === $agent || preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|preview/', $agent ) ) {\n        return;\n    }\n\n    $country = '';\n\n    foreach ( $wpide_country_headers as $header ) {\n        if ( ! empty( $_SERVER[ $header ] ) ) {\n            $country = strtoupper( substr( sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ), 0, 2 ) );\n            break;\n        }\n    }\n\n    if ( '' === $country || empty( $wpide_country_redirects[ $country ] ) ) {\n        return;\n    }\n\n    $target = $wpide_country_redirects[ $country ];\n    $target = preg_match( '#^https?://#i', $target ) ? $target : home_url( $target );\n\n    nocache_headers();\n    wp_redirect( esc_url_raw( $target ), 302 ); // phpcs:ignore WordPress.Security.SafeRedirect -- targets are set by the site owner.\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "Off until you add countries. Needs a host or CDN that sends a country header; page caches must vary by country. Visitors can opt out with ?stay=1."
        },
        {
            "id": "app-download-smart-link",
            "version": 1,
            "title": "Smart App Download Link",
            "description": "Turns an address such as /app/ into one link for posters and QR codes: iPhone visitors go to the App Store, Android visitors to Google Play and everyone else to a page you choose.",
            "category": "redirects",
            "tags": [
                "redirects",
                "mobile-apps",
                "qr-codes"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// The short address to share, for example on posters, QR codes and email signatures.\n$wpide_app_link_path = '/app/';\n// Store pages of your app. Leave a value empty to skip that platform.\n$wpide_app_link_ios     = ''; // e.g. https://apps.apple.com/app/id0000000000\n$wpide_app_link_android = ''; // e.g. https://play.google.com/store/apps/details?id=com.example.app\n// Where everyone else goes (desktop browsers); empty = let WordPress show the page at that address.\n$wpide_app_link_other = '';\n\nadd_action( 'template_redirect', function () use ( $wpide_app_link_path, $wpide_app_link_ios, $wpide_app_link_android, $wpide_app_link_other ) {\n    if ( '' === $wpide_app_link_ios && '' === $wpide_app_link_android ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $want = (string) wp_parse_url( home_url( $wpide_app_link_path ), PHP_URL_PATH );\n\n    if ( '' === $path || strtolower( untrailingslashit( $path ) ) !== strtolower( untrailingslashit( $want ) ) ) {\n        return;\n    }\n\n    $agent  = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';\n    $target = $wpide_app_link_other;\n\n    if ( preg_match( '/iPhone|iPad|iPod/i', $agent ) && '' !== $wpide_app_link_ios ) {\n        $target = $wpide_app_link_ios;\n    } elseif ( false !== stripos( $agent, 'Android' ) && '' !== $wpide_app_link_android ) {\n        $target = $wpide_app_link_android;\n    }\n\n    if ( '' === $target ) {\n        return;\n    }\n\n    nocache_headers();\n    header( 'Vary: User-Agent', false );\n    wp_redirect( esc_url_raw( $target ), 302 ); // phpcs:ignore WordPress.Security.SafeRedirect -- store links set by the site owner.\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "Off until you add your store links; without a desktop address, desktop visitors see the page at that address, if any."
        },
        {
            "id": "go-links-for-affiliate-urls",
            "version": 1,
            "title": "Short /go/ Links for Affiliate and Partner URLs",
            "description": "Creates short links such as /go/amazon/ that redirect to long affiliate or partner URLs, so links in posts stay tidy and you can update a destination in one place.",
            "category": "redirects",
            "tags": [
                "redirects",
                "affiliate",
                "short-links"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Prefix of your short links: /go/amazon/ and so on.\n$wpide_go_prefix = 'go';\n// Short name => destination. Empty = off. Examples:\n// 'amazon'  => 'https://www.amazon.com/dp/B000000000?tag=yourtag-20',\n// 'hosting' => 'https://partner.example.com/?ref=you',\n$wpide_go_links = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_go_prefix, $wpide_go_links ) {\n    // Only when no real page uses the address.\n    if ( ! $wpide_go_links || ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $base = (string) wp_parse_url( home_url( '/' . trim( $wpide_go_prefix, '/' ) . '/' ), PHP_URL_PATH );\n\n    if ( 0 !== stripos( $path, $base ) ) {\n        return;\n    }\n\n    $slug = strtolower( trim( substr( $path, strlen( $base ) ), '/' ) );\n\n    if ( '' === $slug || empty( $wpide_go_links[ $slug ] ) ) {\n        return;\n    }\n\n    nocache_headers();\n    header( 'X-Robots-Tag: noindex, nofollow' );\n    // 307 is temporary: browsers and search engines do not remember it, so you can change destinations any time.\n    wp_redirect( esc_url_raw( $wpide_go_links[ $slug ] ), 307 ); // phpcs:ignore WordPress.Security.SafeRedirect -- destinations set by the site owner.\n    exit;\n}, 1 );\n\n// Ask search engines not to crawl the short links.\nadd_filter( 'robots_txt', function ( $output, $public ) use ( $wpide_go_prefix, $wpide_go_links ) {\n    if ( ! $public || ! $wpide_go_links ) {\n        return $output;\n    }\n\n    $path = (string) wp_parse_url( home_url( '/' . trim( $wpide_go_prefix, '/' ) . '/' ), PHP_URL_PATH );\n\n    return $output . \"\\nUser-agent: *\\nDisallow: \" . $path . \"\\n\";\n}, 10, 2 );",
            "requires": [],
            "notes": "Uses temporary 307 redirects with noindex and a robots.txt rule; real pages at the same address always win."
        },
        {
            "id": "removed-posts-redirect-to-category",
            "version": 1,
            "title": "Redirect Trashed Posts to Their Category",
            "description": "Remembers the address of each post or page you move to the trash and sends later visitors to its category, parent page or the blog page instead of a 404, even after the trash is emptied.",
            "category": "redirects",
            "tags": [
                "redirects",
                "404",
                "trash"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Post types to remember; when one is trashed, its old address leads to its first category (posts)\n// or its parent page (pages), and otherwise to the blog page.\n$wpide_removed_types = array( 'post', 'page' );\n// How many old addresses to remember (oldest are dropped first).\n$wpide_removed_limit = 500;\n\nadd_action( 'wp_trash_post', function ( $post_id ) use ( $wpide_removed_types, $wpide_removed_limit ) {\n    $post = get_post( $post_id );\n\n    if ( ! $post || 'publish' !== $post->post_status || ! in_array( $post->post_type, $wpide_removed_types, true ) ) {\n        return;\n    }\n\n    $target = '';\n\n    if ( 'post' === $post->post_type ) {\n        $cats   = wp_get_post_categories( $post->ID );\n        $target = $cats ? get_category_link( (int) $cats[0] ) : '';\n    } elseif ( $post->post_parent && 'publish' === get_post_status( $post->post_parent ) ) {\n        $target = get_permalink( $post->post_parent );\n    }\n\n    if ( ! $target ) {\n        $blog   = (int) get_option( 'page_for_posts' );\n        $target = $blog ? get_permalink( $blog ) : home_url( '/' );\n    }\n\n    $path = untrailingslashit( (string) wp_parse_url( get_permalink( $post ), PHP_URL_PATH ) );\n    $map  = (array) get_option( 'wpide_removed_post_redirects', array() );\n\n    unset( $map[ $path ] );\n    $map[ $path ] = array( 'to' => $target, 'id' => $post->ID );\n\n    update_option( 'wpide_removed_post_redirects', array_slice( $map, -absint( $wpide_removed_limit ), null, true ), false );\n} );\n\n// Restoring a post from the trash removes its redirect.\nadd_action( 'untrashed_post', function ( $post_id ) {\n    $map = (array) get_option( 'wpide_removed_post_redirects', array() );\n\n    foreach ( $map as $path => $item ) {\n        if ( isset( $item['id'] ) && (int) $item['id'] === (int) $post_id ) {\n            unset( $map[ $path ] );\n        }\n    }\n\n    update_option( 'wpide_removed_post_redirects', $map, false );\n} );\n\nadd_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $map  = get_option( 'wpide_removed_post_redirects', array() );\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? untrailingslashit( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) : '';\n\n    if ( ! is_array( $map ) || '' === $path || empty( $map[ $path ]['to'] ) ) {\n        return;\n    }\n\n    wp_safe_redirect( $map[ $path ]['to'], 301 );\n    exit;\n} );",
            "requires": [],
            "notes": "Restoring a post removes its redirect; up to 500 addresses are remembered."
        },
        {
            "id": "redirect-404-to-nearest-parent",
            "version": 1,
            "title": "Redirect Missing Pages to the Nearest Parent Page",
            "description": "When an address like /services/old-offer/ no longer exists, sends visitors to the closest existing level above it, such as /services/, instead of a dead end.",
            "category": "redirects",
            "tags": [
                "redirects",
                "404",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Status code: 301 (permanent) or 302 (temporary).\n$wpide_parent_redirect_status = 301;\n\nadd_action( 'template_redirect', function () use ( $wpide_parent_redirect_status ) {\n    if ( ! is_404() || ! get_option( 'permalink_structure' ) ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $base = untrailingslashit( (string) wp_parse_url( home_url(), PHP_URL_PATH ) );\n    $path = trim( substr( $path, strlen( $base ) ), '/' );\n\n    // Skip missing files such as images and scripts, and very deep or empty paths.\n    if ( '' === $path || preg_match( '/\\.[a-z0-9]{2,5}$/i', $path ) || substr_count( $path, '/' ) > 6 ) {\n        return;\n    }\n\n    $parts = explode( '/', $path );\n\n    while ( count( $parts ) > 1 ) {\n        array_pop( $parts );\n\n        $url = home_url( user_trailingslashit( implode( '/', $parts ) ) );\n\n        if ( url_to_postid( $url ) || get_page_by_path( implode( '/', $parts ) ) ) {\n            wp_safe_redirect( $url, (int) $wpide_parent_redirect_status );\n            exit;\n        }\n\n        $term = get_term_by( 'slug', end( $parts ), 'category' );\n\n        if ( $term && untrailingslashit( get_term_link( $term ) ) === untrailingslashit( $url ) ) {\n            wp_safe_redirect( $url, (int) $wpide_parent_redirect_status );\n            exit;\n        }\n    }\n} );",
            "requires": [],
            "notes": "Needs pretty permalinks; missing files such as images are skipped. Set the status to 302 while you test."
        },
        {
            "id": "redirects-from-csv-file",
            "version": 1,
            "title": "Redirects From a CSV File",
            "description": "Reads redirect rules from a redirects.csv file in wp-content, one \"old path, new address, status\" line each, so you can manage hundreds of redirects in a spreadsheet without a plugin.",
            "category": "redirects",
            "tags": [
                "redirects",
                "csv",
                "migration"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// The redirect file: one rule per line as  old-path,new-address[,status]  for example\n// /old-page/,/new-page/,301\n// /spring-sale/,https://shop.example.com/sale/,302\n// Kept outside the uploads folder so only people with file access can change it.\n$wpide_redirects_file = WP_CONTENT_DIR . '/redirects.csv';\n\nadd_action( 'template_redirect', function () use ( $wpide_redirects_file ) {\n    if ( ! is_readable( $wpide_redirects_file ) ) {\n        return;\n    }\n\n    $stamp = filemtime( $wpide_redirects_file ) . '-' . filesize( $wpide_redirects_file );\n    $cache = get_transient( 'wpide_csv_redirects' );\n\n    // Parse the file only when it changes.\n    if ( ! is_array( $cache ) || ! isset( $cache['stamp'] ) || $cache['stamp'] !== $stamp ) {\n        $rules  = array();\n        $handle = fopen( $wpide_redirects_file, 'r' ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n\n        while ( $handle && false !== ( $row = fgetcsv( $handle, 2048, ',', '\"', '' ) ) ) {\n            if ( count( $row ) < 2 || '' === trim( (string) $row[0] ) || 0 === strpos( trim( (string) $row[0] ), '#' ) ) {\n                continue;\n            }\n\n            $from   = strtolower( untrailingslashit( (string) wp_parse_url( trim( $row[0] ), PHP_URL_PATH ) ) );\n            $status = isset( $row[2] ) ? (int) $row[2] : 301;\n\n            $rules[ '' === $from ? '/' : $from ] = array(\n                'to'     => trim( $row[1] ),\n                'status' => in_array( $status, array( 301, 302, 307, 308, 410 ), true ) ? $status : 301,\n            );\n        }\n\n        if ( $handle ) {\n            fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions\n        }\n\n        $cache = array( 'stamp' => $stamp, 'rules' => $rules );\n        set_transient( 'wpide_csv_redirects', $cache, DAY_IN_SECONDS );\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? strtolower( untrailingslashit( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) ) : '';\n    $path = '' === $path ? '/' : $path;\n\n    if ( empty( $cache['rules'][ $path ] ) ) {\n        return;\n    }\n\n    $rule = $cache['rules'][ $path ];\n\n    if ( 410 === $rule['status'] ) {\n        status_header( 410 );\n        nocache_headers();\n        return;\n    }\n\n    $to = preg_match( '#^https?://#i', $rule['to'] ) ? $rule['to'] : home_url( $rule['to'] );\n\n    if ( strtolower( untrailingslashit( (string) wp_parse_url( $to, PHP_URL_PATH ) ) ) === $path && wp_parse_url( $to, PHP_URL_HOST ) === wp_parse_url( home_url(), PHP_URL_HOST ) ) {\n        return; // Would redirect to itself.\n    }\n\n    wp_redirect( esc_url_raw( $to ), $rule['status'] ); // phpcs:ignore WordPress.Security.SafeRedirect -- rules come from the site owner's file.\n    exit;\n}, 1 );",
            "requires": [],
            "notes": "Nothing happens until the file exists; it is re-read only when it changes. Supports 301, 302, 307, 308 and 410."
        },
        {
            "id": "latest-post-shortcut-link",
            "version": 1,
            "title": "/latest/ Link to Your Newest Post",
            "description": "Makes /latest/ always open your newest post and /latest/category-name/ the newest post in that category, a link that never goes stale for social bios, emails and QR codes.",
            "category": "redirects",
            "tags": [
                "redirects",
                "short-links",
                "posts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// /latest/ opens your newest post; /latest/news/ opens the newest post in the News category.\n$wpide_latest_path = 'latest';\n\nadd_action( 'template_redirect', function () use ( $wpide_latest_path ) {\n    // Only when no real page uses the address.\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $base = untrailingslashit( (string) wp_parse_url( home_url( '/' . trim( $wpide_latest_path, '/' ) ), PHP_URL_PATH ) );\n\n    if ( '' === $path || ( strtolower( untrailingslashit( $path ) ) !== strtolower( $base ) && 0 !== stripos( $path, $base . '/' ) ) ) {\n        return;\n    }\n\n    $category = sanitize_title( trim( substr( $path, strlen( $base ) ), '/' ) );\n    $cache    = get_transient( 'wpide_latest_posts' );\n    $cache    = is_array( $cache ) ? $cache : array();\n    $post_id  = isset( $cache[ $category ] ) ? (int) $cache[ $category ] : null;\n\n    if ( null === $post_id ) {\n        $args = array(\n            'post_type'           => 'post',\n            'posts_per_page'      => 1,\n            'has_password'        => false,\n            'ignore_sticky_posts' => true,\n            'no_found_rows'       => true,\n            'fields'              => 'ids',\n        );\n\n        if ( '' !== $category ) {\n            $args['category_name'] = $category;\n        }\n\n        $ids     = get_posts( $args );\n        $post_id = $ids ? (int) $ids[0] : 0;\n\n        // Keep at most 50 category answers in the cache.\n        $cache[ $category ] = $post_id;\n        set_transient( 'wpide_latest_posts', array_slice( $cache, -50, null, true ), DAY_IN_SECONDS );\n    }\n\n    if ( ! $post_id ) {\n        return;\n    }\n\n    nocache_headers();\n    wp_safe_redirect( get_permalink( $post_id ), 302 );\n    exit;\n}, 1 );\n\n// Forget the cached answers whenever a post is published, updated or removed.\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    if ( 'post' === $post->post_type && ( 'publish' === $new_status || 'publish' === $old_status ) ) {\n        delete_transient( 'wpide_latest_posts' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Only used when no real page has that address; change the path at the top of the snippet."
        },
        {
            "id": "gone-410-for-removed-urls",
            "version": 1,
            "title": "Return 410 Gone for Removed Pages",
            "description": "Answers requests for pages you deleted on purpose with a \"410 Gone\" status instead of a 404, so search engines drop them from results faster.",
            "category": "redirects",
            "tags": [
                "redirects",
                "410",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Addresses you removed on purpose and that will not come back. Empty = off. A trailing * matches everything below. Examples:\n// '/old-promo/',\n// '/discontinued-products/*',\n$wpide_gone_paths = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_gone_paths ) {\n    // Only for addresses that are missing anyway, so live content can never be hidden by mistake.\n    if ( ! $wpide_gone_paths || ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? strtolower( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) : '';\n\n    foreach ( $wpide_gone_paths as $gone ) {\n        $gone = strtolower( (string) wp_parse_url( home_url( $gone ), PHP_URL_PATH ) );\n\n        $match = '*' === substr( $gone, -1 )\n            ? 0 === strpos( $path, rtrim( $gone, '*' ) )\n            : untrailingslashit( $gone ) === untrailingslashit( $path );\n\n        if ( $match ) {\n            // 410 tells search engines the page is gone for good, so they drop it faster than a 404.\n            status_header( 410 );\n            nocache_headers();\n            header( 'X-Robots-Tag: noindex' );\n            return;\n        }\n    }\n} );",
            "requires": [],
            "notes": "The list is empty by default; only addresses that are already missing get the 410, so live content is never hidden."
        },
        {
            "id": "random-post-link",
            "version": 1,
            "title": "/random/ Link to a Random Post",
            "description": "Makes /random/ open a random published post, a fun \"Surprise me\" link for menus that keeps readers exploring your archive.",
            "category": "redirects",
            "tags": [
                "redirects",
                "random",
                "engagement"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// /random/ opens a random published post: a fun \"Surprise me\" link for menus.\n$wpide_random_path = 'random';\n// Pick from this many of your newest posts.\n$wpide_random_pool = 500;\n\nadd_action( 'template_redirect', function () use ( $wpide_random_path, $wpide_random_pool ) {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) : '';\n    $want = (string) wp_parse_url( home_url( '/' . trim( $wpide_random_path, '/' ) ), PHP_URL_PATH );\n\n    if ( strtolower( untrailingslashit( $path ) ) !== strtolower( untrailingslashit( $want ) ) ) {\n        return;\n    }\n\n    $ids = get_transient( 'wpide_random_post_pool' );\n\n    if ( ! is_array( $ids ) ) {\n        $ids = get_posts( array(\n            'post_type'           => 'post',\n            'posts_per_page'      => max( 1, (int) $wpide_random_pool ),\n            'has_password'        => false,\n            'ignore_sticky_posts' => true,\n            'no_found_rows'       => true,\n            'fields'              => 'ids',\n        ) );\n        set_transient( 'wpide_random_post_pool', $ids, 6 * HOUR_IN_SECONDS );\n    }\n\n    if ( ! $ids ) {\n        return;\n    }\n\n    nocache_headers();\n    wp_safe_redirect( get_permalink( $ids[ array_rand( $ids ) ] ), 302 );\n    exit;\n} );\n\nadd_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {\n    if ( 'post' === $post->post_type && ( 'publish' === $new_status || 'publish' === $old_status ) ) {\n        delete_transient( 'wpide_random_post_pool' );\n    }\n}, 10, 3 );",
            "requires": [],
            "notes": "Picks from your 500 newest posts by default; only used when no real page has that address."
        },
        {
            "id": "redirect-page-numbers-past-the-end",
            "version": 1,
            "title": "Send Page Numbers Past the End to the Last Page",
            "description": "When someone opens a page number that no longer exists, such as /category/news/page/40/ after you deleted posts, sends them to the real last page instead of a 404.",
            "category": "redirects",
            "tags": [
                "redirects",
                "pagination",
                "404"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    $paged = (int) get_query_var( 'paged' );\n\n    // A list page (blog, archive, search) asked for a page number that does not exist.\n    if ( ! is_404() || $paged < 2 ) {\n        return;\n    }\n\n    $vars = $wp_query->query_vars;\n\n    $vars['paged']          = 1;\n    $vars['fields']         = 'ids';\n    $vars['no_found_rows']  = false;\n    $vars['posts_per_page'] = max( 1, (int) $wp_query->get( 'posts_per_page' ) );\n\n    $check = new WP_Query( $vars );\n    $last  = (int) $check->max_num_pages;\n\n    if ( $last < 1 || $paged <= $last ) {\n        return;\n    }\n\n    // /category/news/page/40/ goes to the real last page, for example /category/news/page/7/.\n    wp_safe_redirect( get_pagenum_link( $last, false ), 301 );\n    exit;\n} );",
            "requires": [],
            "notes": "Works for the blog page, archives and search results."
        },
        {
            "id": "missing-image-sizes-to-original",
            "version": 1,
            "title": "Redirect Missing Resized Images to the Original",
            "description": "Sends requests for resized images that no longer exist, such as photo-300x200.jpg after a theme change, to the original upload, fixing broken images in old posts and on other sites.",
            "category": "redirects",
            "tags": [
                "redirects",
                "images",
                "uploads"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    if ( ! is_404() ) {\n        return;\n    }\n\n    $path    = isset( $_SERVER['REQUEST_URI'] ) ? rawurldecode( (string) wp_parse_url( wp_unslash( $_SERVER['REQUEST_URI'] ), PHP_URL_PATH ) ) : '';\n    $uploads = wp_get_upload_dir();\n    $base    = (string) wp_parse_url( $uploads['baseurl'], PHP_URL_PATH );\n\n    // Only resized copies such as photo-300x200.jpg inside the uploads folder.\n    if ( '' === $path || 0 !== strpos( $path, trailingslashit( $base ) ) || ! preg_match( '/^(.+)-\\d+x\\d+(\\.(?:jpe?g|png|gif|webp|avif))$/i', $path, $m ) ) {\n        return;\n    }\n\n    $relative = substr( $m[1], strlen( $base ) );\n\n    if ( false !== strpos( $relative, '..' ) ) {\n        return;\n    }\n\n    // Large uploads keep their original as name-scaled.jpg; check that first.\n    foreach ( array( '-scaled', '' ) as $suffix ) {\n        $file = $relative . $suffix . $m[2];\n\n        if ( file_exists( $uploads['basedir'] . $file ) ) {\n            wp_safe_redirect( $uploads['baseurl'] . implode( '/', array_map( 'rawurlencode', explode( '/', $file ) ) ), 301 );\n            exit;\n        }\n    }\n} );",
            "requires": [],
            "notes": "Only works when your web server passes missing upload files to WordPress, as the default Apache setup does."
        },
        {
            "id": "redirect-old-amp-urls",
            "version": 1,
            "title": "Redirect Old AMP Addresses After Removing AMP",
            "description": "After you stop using an AMP plugin, permanently redirects the old /amp/ and ?amp=1 addresses to the normal pages, so search engines and shared links do not hit errors.",
            "category": "redirects",
            "tags": [
                "redirects",
                "amp",
                "seo"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    // Leave everything alone while an AMP plugin is active.\n    if ( function_exists( 'amp_is_request' ) || function_exists( 'is_amp_endpoint' ) || defined( 'AMPFORWP_VERSION' ) ) {\n        return;\n    }\n\n    $uri  = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';\n    $path = (string) wp_parse_url( $uri, PHP_URL_PATH );\n    $home = wp_parse_url( home_url() );\n    $base = $home['scheme'] . '://' . $home['host'] . ( isset( $home['port'] ) ? ':' . $home['port'] : '' );\n\n    // ?amp=1 or ?amp on any page: drop the parameter.\n    if ( isset( $_GET['amp'] ) ) {\n        wp_safe_redirect( remove_query_arg( 'amp', $base . $uri ), 301 );\n        exit;\n    }\n\n    // /my-post/amp/ that no longer exists: go to /my-post/.\n    if ( is_404() && preg_match( '#^(.*)/amp/?$#i', $path, $m ) && '' !== $m[1] ) {\n        $query = (string) wp_parse_url( $uri, PHP_URL_QUERY );\n\n        wp_safe_redirect( $base . user_trailingslashit( $m[1] ) . ( '' !== $query ? '?' . $query : '' ), 301 );\n        exit;\n    }\n}, 1 );",
            "requires": [],
            "notes": "Does nothing while an AMP plugin is active."
        },
        {
            "id": "search-pages-first",
            "version": 1,
            "title": "Show Pages Before Posts in Search Results",
            "description": "Lists matching pages first in search results, then blog posts, then other content, so key pages like pricing or contact are not buried under older posts.",
            "category": "search",
            "tags": [
                "search",
                "ordering",
                "pages"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Content types in the order they should appear in search results; anything else follows.\n$wpide_search_type_order = array( 'page', 'post' );\n\nadd_filter( 'posts_orderby', function ( $orderby, $query ) use ( $wpide_search_type_order ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() || ! $wpide_search_type_order ) {\n        return $orderby;\n    }\n\n    $case = \"CASE {$wpdb->posts}.post_type\";\n\n    foreach ( array_values( $wpide_search_type_order ) as $i => $type ) {\n        $case .= $wpdb->prepare( ' WHEN %s THEN %d', $type, $i );\n    }\n\n    $case .= ' ELSE ' . count( $wpide_search_type_order ) . ' END ASC';\n\n    // Within each type, WordPress's own relevance order is kept.\n    return '' !== trim( $orderby ) ? $case . ', ' . $orderby : $case;\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the order list to rank other types, such as product or docs, at the top."
        },
        {
            "id": "search-synonyms",
            "version": 1,
            "title": "Search Synonyms",
            "description": "Makes searches for one word also find posts that use another word with the same meaning, for example \"sofa\" also finds \"couch\", using groups you define.",
            "category": "search",
            "tags": [
                "search",
                "synonyms",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Groups of words that mean the same thing on your site; a search for any of them finds them all. Empty = off. Examples:\n// array( 'sofa', 'couch', 'settee' ),\n// array( 'tv', 'television' ),\n$wpide_search_synonyms = array();\n\nadd_filter( 'posts_search', function ( $search, $query ) use ( $wpide_search_synonyms ) {\n    global $wpdb;\n\n    if ( '' === $search || ! $wpide_search_synonyms || is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $search;\n    }\n\n    $terms = (array) $query->get( 'search_terms' );\n\n    // Leave exclusions (\"-word\") and exact-phrase searches to WordPress.\n    if ( ! $terms || $query->get( 'exact' ) || $query->get( 'sentence' ) || preg_grep( '/^-/', $terms ) ) {\n        return $search;\n    }\n\n    $lookup = array();\n\n    foreach ( $wpide_search_synonyms as $group ) {\n        $group = array_map( 'strtolower', array_map( 'trim', (array) $group ) );\n\n        foreach ( $group as $word ) {\n            $lookup[ $word ] = $group;\n        }\n    }\n\n    $changed = false;\n    $parts   = array();\n\n    foreach ( $terms as $term ) {\n        $words = isset( $lookup[ strtolower( $term ) ] ) ? $lookup[ strtolower( $term ) ] : array( $term );\n        $ors   = array();\n\n        $changed = $changed || count( $words ) > 1;\n\n        foreach ( $words as $word ) {\n            $like = '%' . $wpdb->esc_like( $word ) . '%';\n\n            $ors[] = $wpdb->prepare( \"({$wpdb->posts}.post_title LIKE %s) OR ({$wpdb->posts}.post_excerpt LIKE %s) OR ({$wpdb->posts}.post_content LIKE %s)\", $like, $like, $like );\n        }\n\n        $parts[] = '(' . implode( ' OR ', $ors ) . ')';\n    }\n\n    if ( ! $changed ) {\n        return $search;\n    }\n\n    $search = ' AND (' . implode( ' AND ', $parts ) . ') ';\n\n    if ( ! is_user_logged_in() ) {\n        $search .= \" AND ({$wpdb->posts}.post_password = '') \";\n    }\n\n    return $search;\n}, 10, 2 );",
            "requires": [],
            "notes": "The list is empty by default; add groups of words. Searches with -exclusions or exact phrases are left to WordPress."
        },
        {
            "id": "search-form-category-dropdown",
            "version": 1,
            "title": "Category Dropdown in the Search Form",
            "description": "Adds a category dropdown to the site search form and the Search block, so visitors can search all content or only one category.",
            "category": "search",
            "tags": [
                "search",
                "categories",
                "forms"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Text of the first option, which searches everything.\n$wpide_search_cat_all = 'All categories';\n\n$wpide_search_cat_select = function () use ( $wpide_search_cat_all ) {\n    static $html = null;\n\n    if ( null === $html ) {\n        $id   = wp_unique_id( 'wpide-search-cat-' );\n        $html = '<label class=\"screen-reader-text\" for=\"' . esc_attr( $id ) . '\">Category</label>' . wp_dropdown_categories( array(\n            'show_option_all' => $wpide_search_cat_all,\n            'name'            => 'cat',\n            'id'              => $id,\n            'class'           => 'wpide-search-cat',\n            'selected'        => absint( get_query_var( 'cat' ) ),\n            'hierarchical'    => true,\n            'hide_empty'      => true,\n            'echo'            => false,\n        ) );\n    }\n\n    return $html;\n};\n\n$wpide_search_cat_insert = function ( $form ) use ( $wpide_search_cat_select ) {\n    if ( false !== strpos( $form, 'wpide-search-cat' ) ) {\n        return $form;\n    }\n\n    // Before the search button, or before the end of the form when there is no button.\n    $count = 0;\n    $form  = preg_replace( '#(<button|<input[^>]+type=[\"\\']submit)#i', $wpide_search_cat_select() . '$1', $form, 1, $count );\n\n    return $count ? $form : str_replace( '</form>', $wpide_search_cat_select() . '</form>', $form );\n};\n\nadd_filter( 'get_search_form', $wpide_search_cat_insert );\nadd_filter( 'render_block_core/search', $wpide_search_cat_insert );\n\nadd_action( 'wp_head', function () {\n    echo '<style>.wpide-search-cat{max-width:100%;margin:0 .25em}.wp-block-search__inside-wrapper .wpide-search-cat{border:0;background:transparent}</style>';\n} );",
            "requires": [],
            "notes": "Do not combine with the Search Within the Current Category snippet; both use the same field."
        },
        {
            "id": "did-you-mean-search-suggestions",
            "version": 1,
            "title": "\"Did You Mean\" Suggestions for Empty Searches",
            "description": "When a search finds nothing, suggests a corrected spelling based on the words in your post titles, such as \"Did you mean chocolate brownies?\", with a link to search again.",
            "category": "search",
            "tags": [
                "search",
                "spelling",
                "suggestions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Text before the suggestion.\n$wpide_dym_label = 'Did you mean';\n\n$wpide_dym_vocabulary = function () {\n    $words = get_transient( 'wpide_dym_words' );\n\n    if ( is_array( $words ) ) {\n        return $words;\n    }\n\n    global $wpdb;\n\n    $types  = array_values( get_post_types( array( 'exclude_from_search' => false ) ) );\n    $holder = implode( ',', array_fill( 0, count( $types ), '%s' ) );\n    $titles = $wpdb->get_col( $wpdb->prepare( \"SELECT post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($holder) ORDER BY post_date DESC LIMIT 3000\", $types ) ); // phpcs:ignore WordPress.DB\n\n    $words = array();\n\n    foreach ( $titles as $title ) {\n        foreach ( preg_split( '/[^\\p{L}\\p{N}]+/u', strtolower( $title ), -1, PREG_SPLIT_NO_EMPTY ) as $word ) {\n            if ( strlen( $word ) > 2 ) {\n                $words[ $word ] = true;\n            }\n        }\n    }\n\n    $words = array_keys( $words );\n    set_transient( 'wpide_dym_words', $words, DAY_IN_SECONDS );\n\n    return $words;\n};\n\nadd_action( 'save_post', function ( $post_id, $post ) {\n    if ( 'publish' === $post->post_status ) {\n        delete_transient( 'wpide_dym_words' );\n    }\n}, 10, 2 );\n\n$wpide_dym_html = function () use ( $wpide_dym_vocabulary, $wpide_dym_label ) {\n    static $html = null;\n\n    if ( null !== $html ) {\n        return $html;\n    }\n\n    $html  = '';\n    $terms = (array) get_query_var( 'search_terms' );\n    $vocab = $wpide_dym_vocabulary();\n\n    if ( ! $terms || ! $vocab || count( $terms ) > 6 ) {\n        return $html;\n    }\n\n    $lookup  = array_flip( $vocab );\n    $changed = false;\n    $fixed   = array();\n\n    foreach ( $terms as $term ) {\n        $term = strtolower( $term );\n        $best = $term;\n\n        if ( strlen( $term ) > 3 && ! isset( $lookup[ $term ] ) ) {\n            // Allow 1 typo in short words and 2 in longer ones.\n            $limit = strlen( $term ) >= 7 ? 2 : 1;\n            $score = $limit + 1;\n\n            foreach ( $vocab as $word ) {\n                if ( abs( strlen( $word ) - strlen( $term ) ) > $limit ) {\n                    continue;\n                }\n\n                $distance = levenshtein( $term, $word );\n\n                if ( $distance < $score ) {\n                    $score = $distance;\n                    $best  = $word;\n                }\n            }\n        }\n\n        $changed = $changed || $best !== $term;\n        $fixed[] = $best;\n    }\n\n    if ( $changed ) {\n        $suggestion = implode( ' ', $fixed );\n        $html       = '<p class=\"wpide-did-you-mean\">' . esc_html( $wpide_dym_label ) . ' <a href=\"' . esc_url( get_search_link( $suggestion ) ) . '\"><strong>' . esc_html( $suggestion ) . '</strong></a>?</p>';\n    }\n\n    return $html;\n};\n\n// Classic themes: printed where the \"nothing found\" message starts.\nadd_action( 'loop_no_results', function ( $query ) use ( $wpide_dym_html ) {\n    static $printed = false;\n\n    if ( $printed || wp_is_block_theme() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $printed = true;\n    echo $wpide_dym_html(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped when built.\n} );\n\n// Block themes: added to the Query block's \"No results\" area.\nadd_filter( 'render_block_core/query-no-results', function ( $content ) use ( $wpide_dym_html ) {\n    return is_search() && '' !== trim( $content ) ? $wpide_dym_html() . $content : $content;\n} );",
            "requires": [],
            "notes": "Works best for Latin-alphabet words; the word list is cached for a day and refreshed when you publish."
        },
        {
            "id": "search-result-type-labels",
            "version": 1,
            "title": "Content Type Labels in Search Results",
            "description": "Shows a small label such as \"Page\", \"Post\" or \"Product\" in front of each search result title, so visitors can tell results apart at a glance.",
            "category": "search",
            "tags": [
                "search",
                "labels",
                "results"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'post_class', function ( $classes, $extra, $post_id ) {\n    if ( is_search() && ! is_admin() ) {\n        $classes[] = 'wpide-result-type-' . sanitize_html_class( get_post_type( $post_id ) );\n    }\n\n    return $classes;\n}, 10, 3 );\n\nadd_action( 'wp_head', function () {\n    if ( ! is_search() ) {\n        return;\n    }\n\n    $css = '';\n\n    foreach ( get_post_types( array( 'exclude_from_search' => false ), 'objects' ) as $type ) {\n        $label = str_replace( array( '\\\\', '\"', '<', '>' ), '', $type->labels->singular_name );\n\n        $css .= '.wpide-result-type-' . sanitize_html_class( $type->name ) . ' :is(.entry-title,.wp-block-post-title)::before{content:\"' . $label . '\";}';\n    }\n\n    // A small pill in front of each result title, such as \"Page\" or \"Product\".\n    $css .= '[class*=\"wpide-result-type-\"] :is(.entry-title,.wp-block-post-title)::before{display:inline-block;margin-right:.5em;padding:.15em .6em;border-radius:999px;border:1px solid currentColor;font-size:.55em;font-weight:600;letter-spacing:.03em;line-height:1.4;text-transform:uppercase;vertical-align:middle}';\n\n    echo '<style id=\"wpide-result-type-labels\">' . $css . '</style>'; // phpcs:ignore WordPress.Security.EscapeOutput -- labels stripped of quotes and markup above.\n} );",
            "requires": [],
            "notes": "Uses the singular name of each post type; works with themes that use the standard entry-title or Post Title block classes."
        },
        {
            "id": "search-include-documents",
            "version": 1,
            "title": "Include PDFs and Documents in Search Results",
            "description": "Lets the site search find PDF, Word, Excel and PowerPoint files from the Media Library by their title, caption and description, alongside posts and pages.",
            "category": "search",
            "tags": [
                "search",
                "media",
                "pdf"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// File types to include, as the start of their MIME type: PDF, Word, Excel and PowerPoint by default. Add 'image/' for images.\n$wpide_search_file_types = array( 'application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument', 'application/vnd.ms-' );\n\nadd_action( 'pre_get_posts', function ( $query ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return;\n    }\n\n    $types = $query->get( 'post_type' );\n\n    if ( $types && 'any' !== $types ) {\n        $query->set( 'post_type', array_unique( array_merge( (array) $types, array( 'attachment' ) ) ) );\n    }\n\n    // Uploaded files have the status \"inherit\".\n    $query->set( 'post_status', array( 'publish', 'inherit' ) );\n    $query->set( 'wpide_search_files', true );\n} );\n\nadd_filter( 'posts_where', function ( $where, $query ) use ( $wpide_search_file_types ) {\n    global $wpdb;\n\n    if ( ! $query->get( 'wpide_search_files' ) ) {\n        return $where;\n    }\n\n    $mimes = array();\n\n    foreach ( $wpide_search_file_types as $type ) {\n        $mimes[] = $wpdb->prepare( \"{$wpdb->posts}.post_mime_type LIKE %s\", $wpdb->esc_like( $type ) . '%' );\n    }\n\n    $mimes = $mimes ? '(' . implode( ' OR ', $mimes ) . ')' : '1=0';\n\n    // Files must have a matching type and be unattached or attached to published content; other \"inherit\" items (revisions) are left out.\n    $where .= \" AND ( {$wpdb->posts}.post_status = 'publish' OR ( {$wpdb->posts}.post_type = 'attachment' AND {$mimes} AND ( {$wpdb->posts}.post_parent = 0 OR {$wpdb->posts}.post_parent IN ( SELECT ID FROM ( SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' ) AS wpide_published ) ) ) )\";\n\n    return $where;\n}, 10, 2 );",
            "requires": [],
            "notes": "Files attached to unpublished content stay hidden. Add 'image/' to the type list to include images too."
        },
        {
            "id": "search-custom-stop-words",
            "version": 1,
            "title": "Custom Stop Words for Site Search",
            "description": "Ignores filler words such as \"can\", \"my\" or \"need\" in multi-word searches, so \"how can I reset my password\" finds posts about resetting passwords.",
            "category": "search",
            "tags": [
                "search",
                "stopwords",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Extra words to ignore in multi-word searches, so \"how can I reset my password\" searches for \"reset password\".\n$wpide_extra_stopwords = array( 'can', 'do', 'does', 'i', 'my', 'me', 'you', 'your', 'want', 'need', 'please', 'why', 'which', 'get' );\n// Words WordPress ignores by default that matter on your site (for example 'who' for a band site).\n$wpide_keep_words = array();\n\nadd_filter( 'wp_search_stopwords', function ( $stopwords ) use ( $wpide_extra_stopwords, $wpide_keep_words ) {\n    $stopwords = array_merge( $stopwords, array_map( 'strtolower', $wpide_extra_stopwords ) );\n\n    return array_values( array_diff( array_unique( $stopwords ), array_map( 'strtolower', $wpide_keep_words ) ) );\n} );",
            "requires": [],
            "notes": "WordPress only drops stop words from searches with several words; list words that matter on your site under the keep list."
        },
        {
            "id": "search-matches-author-names",
            "version": 1,
            "title": "Search Posts by Author Name",
            "description": "Makes the site search also return posts written by authors whose name matches the search, so \"maria\" finds everything by Maria Lopez.",
            "category": "search",
            "tags": [
                "search",
                "authors",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_search', function ( $search, $query ) {\n    global $wpdb;\n\n    if ( '' === $search || is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $search;\n    }\n\n    $phrase = trim( (string) $query->get( 's' ) );\n\n    if ( strlen( $phrase ) < 3 ) {\n        return $search;\n    }\n\n    // Authors whose display name contains the search, for example \"maria\" finds posts by Maria Lopez.\n    $authors = get_users( array(\n        'search'              => '*' . $phrase . '*',\n        'search_columns'      => array( 'display_name' ),\n        'has_published_posts' => true,\n        'number'              => 20,\n        'fields'              => 'ID',\n    ) );\n\n    if ( ! $authors ) {\n        return $search;\n    }\n\n    $password = '';\n    $marker   = \" AND ({$wpdb->posts}.post_password = '') \";\n\n    if ( substr( $search, -strlen( $marker ) ) === $marker ) {\n        $password = $marker;\n        $search   = substr( $search, 0, -strlen( $marker ) );\n    }\n\n    $inner = preg_replace( '/^\\s*AND\\s*/', '', $search );\n    $ids   = implode( ',', array_map( 'absint', $authors ) );\n\n    return \" AND ( {$wpdb->posts}.post_author IN ({$ids}) OR {$inner} ){$password}\";\n}, 10, 2 );",
            "requires": [],
            "notes": "Matches display names of users who have published posts, for searches of three characters or more."
        },
        {
            "id": "search-within-current-category",
            "version": 1,
            "title": "Search Within the Current Category",
            "description": "On category pages, adds an \"Only in [category]\" checkbox to the search form and Search block, ticked by default, so visitors can search just that section.",
            "category": "search",
            "tags": [
                "search",
                "categories",
                "forms"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Label of the checkbox; %s is the category name.\n$wpide_search_in_cat_label = 'Only in %s';\n\n$wpide_search_in_cat = function ( $form ) use ( $wpide_search_in_cat_label ) {\n    $cat_id = is_category() ? get_queried_object_id() : ( is_search() ? absint( get_query_var( 'cat' ) ) : 0 );\n    $cat    = $cat_id ? get_category( $cat_id ) : null;\n\n    if ( ! $cat || is_wp_error( $cat ) || false !== strpos( $form, 'wpide-search-in-cat' ) ) {\n        return $form;\n    }\n\n    $box = '<label class=\"wpide-search-in-cat\" style=\"display:block;flex-basis:100%;margin-top:.4em;font-size:.9em;\"><input type=\"checkbox\" name=\"cat\" value=\"' . esc_attr( $cat->term_id ) . '\" checked /> ' . esc_html( sprintf( $wpide_search_in_cat_label, $cat->name ) ) . '</label>';\n\n    return str_replace( '</form>', $box . '</form>', $form );\n};\n\nadd_filter( 'get_search_form', $wpide_search_in_cat );\nadd_filter( 'render_block_core/search', $wpide_search_in_cat );",
            "requires": [],
            "notes": "Do not combine with the Category Dropdown in the Search Form snippet; both use the same field."
        },
        {
            "id": "search-skip-noindexed-content",
            "version": 1,
            "title": "Leave Noindexed Content Out of Site Search",
            "description": "Hides posts and pages that you set to noindex in Yoast SEO or Rank Math from your own site search, such as thank-you pages and thin landing pages.",
            "category": "search",
            "tags": [
                "search",
                "seo",
                "noindex"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_filter( 'posts_where', function ( $where, $query ) {\n    global $wpdb;\n\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $where;\n    }\n\n    // Yoast SEO: \"Allow search engines to show this content\" set to No.\n    if ( defined( 'WPSEO_VERSION' ) ) {\n        $where .= \" AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wpide_ny WHERE wpide_ny.post_id = {$wpdb->posts}.ID AND wpide_ny.meta_key = '_yoast_wpseo_meta-robots-noindex' AND wpide_ny.meta_value = '1' )\";\n    }\n\n    // Rank Math: \"No Index\" ticked in the robots meta.\n    if ( defined( 'RANK_MATH_VERSION' ) ) {\n        $where .= \" AND NOT EXISTS ( SELECT 1 FROM {$wpdb->postmeta} wpide_nr WHERE wpide_nr.post_id = {$wpdb->posts}.ID AND wpide_nr.meta_key = 'rank_math_robots' AND wpide_nr.meta_value LIKE '%noindex%' )\";\n    }\n\n    return $where;\n}, 10, 2 );",
            "requires": [],
            "notes": "Uses each post's own robots setting; content type-wide noindex settings in the SEO plugin are not checked."
        },
        {
            "id": "search-recent-posts-only",
            "version": 1,
            "title": "Only Recent Blog Posts in Search Results",
            "description": "Leaves blog posts older than three years out of search results, while pages and other content types stay searchable, which suits news sites and fast-moving topics.",
            "category": "search",
            "tags": [
                "search",
                "dates",
                "relevance"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Blog posts older than this many months are left out of search results. Pages and other content types are not affected.\n$wpide_search_max_age_months = 36;\n\nadd_filter( 'posts_where', function ( $where, $query ) use ( $wpide_search_max_age_months ) {\n    global $wpdb;\n\n    if ( $wpide_search_max_age_months < 1 || is_admin() || ! $query->is_main_query() || ! $query->is_search() ) {\n        return $where;\n    }\n\n    $cutoff = wp_date( 'Y-m-d H:i:s', strtotime( '-' . absint( $wpide_search_max_age_months ) . ' months' ) );\n\n    return $where . $wpdb->prepare( \" AND ( {$wpdb->posts}.post_type <> 'post' OR {$wpdb->posts}.post_date >= %s )\", $cutoff );\n}, 10, 2 );",
            "requires": [],
            "notes": "Change the number of months at the top; old posts remain reachable through archives and links."
        },
        {
            "id": "search-keyword-redirects",
            "version": 1,
            "title": "Send Certain Searches Straight to a Page",
            "description": "Opens a chosen page directly when someone searches for an exact word or phrase, for example \"contact\" goes to your contact page and \"returns\" to your returns policy.",
            "category": "search",
            "tags": [
                "search",
                "redirects",
                "shortcuts"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Search words => page to open instead of the results. Matching ignores upper/lower case. Empty = off. Examples:\n// 'contact'  => '/contact/',\n// 'opening hours' => '/visit/#hours',\n$wpide_search_shortcuts = array();\n\nadd_action( 'template_redirect', function () use ( $wpide_search_shortcuts ) {\n    if ( ! $wpide_search_shortcuts || ! is_search() || is_paged() ) {\n        return;\n    }\n\n    $phrase = strtolower( trim( preg_replace( '/\\s+/', ' ', (string) get_search_query( false ) ) ) );\n\n    foreach ( $wpide_search_shortcuts as $words => $target ) {\n        if ( strtolower( trim( $words ) ) === $phrase ) {\n            wp_safe_redirect( preg_match( '#^https?://#i', $target ) ? $target : home_url( $target ), 302 );\n            exit;\n        }\n    }\n} );",
            "requires": [],
            "notes": "The list is empty by default; matching ignores capital letters and extra spaces, and only whole searches match."
        },
        {
            "id": "author-bio-on-author-archives",
            "version": 1,
            "title": "Author Bio at the Top of Author Archives",
            "description": "Shows the author's photo, name, number of posts, bio and website at the top of their author archive, turning it into a proper profile page.",
            "category": "archive",
            "tags": [
                "archives",
                "authors",
                "bio"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Size of the author photo in pixels.\n$wpide_author_archive_avatar = 96;\n\n$wpide_author_archive_box = function () use ( $wpide_author_archive_avatar ) {\n    $user = get_queried_object();\n\n    if ( ! $user instanceof WP_User ) {\n        return '';\n    }\n\n    $bio     = get_the_author_meta( 'description', $user->ID );\n    $website = $user->user_url;\n    $count   = count_user_posts( $user->ID, 'post', true );\n\n    $html  = '<section class=\"wpide-author-intro\" style=\"display:flex;flex-wrap:wrap;gap:1em;align-items:flex-start;margin:0 0 2em;padding:1.25em;border:1px solid rgba(127,127,127,.3);border-radius:8px;\">';\n    $html .= get_avatar( $user->ID, $wpide_author_archive_avatar, '', '', array( 'extra_attr' => 'style=\"border-radius:50%;flex:0 0 auto;\"' ) );\n    $html .= '<div style=\"flex:1 1 240px;min-width:0;\">';\n    $html .= '<p style=\"margin:0 0 .25em;\"><strong>' . esc_html( $user->display_name ) . '</strong> · ' . esc_html( sprintf( _n( '%s post', '%s posts', $count ), number_format_i18n( $count ) ) ) . '</p>';\n\n    if ( '' !== $bio ) {\n        $html .= wpautop( wp_kses_post( $bio ) );\n    }\n\n    if ( $website ) {\n        $html .= '<p style=\"margin:.5em 0 0;\"><a href=\"' . esc_url( $website ) . '\" rel=\"me\">' . esc_html( preg_replace( '#^https?://(www\\.)?#', '', untrailingslashit( $website ) ) ) . '</a></p>';\n    }\n\n    return $html . '</div></section>';\n};\n\n// Classic themes: before the list of posts.\nadd_action( 'loop_start', function ( $query ) use ( $wpide_author_archive_box ) {\n    static $done = false;\n\n    if ( $done || wp_is_block_theme() || ! $query->is_main_query() || ! is_author() || is_paged() ) {\n        return;\n    }\n\n    $done = true;\n    echo $wpide_author_archive_box(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped when built.\n} );\n\n// Block themes: after the archive title.\nadd_filter( 'render_block_core/query-title', function ( $content ) use ( $wpide_author_archive_box ) {\n    static $done = false;\n\n    if ( $done || ! is_author() || is_paged() ) {\n        return $content;\n    }\n\n    $done = true;\n\n    return $content . $wpide_author_archive_box();\n} );",
            "requires": [],
            "notes": "Classic themes get it above the first post, block themes after the archive title; it appears on the first page only."
        },
        {
            "id": "post-type-archive-intro-text",
            "version": 1,
            "title": "Intro Text for Custom Post Type Archives",
            "description": "Adds an \"Archive intros\" section to Settings > Reading where you can write an introduction for each custom post type archive, such as Portfolio or Recipes, shown at the top of that archive.",
            "category": "archive",
            "tags": [
                "archives",
                "post-types",
                "descriptions"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Adds an \"Archive intros\" section to Settings > Reading with a text box for every custom post type that has an archive.\n$wpide_archive_intro_types = function () {\n    return array_filter( get_post_types( array( 'public' => true, '_builtin' => false ), 'objects' ), function ( $type ) {\n        return ! empty( $type->has_archive );\n    } );\n};\n\nadd_action( 'admin_init', function () use ( $wpide_archive_intro_types ) {\n    $types = $wpide_archive_intro_types();\n\n    if ( ! $types ) {\n        return;\n    }\n\n    register_setting( 'reading', 'wpide_archive_intros', array(\n        'type'              => 'array',\n        'default'           => array(),\n        'sanitize_callback' => function ( $value ) {\n            $clean = array();\n\n            foreach ( (array) $value as $type => $html ) {\n                if ( post_type_exists( $type ) && '' !== trim( (string) $html ) ) {\n                    $clean[ sanitize_key( $type ) ] = wp_kses_post( $html );\n                }\n            }\n\n            return $clean;\n        },\n    ) );\n\n    add_settings_section( 'wpide_archive_intros', 'Archive intros', function () {\n        echo '<p>Text shown at the top of each content type\\'s archive page. Basic HTML is allowed.</p>';\n    }, 'reading' );\n\n    $saved = (array) get_option( 'wpide_archive_intros', array() );\n\n    foreach ( $types as $type ) {\n        add_settings_field( 'wpide_archive_intro_' . $type->name, esc_html( $type->labels->name ), function () use ( $type, $saved ) {\n            printf(\n                '<textarea name=\"wpide_archive_intros[%1$s]\" id=\"wpide_archive_intro_%1$s\" rows=\"4\" class=\"large-text\">%2$s</textarea><p class=\"description\"><a href=\"%3$s\">%3$s</a></p>',\n                esc_attr( $type->name ),\n                esc_textarea( isset( $saved[ $type->name ] ) ? $saved[ $type->name ] : '' ),\n                esc_url( get_post_type_archive_link( $type->name ) )\n            );\n        }, 'reading', 'wpide_archive_intros', array( 'label_for' => 'wpide_archive_intro_' . $type->name ) );\n    }\n} );\n\n// The saved intro for the post type archive being viewed (first page only).\n$wpide_archive_intro_text = function () {\n    if ( ! is_post_type_archive() || is_paged() ) {\n        return '';\n    }\n\n    $type   = get_query_var( 'post_type' );\n    $type   = is_array( $type ) ? reset( $type ) : $type;\n    $intros = (array) get_option( 'wpide_archive_intros', array() );\n\n    return ! empty( $intros[ $type ] ) ? wpautop( wp_kses_post( $intros[ $type ] ) ) : '';\n};\n\n// Classic themes that print the archive description.\nadd_filter( 'get_the_archive_description', function ( $description ) use ( $wpide_archive_intro_text ) {\n    $intro = $wpide_archive_intro_text();\n\n    return '' !== $intro ? $intro : $description;\n} );\n\n// Block themes: after the archive title.\nadd_filter( 'render_block_core/query-title', function ( $content ) use ( $wpide_archive_intro_text ) {\n    $intro = wp_is_block_theme() ? $wpide_archive_intro_text() : '';\n\n    return '' !== $intro ? $content . '<div class=\"wpide-archive-intro\">' . $intro . '</div>' : $content;\n} );",
            "requires": [],
            "notes": "Only post types with an archive are listed. Classic themes need to show the archive description; block themes get it after the archive title."
        },
        {
            "id": "author-archives-404-without-posts",
            "version": 1,
            "title": "404 for Author Pages of Users Without Posts",
            "description": "Returns a 404 for the author archive of anyone who has never published, such as subscribers and customers, so their usernames and empty profile pages are not exposed.",
            "category": "archive",
            "tags": [
                "archives",
                "authors",
                "privacy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "add_action( 'template_redirect', function () {\n    global $wp_query;\n\n    if ( ! is_author() ) {\n        return;\n    }\n\n    $user = get_queried_object();\n\n    if ( ! $user instanceof WP_User ) {\n        return;\n    }\n\n    // Users who never published anything (subscribers, customers, new staff) get no public profile page.\n    $types = array_values( get_post_types( array( 'public' => true ) ) );\n\n    if ( count_user_posts( $user->ID, $types, true ) > 0 ) {\n        return;\n    }\n\n    $wp_query->set_404();\n    status_header( 404 );\n    nocache_headers();\n}, 1 );",
            "requires": [],
            "notes": "Authors get their archive back as soon as they publish something."
        },
        {
            "id": "oldest-first-in-selected-categories",
            "version": 1,
            "title": "Oldest Posts First in Selected Categories",
            "description": "Lists posts in the categories you choose from oldest to newest, so serialized stories, courses and step-by-step series read in the right order.",
            "category": "archive",
            "tags": [
                "archives",
                "categories",
                "ordering"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Category slugs whose archives list posts oldest first, for series, stories and step-by-step courses. Empty = off.\n// Example: array( 'my-novel', 'beginner-course' )\n$wpide_oldest_first_categories = array();\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_oldest_first_categories ) {\n    if ( ! $wpide_oldest_first_categories || is_admin() || ! $query->is_main_query() || ! $query->is_category( $wpide_oldest_first_categories ) ) {\n        return;\n    }\n\n    $query->set( 'orderby', 'date' );\n    $query->set( 'order', 'ASC' );\n    $query->set( 'ignore_sticky_posts', true );\n} );",
            "requires": [],
            "notes": "The category list is empty by default; add the slugs of your series categories."
        },
        {
            "id": "custom-post-types-on-date-archives",
            "version": 1,
            "title": "Show Custom Post Types in Year and Month Archives",
            "description": "Makes yearly, monthly and daily archives also list custom post type entries, such as recipes or events, not just blog posts.",
            "category": "archive",
            "tags": [
                "archives",
                "dates",
                "post-types"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Content types to list on year, month and day archives besides posts.\n// Empty = every public custom post type that has its own archive.\n$wpide_date_archive_types = array();\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_date_archive_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_date() || $query->get( 'post_type' ) ) {\n        return;\n    }\n\n    $types = $wpide_date_archive_types;\n\n    if ( ! $types ) {\n        foreach ( get_post_types( array( 'public' => true, '_builtin' => false ), 'objects' ) as $type ) {\n            if ( ! empty( $type->has_archive ) ) {\n                $types[] = $type->name;\n            }\n        }\n    }\n\n    if ( $types ) {\n        $query->set( 'post_type', array_values( array_unique( array_merge( array( 'post' ), $types ) ) ) );\n    }\n} );",
            "requires": [],
            "notes": "Leave the list empty to include every public custom post type that has an archive, or name the exact types."
        },
        {
            "id": "archive-sort-menu",
            "version": 1,
            "title": "Sort Menu for Blog and Archive Pages",
            "description": "Adds a \"Sort by\" menu above the blog and archive lists so visitors can switch between newest, oldest, A to Z and most discussed posts.",
            "category": "archive",
            "tags": [
                "archives",
                "sorting",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Sort choices offered to visitors: key => array( label, orderby, order ).\n$wpide_archive_sorts = array(\n    'newest'    => array( 'Newest first', 'date', 'DESC' ),\n    'oldest'    => array( 'Oldest first', 'date', 'ASC' ),\n    'title'     => array( 'Title (A–Z)', 'title', 'ASC' ),\n    'discussed' => array( 'Most discussed', 'comment_count', 'DESC' ),\n);\n\n$wpide_archive_sort_key = function () use ( $wpide_archive_sorts ) {\n    $key = isset( $_GET['sort'] ) ? sanitize_key( wp_unslash( $_GET['sort'] ) ) : '';\n\n    return isset( $wpide_archive_sorts[ $key ] ) ? $key : '';\n};\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_archive_sorts, $wpide_archive_sort_key ) {\n    $key = $wpide_archive_sort_key();\n\n    if ( '' === $key || is_admin() || ! $query->is_main_query() || ! ( $query->is_home() || $query->is_archive() ) ) {\n        return;\n    }\n\n    $query->set( 'orderby', $wpide_archive_sorts[ $key ][1] );\n    $query->set( 'order', $wpide_archive_sorts[ $key ][2] );\n    $query->set( 'ignore_sticky_posts', true );\n} );\n\n// Sorted copies of a list are not separate pages for search engines.\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_archive_sort_key ) {\n    if ( '' !== $wpide_archive_sort_key() ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );\n\n$wpide_archive_sort_form = function () use ( $wpide_archive_sorts, $wpide_archive_sort_key ) {\n    $current = $wpide_archive_sort_key();\n    $id      = wp_unique_id( 'wpide-sort-' );\n    $action  = remove_query_arg( array( 'sort', 'paged' ), get_pagenum_link( 1, false ) );\n    $html    = '<form class=\"wpide-archive-sort\" method=\"get\" action=\"' . esc_url( $action ) . '\" style=\"display:flex;flex-wrap:wrap;gap:.5em;align-items:center;justify-content:flex-end;margin:0 0 1.5em;\">';\n    $html   .= '<label for=\"' . esc_attr( $id ) . '\">Sort by</label><select id=\"' . esc_attr( $id ) . '\" name=\"sort\">';\n\n    foreach ( $wpide_archive_sorts as $key => $sort ) {\n        $html .= '<option value=\"' . esc_attr( $key ) . '\"' . selected( $current ? $current : 'newest', $key, false ) . '>' . esc_html( $sort[0] ) . '</option>';\n    }\n\n    $html .= '</select>';\n\n    // A GET form drops the query string of its action, so keep those values (plain permalinks: ?cat=3 and so on).\n    parse_str( (string) wp_parse_url( $action, PHP_URL_QUERY ), $args );\n\n    foreach ( $args as $name => $value ) {\n        if ( is_string( $value ) ) {\n            $html .= '<input type=\"hidden\" name=\"' . esc_attr( $name ) . '\" value=\"' . esc_attr( $value ) . '\" />';\n        }\n    }\n\n    return $html . '<button type=\"submit\" class=\"wp-element-button button\">Sort</button></form>';\n};\n\n// Classic themes: above the first post.\nadd_action( 'loop_start', function ( $query ) use ( $wpide_archive_sort_form ) {\n    static $done = false;\n\n    if ( $done || wp_is_block_theme() || ! $query->is_main_query() || ! ( is_home() || is_archive() ) || ! $query->have_posts() ) {\n        return;\n    }\n\n    $done = true;\n    echo $wpide_archive_sort_form(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped when built.\n} );\n\n// Block themes: above Query Loops that show the page's own posts.\nadd_filter( 'render_block_core/query', function ( $content, $block ) use ( $wpide_archive_sort_form ) {\n    static $done = false;\n\n    if ( $done || empty( $block['attrs']['query']['inherit'] ) || ! ( is_home() || is_archive() ) ) {\n        return $content;\n    }\n\n    $done = true;\n\n    return $wpide_archive_sort_form() . $content;\n}, 10, 2 );",
            "requires": [],
            "notes": "Sorted pages are marked noindex so search engines keep the default order; edit the list to offer other sorts."
        },
        {
            "id": "category-header-images",
            "version": 1,
            "title": "Header Images for Categories",
            "description": "Adds an image picker to categories and shows the chosen image as a banner at the top of each category archive.",
            "category": "archive",
            "tags": [
                "archives",
                "categories",
                "images"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Adds a \"Header image\" picker to categories and shows the image at the top of the category archive.\n$wpide_cat_image_size = 'full';\n\n$wpide_cat_image_field = function ( $term_id = 0 ) {\n    $image_id = $term_id ? absint( get_term_meta( $term_id, 'wpide_header_image_id', true ) ) : 0;\n    $preview  = $image_id ? wp_get_attachment_image( $image_id, 'medium', false, array( 'style' => 'max-width:300px;height:auto;display:block;margin-bottom:8px;' ) ) : '';\n\n    wp_nonce_field( 'wpide_cat_image', 'wpide_cat_image_nonce' );\n\n    echo '<div class=\"wpide-cat-image\"><div class=\"wpide-cat-image__preview\">' . $preview . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- core image markup.\n    echo '<input type=\"hidden\" name=\"wpide_header_image_id\" value=\"' . esc_attr( $image_id ) . '\" />';\n    echo '<button type=\"button\" class=\"button wpide-cat-image__choose\">Choose image</button> ';\n    echo '<button type=\"button\" class=\"button-link wpide-cat-image__remove\"' . ( $image_id ? '' : ' hidden' ) . '>Remove image</button></div>';\n};\n\nadd_action( 'category_add_form_fields', function () use ( $wpide_cat_image_field ) {\n    echo '<div class=\"form-field\"><label>Header image</label>';\n    $wpide_cat_image_field();\n    echo '<p>Shown at the top of this category\\'s archive page.</p></div>';\n} );\n\nadd_action( 'category_edit_form_fields', function ( $term ) use ( $wpide_cat_image_field ) {\n    echo '<tr class=\"form-field\"><th scope=\"row\">Header image</th><td>';\n    $wpide_cat_image_field( $term->term_id );\n    echo '<p class=\"description\">Shown at the top of this category\\'s archive page.</p></td></tr>';\n} );\n\n$wpide_cat_image_save = function ( $term_id ) {\n    if ( ! isset( $_POST['wpide_cat_image_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['wpide_cat_image_nonce'] ), 'wpide_cat_image' ) || ! current_user_can( 'manage_categories' ) ) {\n        return;\n    }\n\n    $image_id = isset( $_POST['wpide_header_image_id'] ) ? absint( $_POST['wpide_header_image_id'] ) : 0;\n\n    if ( $image_id && wp_attachment_is_image( $image_id ) ) {\n        update_term_meta( $term_id, 'wpide_header_image_id', $image_id );\n    } else {\n        delete_term_meta( $term_id, 'wpide_header_image_id' );\n    }\n};\n\nadd_action( 'created_category', $wpide_cat_image_save );\nadd_action( 'edited_category', $wpide_cat_image_save );\n\nadd_action( 'admin_enqueue_scripts', function ( $hook ) {\n    if ( ! in_array( $hook, array( 'edit-tags.php', 'term.php' ), true ) || 'category' !== get_current_screen()->taxonomy ) {\n        return;\n    }\n\n    wp_enqueue_media();\n    $script = <<<'JS'\njQuery( function ( $ ) {\n    var style = 'max-width:300px;height:auto;display:block;margin-bottom:8px;';\n\n    $( document ).on( 'click', '.wpide-cat-image__choose', function ( e ) {\n        e.preventDefault();\n\n        var box   = $( this ).closest( '.wpide-cat-image' );\n        var frame = wp.media( { title: 'Header image', library: { type: 'image' }, multiple: false } );\n\n        frame.on( 'select', function () {\n            var img = frame.state().get( 'selection' ).first().toJSON();\n            var src = img.sizes && img.sizes.medium ? img.sizes.medium.url : img.url;\n\n            box.find( 'input' ).val( img.id );\n            box.find( '.wpide-cat-image__preview' ).html( $( '<img>', { src: src, alt: '', style: style } ) );\n            box.find( '.wpide-cat-image__remove' ).prop( 'hidden', false );\n        } );\n\n        frame.open();\n    } );\n\n    $( document ).on( 'click', '.wpide-cat-image__remove', function ( e ) {\n        e.preventDefault();\n\n        var box = $( this ).closest( '.wpide-cat-image' );\n\n        box.find( 'input' ).val( '' );\n        box.find( '.wpide-cat-image__preview' ).empty();\n        $( this ).prop( 'hidden', true );\n    } );\n\n    // The \"Add New Category\" form is submitted with AJAX; clear the picker afterwards.\n    $( document ).ajaxComplete( function ( event, xhr, options ) {\n        if ( options.data && 'string' === typeof options.data && -1 !== options.data.indexOf( 'action=add-tag' ) ) {\n            $( '#addtag .wpide-cat-image input' ).val( '' );\n            $( '#addtag .wpide-cat-image__preview' ).empty();\n            $( '#addtag .wpide-cat-image__remove' ).prop( 'hidden', true );\n        }\n    } );\n} );\nJS;\n\n    wp_add_inline_script( 'media-editor', $script );\n} );\n\n$wpide_cat_image_html = function () use ( $wpide_cat_image_size ) {\n    if ( ! is_category() || is_paged() ) {\n        return '';\n    }\n\n    $image_id = absint( get_term_meta( get_queried_object_id(), 'wpide_header_image_id', true ) );\n\n    return $image_id ? '<figure class=\"wpide-category-header\" style=\"margin:0 0 1.5em;\">' . wp_get_attachment_image( $image_id, $wpide_cat_image_size, false, array( 'style' => 'display:block;width:100%;height:auto;max-height:420px;object-fit:cover;border-radius:8px;', 'fetchpriority' => 'high', 'loading' => 'eager' ) ) . '</figure>' : '';\n};\n\n// Classic themes: above the first post.\nadd_action( 'loop_start', function ( $query ) use ( $wpide_cat_image_html ) {\n    static $done = false;\n\n    if ( $done || wp_is_block_theme() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    $done = true;\n    echo $wpide_cat_image_html(); // phpcs:ignore WordPress.Security.EscapeOutput -- core image markup.\n} );\n\n// Block themes: before the archive title.\nadd_filter( 'render_block_core/query-title', function ( $content ) use ( $wpide_cat_image_html ) {\n    static $done = false;\n\n    if ( $done ) {\n        return $content;\n    }\n\n    $done = true;\n\n    return $wpide_cat_image_html() . $content;\n} );",
            "requires": [],
            "notes": "Classic themes get it above the posts, block themes before the archive title; it shows on the first page only."
        },
        {
            "id": "visual-editor-for-term-descriptions",
            "version": 1,
            "title": "Visual Editor for Category and Tag Descriptions",
            "description": "Replaces the plain description box on the category and tag edit screens with the visual editor, and keeps headings, lists, images and links that WordPress would otherwise strip.",
            "category": "archive",
            "tags": [
                "archives",
                "categories",
                "editor"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Taxonomies whose edit screen gets the visual editor for the description.\n$wpide_rich_description_taxonomies = array( 'category', 'post_tag' );\n\n// Keep headings, images, lists and links in descriptions (WordPress normally allows only a few inline tags).\nadd_action( 'init', function () {\n    if ( current_user_can( 'manage_categories' ) ) {\n        remove_filter( 'pre_term_description', 'wp_filter_kses' );\n        add_filter( 'pre_term_description', 'wp_filter_post_kses' );\n    }\n} );\n\nforeach ( $wpide_rich_description_taxonomies as $wpide_rich_tax ) {\n    add_action( $wpide_rich_tax . '_edit_form_fields', function ( $term ) {\n        echo '<tr class=\"form-field wpide-rich-description\"><th scope=\"row\"><label for=\"wpide_term_description\">Description</label></th><td>';\n\n        wp_editor( $term->description, 'wpide_term_description', array(\n            'textarea_name' => 'description',\n            'textarea_rows' => 10,\n            'media_buttons' => current_user_can( 'upload_files' ),\n        ) );\n\n        echo '<p class=\"description\">Shown on the archive page if your theme displays descriptions.</p></td></tr>';\n    } );\n}\n\n// Hide the plain description box that the editor replaces.\nadd_action( 'admin_head-term.php', function () use ( $wpide_rich_description_taxonomies ) {\n    if ( in_array( get_current_screen()->taxonomy, $wpide_rich_description_taxonomies, true ) ) {\n        echo '<style>.term-description-wrap{display:none}</style>';\n    }\n} );\n\nadd_action( 'admin_footer-term.php', function () use ( $wpide_rich_description_taxonomies ) {\n    if ( in_array( get_current_screen()->taxonomy, $wpide_rich_description_taxonomies, true ) ) {\n        // Remove the original field so only the editor's value is submitted.\n        echo '<script>(function(){var row=document.querySelector(\".term-description-wrap\");if(row){row.parentNode.removeChild(row);}})();</script>';\n    }\n} );",
            "requires": [],
            "notes": "Your theme must display term descriptions; add other taxonomies to the list at the top."
        },
        {
            "id": "subcategories-use-parent-template",
            "version": 1,
            "title": "Subcategories Use Their Parent Category's Template",
            "description": "Makes subcategories use the parent category's custom template, such as category-news.php or a \"category-news\" block template, when they do not have their own.",
            "category": "archive",
            "tags": [
                "archives",
                "templates",
                "categories"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Templates for categories and other hierarchical taxonomies fall back to the parent term's template:\n// /category/news/local/ uses category-news.php (or the category-news block template) when there is no category-local one.\nforeach ( array( 'category', 'taxonomy' ) as $wpide_tpl_type ) {\n    add_filter( $wpide_tpl_type . '_template_hierarchy', function ( $templates ) {\n        $term = get_queried_object();\n\n        if ( ! $term instanceof WP_Term || ! $term->parent || ! is_taxonomy_hierarchical( $term->taxonomy ) ) {\n            return $templates;\n        }\n\n        $prefix = 'category' === $term->taxonomy ? 'category-' : 'taxonomy-' . $term->taxonomy . '-';\n        $extra  = array();\n\n        foreach ( get_ancestors( $term->term_id, $term->taxonomy, 'taxonomy' ) as $ancestor_id ) {\n            $ancestor = get_term( $ancestor_id, $term->taxonomy );\n\n            if ( $ancestor && ! is_wp_error( $ancestor ) ) {\n                $extra[] = $prefix . rawurldecode( $ancestor->slug ) . '.php';\n                $extra[] = $prefix . $ancestor->term_id . '.php';\n            }\n        }\n\n        // Right after the term's own templates, before the generic ones.\n        $own = array( $prefix . rawurldecode( $term->slug ) . '.php', $prefix . $term->slug . '.php', $prefix . $term->term_id . '.php' );\n        $pos = 0;\n\n        foreach ( $templates as $i => $file ) {\n            if ( in_array( $file, $own, true ) ) {\n                $pos = $i + 1;\n            }\n        }\n\n        array_splice( $templates, $pos, 0, $extra );\n\n        return array_values( array_unique( $templates ) );\n    } );\n}",
            "requires": [],
            "notes": "Also works for hierarchical custom taxonomies; the subcategory's own template still wins when it exists."
        },
        {
            "id": "year-headings-in-post-lists",
            "version": 1,
            "title": "Year Headings in Blog and Archive Lists",
            "description": "Inserts a year heading such as \"2024\" between posts whenever the year changes on blog and archive pages, making long date-ordered lists easier to scan.",
            "category": "archive",
            "tags": [
                "archives",
                "dates",
                "headings"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Heading level used for the year dividers.\n$wpide_year_heading_tag = 'h2';\n\nadd_action( 'the_post', function ( $post, $query ) use ( $wpide_year_heading_tag ) {\n    static $last = null;\n\n    // Classic themes only; lists in date order only (blog, category, tag and other archives).\n    if ( wp_is_block_theme() || is_admin() || ! $query->is_main_query() || ! ( $query->is_home() || $query->is_archive() ) || $query->is_year() || ! in_array( $query->get( 'orderby' ), array( '', 'date', 'post_date' ), true ) || ! in_the_loop() ) {\n        return;\n    }\n\n    $year = get_the_date( 'Y', $post );\n\n    if ( $year === $last ) {\n        return;\n    }\n\n    // No divider above the first post on the first page when it is from the current year.\n    $show = null !== $last || $query->is_paged() || wp_date( 'Y' ) !== $year;\n    $last = $year;\n\n    if ( $show ) {\n        $tag = in_array( $wpide_year_heading_tag, array( 'h2', 'h3', 'h4', 'p' ), true ) ? $wpide_year_heading_tag : 'h2';\n\n        echo '<' . $tag . ' class=\"wpide-year-divider\" style=\"margin:2em 0 1em;padding-bottom:.25em;border-bottom:1px solid rgba(127,127,127,.35);font-size:1.25em;\">' . esc_html( $year ) . '</' . $tag . '>'; // phpcs:ignore WordPress.Security.EscapeOutput -- tag from a fixed list.\n    }\n}, 10, 2 );",
            "requires": [],
            "notes": "For classic themes; lists sorted by something other than date get no headings."
        },
        {
            "id": "view-all-link-on-archives",
            "version": 1,
            "title": "\"View All\" Link on Paginated Archives",
            "description": "Adds a \"View all 42 posts on one page\" link under paginated category, tag and other archives that are not too large, for readers who prefer to scroll or search the page.",
            "category": "archive",
            "tags": [
                "archives",
                "pagination",
                "navigation"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Show the \"View all\" option only when the archive has no more than this many posts.\n$wpide_view_all_max = 150;\n// Link text; %d is the number of posts.\n$wpide_view_all_label = 'View all %d posts on one page';\n\n$wpide_view_all_on = function () {\n    return isset( $_GET['view'] ) && 'all' === sanitize_key( wp_unslash( $_GET['view'] ) );\n};\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_view_all_max, $wpide_view_all_on ) {\n    if ( ! $wpide_view_all_on() || is_admin() || ! $query->is_main_query() || ! $query->is_archive() ) {\n        return;\n    }\n\n    $query->set( 'posts_per_page', absint( $wpide_view_all_max ) );\n    $query->set( 'paged', 1 );\n} );\n\n// The single-page copy is for readers; search engines keep the normal paginated pages.\nadd_filter( 'wp_robots', function ( $robots ) use ( $wpide_view_all_on ) {\n    if ( $wpide_view_all_on() ) {\n        $robots['noindex'] = true;\n        $robots['follow']  = true;\n    }\n\n    return $robots;\n} );\n\n$wpide_view_all_link = function () use ( $wpide_view_all_max, $wpide_view_all_label ) {\n    global $wp_query;\n\n    if ( ! is_archive() || isset( $_GET['view'] ) || $wp_query->max_num_pages < 2 || $wp_query->found_posts > $wpide_view_all_max ) {\n        return '';\n    }\n\n    $url = add_query_arg( 'view', 'all', get_pagenum_link( 1, false ) );\n\n    return '<p class=\"wpide-view-all\" style=\"text-align:center;margin:1.5em 0;\"><a href=\"' . esc_url( $url ) . '\">' . esc_html( sprintf( $wpide_view_all_label, (int) $wp_query->found_posts ) ) . '</a></p>';\n};\n\n// Classic themes: after the last post.\nadd_action( 'loop_end', function ( $query ) use ( $wpide_view_all_link ) {\n    static $done = false;\n\n    if ( $done || wp_is_block_theme() || ! $query->is_main_query() ) {\n        return;\n    }\n\n    $done = true;\n    echo $wpide_view_all_link(); // phpcs:ignore WordPress.Security.EscapeOutput -- escaped when built.\n} );\n\n// Block themes: after the page numbers.\nadd_filter( 'render_block_core/query-pagination', function ( $content ) use ( $wpide_view_all_link ) {\n    static $done = false;\n\n    if ( $done || '' === trim( $content ) ) {\n        return $content;\n    }\n\n    $done = true;\n\n    return $content . $wpide_view_all_link();\n} );",
            "requires": [],
            "notes": "Only shown for archives up to 150 posts; the single-page version is marked noindex."
        },
        {
            "id": "top-level-items-on-hierarchical-archives",
            "version": 1,
            "title": "Only Top-Level Items on Hierarchical Post Type Archives",
            "description": "Lists only parent items on the archive of hierarchical post types, such as services with sub-services, so child items do not crowd the overview.",
            "category": "archive",
            "tags": [
                "archives",
                "post-types",
                "hierarchy"
            ],
            "code_type": "php",
            "insert_method": "auto",
            "location": "everywhere",
            "insert_number": 1,
            "code": "// Hierarchical post types whose archive lists only top-level items (child items stay reachable through their parents).\n// Empty = every public hierarchical custom post type with an archive.\n$wpide_top_level_archive_types = array();\n\nadd_action( 'pre_get_posts', function ( $query ) use ( $wpide_top_level_archive_types ) {\n    if ( is_admin() || ! $query->is_main_query() || ! $query->is_post_type_archive() ) {\n        return;\n    }\n\n    $type = $query->get( 'post_type' );\n    $type = is_array( $type ) ? reset( $type ) : $type;\n    $obj  = $type ? get_post_type_object( $type ) : null;\n\n    if ( ! $obj || ! $obj->hierarchical || ( $wpide_top_level_archive_types && ! in_array( $type, $wpide_top_level_archive_types, true ) ) ) {\n        return;\n    }\n\n    $query->set( 'post_parent', 0 );\n} );",
            "requires": [],
            "notes": "Leave the list empty to apply to every hierarchical post type archive, or name the exact types."
        }
    ],
    "packs": [
        {
            "id": "security-hardening",
            "title": "Security hardening",
            "icon": "ni-shield-check",
            "description": "Close common attack surfaces: XML-RPC, version leaks, user enumeration, the file editors and login probing.",
            "snippets": [
                {
                    "id": "disable-xmlrpc"
                },
                {
                    "id": "hide-wordpress-version"
                },
                {
                    "id": "remove-core-version-from-assets"
                },
                {
                    "id": "block-user-enumeration"
                },
                {
                    "id": "disable-theme-plugin-file-editor"
                },
                {
                    "id": "security-headers"
                },
                {
                    "id": "generic-login-error-message"
                },
                {
                    "id": "limit-login-attempts"
                },
                {
                    "id": "restrict-unfiltered-html"
                }
            ]
        },
        {
            "id": "performance-essentials",
            "title": "Performance essentials",
            "icon": "ni-speed",
            "description": "Remove scripts and requests most sites do not need, and tune revisions and the Heartbeat API.",
            "snippets": [
                {
                    "id": "disable-emojis"
                },
                {
                    "id": "disable-oembed-discovery"
                },
                {
                    "id": "remove-jquery-migrate"
                },
                {
                    "id": "control-heartbeat"
                },
                {
                    "id": "limit-post-revisions"
                },
                {
                    "id": "remove-dashicons-for-visitors"
                },
                {
                    "id": "clean-up-wp-head"
                },
                {
                    "id": "lazy-load-threshold"
                }
            ]
        },
        {
            "id": "no-comments",
            "title": "Turn off comments",
            "icon": "ni-chat-circle",
            "description": "Remove comments, pingbacks, trackbacks, comment feeds and comment emails everywhere.",
            "snippets": [
                {
                    "id": "disable-comments-completely"
                },
                {
                    "id": "disable-pingbacks-trackbacks"
                },
                {
                    "id": "disable-self-pingbacks"
                },
                {
                    "id": "remove-comment-feeds"
                },
                {
                    "id": "disable-comment-notification-emails"
                }
            ]
        },
        {
            "id": "clean-admin",
            "title": "Clean, branded admin",
            "icon": "ni-dashboard",
            "description": "A calmer dashboard: no WordPress branding, default widgets or help tabs, with your own footer text and greeting.",
            "snippets": [
                {
                    "id": "remove-wordpress-logo-from-admin-bar"
                },
                {
                    "id": "custom-admin-footer-text",
                    "personalize": true
                },
                {
                    "id": "remove-admin-footer-version"
                },
                {
                    "id": "remove-dashboard-widgets"
                },
                {
                    "id": "remove-dashboard-welcome-panel"
                },
                {
                    "id": "replace-howdy-greeting",
                    "personalize": true
                },
                {
                    "id": "remove-admin-help-tabs"
                },
                {
                    "id": "hide-update-notices-for-non-admins"
                }
            ]
        },
        {
            "id": "client-handoff",
            "title": "Client handoff",
            "icon": "ni-user-check",
            "description": "Hand a site to a client safely: hide technical menus and update notices, block installs and editors, and label the environment.",
            "snippets": [
                {
                    "id": "hide-admin-menu-items-for-non-admins",
                    "personalize": true
                },
                {
                    "id": "hide-update-notices-for-non-admins"
                },
                {
                    "id": "hide-screen-options-for-non-admins"
                },
                {
                    "id": "disable-theme-plugin-file-editor"
                },
                {
                    "id": "block-plugin-theme-installs"
                },
                {
                    "id": "custom-dashboard-welcome-widget",
                    "personalize": true
                },
                {
                    "id": "environment-badge-in-admin-bar"
                }
            ]
        },
        {
            "id": "branded-login",
            "title": "Branded login page",
            "icon": "ni-signin",
            "description": "Your logo, colors and link on the login page, a cleaner form and a remembered session.",
            "snippets": [
                {
                    "id": "login-page-logo",
                    "personalize": true
                },
                {
                    "id": "login-logo-links-to-site"
                },
                {
                    "id": "login-page-brand-colors",
                    "personalize": true
                },
                {
                    "id": "hide-login-language-switcher"
                },
                {
                    "id": "remember-me-checked-by-default"
                }
            ]
        },
        {
            "id": "seo-basics",
            "title": "SEO basics",
            "icon": "ni-search",
            "description": "Meta descriptions, social sharing tags, structured data and sensible noindex rules when you do not run an SEO plugin.",
            "snippets": [
                {
                    "id": "meta-description-fallback"
                },
                {
                    "id": "open-graph-tags"
                },
                {
                    "id": "organization-schema",
                    "personalize": true
                },
                {
                    "id": "article-schema"
                },
                {
                    "id": "noindex-thin-pages"
                },
                {
                    "id": "noindex-non-production"
                },
                {
                    "id": "remove-archive-title-prefix"
                }
            ]
        },
        {
            "id": "woocommerce-cleanup",
            "title": "WooCommerce cleanup",
            "icon": "ni-cart",
            "requires": [
                "woocommerce"
            ],
            "description": "Lighter WooCommerce: assets only on shop pages, no marketplace suggestions, simpler checkout and automatic completion of virtual orders.",
            "snippets": [
                {
                    "id": "woo-disable-marketplace-suggestions"
                },
                {
                    "id": "woo-assets-only-on-shop-pages"
                },
                {
                    "id": "woo-remove-order-notes"
                },
                {
                    "id": "woo-remove-checkout-fields"
                },
                {
                    "id": "woo-hide-shipping-when-free"
                },
                {
                    "id": "woo-autocomplete-virtual-orders"
                }
            ]
        },
        {
            "id": "blog-reading",
            "title": "Better blog reading",
            "icon": "ni-book-read",
            "description": "Last updated dates, related posts, an author box, a reading progress bar and external links in a new tab.",
            "snippets": [
                {
                    "id": "last-updated-date"
                },
                {
                    "id": "related-posts-after-content"
                },
                {
                    "id": "author-box-after-posts"
                },
                {
                    "id": "reading-progress-bar"
                },
                {
                    "id": "external-links-new-tab"
                },
                {
                    "id": "smooth-scrolling"
                }
            ]
        },
        {
            "id": "developer-toolkit",
            "title": "Developer toolkit",
            "icon": "ni-code",
            "description": "Template name in the admin bar, query counts in the page source, useful body classes and an environment badge.",
            "snippets": [
                {
                    "id": "show-template-in-admin-bar"
                },
                {
                    "id": "performance-stats-comment"
                },
                {
                    "id": "extra-body-classes"
                },
                {
                    "id": "environment-badge-in-admin-bar"
                }
            ]
        }
    ]
}
