%s',
esc_url( admin_url( 'options-general.php?page=16deza-block-curator' ) ),
esc_html__( 'Settings', '16deza-block-curator' )
);
array_unshift( $links, $settings_link );
return $links;
}
/**
* Register the settings page under Settings menu.
*/
public function add_settings_page() {
add_options_page(
__( 'Block Curator', '16deza-block-curator' ),
__( 'Block Curator', '16deza-block-curator' ),
'manage_options',
'16deza-block-curator',
array( $this, 'render_settings_page' )
);
}
/**
* Enqueue admin styles and scripts.
*
* @param string $hook Current admin page hook.
*/
public function enqueue_assets( $hook ) {
if ( 'settings_page_16deza-block-curator' !== $hook ) {
return;
}
// Styles.
wp_enqueue_style(
'16deza-block-curator-admin',
BLOCUR_PLUGIN_URL . 'admin/css/admin.css',
array(),
BLOCUR_VERSION
);
// Admin JS (loaded in footer). No wp-blocks dependency needed — icons are static SVG.
wp_enqueue_script(
'16deza-block-curator-admin',
BLOCUR_PLUGIN_URL . 'admin/js/admin.js',
array( 'jquery' ),
BLOCUR_VERSION,
true
);
// [1] Settings page interactions (extracted from inline script).
wp_enqueue_script(
'16deza-block-curator-settings',
BLOCUR_PLUGIN_URL . 'admin/js/admin-settings.js',
array(),
BLOCUR_VERSION,
true
);
// Register JS translation file (must be called after wp_enqueue_script).
wp_set_script_translations(
'16deza-block-curator-admin',
'16deza-block-curator',
BLOCUR_PLUGIN_DIR . 'languages'
);
// Pass PHP data to JS.
$title_map = array(
'amazon-kindle' => 'Amazon Kindle', 'animoto' => 'Animoto',
'bluesky' => 'Bluesky', 'cloudup' => 'Cloudup',
'crowdsignal' => 'Crowdsignal', 'dailymotion' => 'Dailymotion',
'flickr' => 'Flickr', 'imgur' => 'Imgur',
'issuu' => 'Issuu', 'kickstarter' => 'Kickstarter',
'mixcloud' => 'Mixcloud', 'pinterest' => 'Pinterest',
'pocket-casts' => 'Pocket Casts', 'reddit' => 'Reddit',
'reverbnation' => 'ReverbNation', 'scribd' => 'Scribd',
'smugmug' => 'SmugMug', 'soundcloud' => 'SoundCloud', 'speaker-deck' => 'Speaker Deck',
'spotify' => 'Spotify', 'ted' => 'TED',
'tiktok' => 'TikTok', 'tumblr' => 'Tumblr',
'twitter' => 'Twitter / X', 'videopress' => 'VideoPress',
'vimeo' => 'Vimeo', 'wolfram-cloud'=> 'Wolfram Cloud',
'wordpress' => 'WordPress', 'wordpress-tv' => 'WordPress.tv',
'youtube' => 'YouTube',
);
$variations_data = array();
foreach ( Blocur_Core::$embed_variations as $slug ) {
$variations_data[] = array(
'slug' => $slug,
'title' => isset( $title_map[ $slug ] ) ? $title_map[ $slug ] : ucwords( str_replace( '-', ' ', $slug ) ),
);
}
// Build embed variation icon map from static SVG data.
$embed_icons = array();
foreach ( Blocur_Core::$embed_variations as $slug ) {
$icon_html = $this->get_block_icon_html( 'embed/' . $slug );
$embed_icons[ $slug ] = $icon_html;
}
wp_localize_script(
'16deza-block-curator-admin',
'blocurData',
array(
'embedVariations' => $variations_data,
'disabledEmbedVariations' => get_option( 'blocur_disabled_embed_variations', array() ),
'embedIcons' => $embed_icons,
'i18n' => array(
'embedVariationLabel' => __( 'Display settings for each embedded service (visual control using display:none)', '16deza-block-curator' ),
'embedVariationNote' => __( '* This is not a functional limitation as it is hidden using CSS. It may stop working after a WordPress update.', '16deza-block-curator' ),
),
)
);
}
/**
* Handle form submission.
*/
public function handle_save() {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Permission denied.', '16deza-block-curator' ) );
}
check_admin_referer( 'blocur_save', 'blocur_nonce' );
// Save exempt_admin option.
$exempt_admin = isset( $_POST['blocur_exempt_admin'] ) ? '1' : '0';
update_option( 'blocur_exempt_admin', $exempt_admin );
// [v0.1.13] ブロックチェックボックスは「チェック=有効」方式に変更。
// フォームから送信された有効ブロックリストを読み取り、
// 親ブロックが有効な場合は子ブロックも自動的に有効にする。
$submitted_enabled = array();
if ( isset( $_POST['blocur_enabled'] ) && is_array( $_POST['blocur_enabled'] ) ) {
$submitted_enabled = array_map( 'sanitize_text_field', wp_unslash( $_POST['blocur_enabled'] ) );
}
// 全登録ブロックを取得。
$registered = WP_Block_Type_Registry::get_instance()->get_all_registered();
$all_blocks = array_keys( $registered );
// 親ブロックが有効なら、対応する子ブロックも有効にする。
$parent_child_map = self::get_parent_child_map();
foreach ( $parent_child_map as $parent => $children ) {
if ( in_array( $parent, $submitted_enabled, true ) ) {
foreach ( $children as $child ) {
if ( in_array( $child, $all_blocks, true ) && ! in_array( $child, $submitted_enabled, true ) ) {
$submitted_enabled[] = $child;
}
}
}
}
// 無効ブロック = 全登録ブロック − 有効ブロック。
$disabled = array_values( array_diff( $all_blocks, $submitted_enabled ) );
// Virtual WooCommerce product-collection variations (JS-only, not in PHP registry).
// These appear as virtual entries in the settings UI but are never in $all_blocks,
// so they must be handled separately: if not submitted as enabled → mark as disabled.
$virtual_wc_variations = array(
'woocommerce/product-collection/best-sellers',
'woocommerce/product-collection/by-brand',
'woocommerce/product-collection/by-category',
'woocommerce/product-collection/by-tag',
'woocommerce/product-collection/cross-sells',
'woocommerce/product-collection/featured',
'woocommerce/product-collection/hand-picked',
'woocommerce/product-collection/new-arrivals',
'woocommerce/product-collection/on-sale',
'woocommerce/product-collection/related',
'woocommerce/product-collection/top-rated',
'woocommerce/product-collection/upsells',
);
foreach ( $virtual_wc_variations as $vname ) {
if ( ! in_array( $vname, $submitted_enabled, true ) ) {
$disabled[] = $vname;
}
}
update_option( 'blocur_disabled_blocks', $disabled );
// Save enabled embed variations (v0.1.13: checked=enabled).
$all_embed_variations = Blocur_Core::$embed_variations;
$submitted_enabled_embeds = array();
if ( isset( $_POST['blocur_enabled_embed_variations'] ) && is_array( $_POST['blocur_enabled_embed_variations'] ) ) {
$submitted_enabled_embeds = array_map( 'sanitize_text_field', wp_unslash( $_POST['blocur_enabled_embed_variations'] ) );
}
$disabled_variations = array_values( array_diff( $all_embed_variations, $submitted_enabled_embeds ) );
update_option( 'blocur_disabled_embed_variations', $disabled_variations );
wp_safe_redirect(
add_query_arg(
array(
'page' => '16deza-block-curator',
'updated' => '1',
),
admin_url( 'options-general.php' )
)
);
exit;
}
/**
* 親ブロック → 子ブロック のマッピング。
* UI上は親ブロックのみ表示し、保存時に子ブロックを自動同期する。
*
* @return array 親ブロック名 => 子ブロック名の配列
*/
public static function get_parent_child_map() {
return array(
'core/accordion' => array( 'core/accordion-heading', 'core/accordion-item', 'core/accordion-panel' ),
'core/buttons' => array( 'core/button' ),
'core/columns' => array( 'core/column' ),
'core/comments' => array(
'core/comment-author-name',
'core/comment-content',
'core/comment-date',
'core/comment-edit-link',
'core/comment-reply-link',
'core/comments-pagination',
'core/comments-pagination-next',
'core/comments-pagination-numbers',
'core/comments-pagination-previous',
'core/comments-title',
),
'core/list' => array( 'core/list-item' ),
'core/navigation' => array( 'core/navigation-link', 'core/navigation-submenu', 'core/home-link', 'core/navigation-overlay-close' ),
'core/page-list' => array( 'core/page-list-item' ),
'core/playlist' => array( 'core/playlist-track' ),
'core/social-links' => array( 'core/social-link' ),
'core/tabs' => array( 'core/tab-panel', 'core/tab-panels', 'core/tab-list' ),
// ── WooCommerce ─────────────────────────────────────────────────────────────
'woocommerce/cart' => array(
'woocommerce/cart-items-block',
'woocommerce/cart-line-items-block',
'woocommerce/cart-order-summary-block',
'woocommerce/cart-order-summary-coupon-form-block',
'woocommerce/cart-order-summary-discount-block',
'woocommerce/cart-order-summary-fee-block',
'woocommerce/cart-order-summary-heading-block',
'woocommerce/cart-order-summary-shipping-block',
'woocommerce/cart-order-summary-subtotal-block',
'woocommerce/cart-order-summary-tax-block',
'woocommerce/cart-express-payment-block',
'woocommerce/cart-cross-sells-block',
'woocommerce/cart-cross-sells-products-block',
'woocommerce/proceed-to-checkout-block',
'woocommerce/cart-accepted-payment-methods-block',
'woocommerce/filled-cart-block',
'woocommerce/empty-cart-block',
'woocommerce/cart-totals-block',
),
'woocommerce/checkout' => array(
'woocommerce/checkout-actions-block',
'woocommerce/checkout-billing-address-block',
'woocommerce/checkout-contact-information-block',
'woocommerce/checkout-express-payment-block',
'woocommerce/checkout-fields-block',
'woocommerce/checkout-order-information-block',
'woocommerce/checkout-order-note-block',
'woocommerce/checkout-order-summary-block',
'woocommerce/checkout-order-summary-coupon-form-block',
'woocommerce/checkout-order-summary-discount-block',
'woocommerce/checkout-order-summary-fee-block',
'woocommerce/checkout-order-summary-shipping-block',
'woocommerce/checkout-order-summary-subtotal-block',
'woocommerce/checkout-order-summary-tax-block',
'woocommerce/checkout-payment-block',
'woocommerce/checkout-pickup-options-block',
'woocommerce/checkout-shipping-address-block',
'woocommerce/checkout-shipping-methods-block',
'woocommerce/checkout-totals-block',
'woocommerce/checkout-terms-block',
'woocommerce/checkout-additional-information-block',
// Order-confirmation inner blocks — with and without '-block' suffix.
// (Prefix 'woocommerce/order-confirmation-' is also caught dynamically.)
'woocommerce/order-confirmation-downloads-block',
'woocommerce/order-confirmation-downloads',
'woocommerce/order-confirmation-status-block',
'woocommerce/order-confirmation-status',
'woocommerce/order-confirmation-totals-block',
'woocommerce/order-confirmation-totals',
'woocommerce/order-confirmation-totals-wrapper-block',
'woocommerce/order-confirmation-totals-wrapper',
'woocommerce/order-confirmation-shipping-address-block',
'woocommerce/order-confirmation-shipping-address',
'woocommerce/order-confirmation-shipping-address-wrapper',
'woocommerce/order-confirmation-billing-address-block',
'woocommerce/order-confirmation-billing-address',
'woocommerce/order-confirmation-billing-address-wrapper',
'woocommerce/order-confirmation-additional-fields-block',
'woocommerce/order-confirmation-additional-fields',
'woocommerce/order-confirmation-additional-fields-list',
'woocommerce/order-confirmation-additional-information',
'woocommerce/order-confirmation-summary',
'woocommerce/order-confirmation-order-details',
),
'woocommerce/mini-cart' => array(
'woocommerce/mini-cart-shopping-button-block',
'woocommerce/mini-cart-title-block',
'woocommerce/mini-cart-title-label-block',
'woocommerce/mini-cart-title-items-counter-block',
'woocommerce/mini-cart-footer-block',
'woocommerce/mini-cart-products-table-block',
'woocommerce/mini-cart-cart-button-block',
'woocommerce/mini-cart-checkout-button-block',
'woocommerce/filled-mini-cart-contents-block',
'woocommerce/empty-mini-cart-contents-block',
'woocommerce/mini-cart-contents',
'woocommerce/mini-cart-contents-block',
'woocommerce/mini-cart-items-block',
'woocommerce/mini-cart-items',
),
'woocommerce/product-collection' => array(
'woocommerce/product-template',
'woocommerce/product-collection-toolbar',
'woocommerce/product-collection-no-results-block',
'woocommerce/product-collection-no-results', // Actual registered name (no -block suffix).
'woocommerce/product-results-count',
),
'woocommerce/product-filters' => array(
// Legacy filter blocks.
'woocommerce/attribute-filter',
'woocommerce/price-filter',
'woocommerce/stock-filter',
'woocommerce/rating-filter',
// Current filter blocks (product-filter-* prefix also caught dynamically).
'woocommerce/product-filter-active',
'woocommerce/product-filter-attribute',
'woocommerce/product-filter-price',
'woocommerce/product-filter-price-slider',
'woocommerce/product-filter-rating',
'woocommerce/product-filter-removable-chips',
'woocommerce/product-filter-status',
'woocommerce/product-filter-stock-status',
'woocommerce/product-filter-chips',
'woocommerce/product-filter-clear',
'woocommerce/product-filter-list',
'woocommerce/product-filter-checkbox',
'woocommerce/product-filter-color',
'woocommerce/product-filter-sale-status',
// Filter utility blocks grouped here for settings-UI simplicity.
'woocommerce/active-filters', // アクティブなフィルターコントロール
),
'woocommerce/filter-wrapper' => array(
'woocommerce/dropdown',
),
'woocommerce/accordion-group' => array(
'woocommerce/accordion-item',
'woocommerce/accordion-panel',
'woocommerce/accordion-header',
),
'woocommerce/product-gallery' => array(
'woocommerce/product-gallery-large-image',
'woocommerce/product-gallery-thumbnails',
'woocommerce/product-gallery-large-image-next-previous',
'woocommerce/product-gallery-pager',
),
'woocommerce/product-reviews' => array(
'woocommerce/product-reviews-title',
'woocommerce/product-review-template',
'woocommerce/product-reviews-pagination',
'woocommerce/product-reviews-pagination-numbers',
'woocommerce/product-reviews-pagination-previous',
'woocommerce/product-review-author-name',
'woocommerce/product-reviews-pagination-next',
'woocommerce/product-review-rating',
'woocommerce/product-review-date',
'woocommerce/product-review-content',
),
'woocommerce/single-product' => array(
'woocommerce/product-button',
'woocommerce/add-to-cart-form',
'woocommerce/add-to-cart-with-options',
'woocommerce/product-sale-badge',
'woocommerce/product-sku',
'woocommerce/product-meta',
'woocommerce/product-average-rating',
'woocommerce/product-rating',
'woocommerce/product-price',
'woocommerce/product-title',
'woocommerce/product-stock-indicator',
'woocommerce/product-summary',
'woocommerce/product-image',
'woocommerce/product-image-gallery',
'woocommerce/product-rating-stars',
'woocommerce/product-rating-count',
'woocommerce/product-rating-counter', // Alternative name variant.
'woocommerce/product-description',
'woocommerce/product-details',
'woocommerce/product-specifications',
'woocommerce/product-quantity', // WooCommerce 商品要素 inner block.
// Note: woocommerce/related-products is a standalone insertable block
// and must NOT be listed here — kept visible in settings UI.
),
);
}
/**
* Dynamically detect WooCommerce inner blocks that should be hidden from the
* settings UI. Blocks whose block.json 'parent' metadata is set to a
* WooCommerce-namespaced block cannot be freely inserted by users (they live
* exclusively inside a specific parent block) and therefore do not need to
* appear as individual toggle items in Block Curator's settings page.
*
* This dynamic detection complements the static get_parent_child_map() list
* and automatically covers new inner blocks added by WooCommerce updates.
*
* @return string[] Registered block names to exclude from the settings UI.
*/
private static function get_auto_hidden_wc_blocks() {
static $cache = null;
if ( null !== $cache ) {
return $cache;
}
/*
* Blocks whose name starts with any of these prefixes are specialized
* inner blocks that cannot be freely inserted by users, regardless of
* whether their block.json declares a 'parent'. Matching by prefix
* covers all variants without requiring exact name enumeration.
*
* Prefixes must end with a separator (/ or -) to avoid false positives.
*/
$hidden_prefixes = array(
'woocommerce/order-confirmation-', // Order-confirmation page inner blocks.
'woocommerce/add-to-cart-with-options/', // ATC-with-options sub-blocks (slash notation).
'woocommerce/add-to-cart-with-options-', // ATC-with-options sub-blocks (hyphen notation).
'woocommerce/product-filter-', // Individual filter display/type blocks.
);
/*
* Blocks that declare a WooCommerce parent in block.json but are still
* independently insertable from the editor inserter. They must remain
* visible in the settings UI so site administrators can control them.
*/
$visible_exceptions = array(
'woocommerce/related-products', // 関連商品 — standalone insertable.
'woocommerce/cart-cross-sells-products', // クロスセル — standalone insertable.
);
/*
* Blocks that are always hidden from the settings UI regardless of
* parent metadata — experimental, email-only, or not available in the
* standard block inserter.
*/
$always_hidden = array(
'woocommerce/accordion-group', // __experimental:true — not in standard inserter.
'woocommerce/catalog-sorting', // Shop/archive context only; not in general inserter.
'woocommerce/email-content', // "inserter":false, email:true — email templates only.
'woocommerce/breadcrumbs', // Store breadcrumbs — not in standard inserter.
);
$hidden = array();
$registry = WP_Block_Type_Registry::get_instance();
foreach ( $registry->get_all_registered() as $block_name => $block_type ) {
$should_hide = false;
// 1. Check 'parent' metadata: restricted to a WooCommerce parent → inner block.
if ( ! empty( $block_type->parent ) ) {
foreach ( $block_type->parent as $parent_name ) {
if ( 0 === strncmp( $parent_name, 'woocommerce/', 12 ) ) {
$should_hide = true;
break;
}
}
}
// 2. Check name prefix: known inner-block name patterns.
if ( ! $should_hide ) {
foreach ( $hidden_prefixes as $prefix ) {
if ( 0 === strncmp( $block_name, $prefix, strlen( $prefix ) ) ) {
$should_hide = true;
break;
}
}
}
if ( $should_hide && ! in_array( $block_name, $visible_exceptions, true ) ) {
$hidden[] = $block_name;
}
}
$cache = array_unique( array_merge( $hidden, $always_hidden ) );
return $cache;
}
/**
* Return allowed HTML tags/attributes for wp_kses() when outputting block icon SVGs.
*
* @return array
*/
private function allowed_svg_kses() {
$svg_args = array(
'svg' => array(
'class' => true,
'xmlns' => true,
'viewbox' => true,
'aria-hidden' => true,
'focusable' => true,
'width' => true,
'height' => true,
),
'g' => array( 'fill' => true ),
'path' => array( 'd' => true, 'fill' => true, 'fill-rule' => true, 'clip-rule' => true, 'stroke' => true, 'stroke-width' => true ),
'rect' => array( 'x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true ),
'circle' => array( 'cx' => true, 'cy' => true, 'r' => true, 'fill' => true ),
'polygon' => array( 'points' => true, 'fill' => true ),
'span' => array( 'class' => true, 'aria-hidden' => true, 'style' => true ),
);
return $svg_args;
}
/**
* [2] Return inline SVG markup for a block icon.
* Covers all core blocks with the same paths WordPress uses in the inserter.
* Falls back to a generic block-outline icon for unknown blocks.
*
* @param string $block_name e.g. "core/paragraph".
* @return string Safe HTML (inline SVG).
*/
private function get_block_icon_html( $block_name ) {
static $icons = null;
if ( null === $icons ) {
$icons = array(
// ── Icons extracted from live block inserter HTML ──────────────────────────
// ── Core blocks ─────────────────────────────────────────────────────────
'core/accordion' => '
v