settings_init(); $this->settings_save(); } /** * Init our settings. */ public function settings_init() { add_settings_section( 'woocommerce-permalink', __( 'Product permalinks', 'woocommerce' ), array( $this, 'settings' ), 'permalink' ); add_settings_field( 'woocommerce_product_category_slug', __( 'Product category base', 'woocommerce' ), array( $this, 'product_category_slug_input' ), 'permalink', 'optional' ); add_settings_field( 'woocommerce_product_tag_slug', __( 'Product tag base', 'woocommerce' ), array( $this, 'product_tag_slug_input' ), 'permalink', 'optional' ); add_settings_field( 'woocommerce_product_attribute_slug', __( 'Product attribute base', 'woocommerce' ), array( $this, 'product_attribute_slug_input' ), 'permalink', 'optional' ); $this->permalinks = wc_get_permalink_structure(); } /** * Show a slug input box. */ public function product_category_slug_input() { ?> /attribute-name/attribute/ 0 && get_post( $shop_page_id ) ) ? get_page_uri( $shop_page_id ) : _x( 'shop', 'default-slug', 'woocommerce' ) ); } /** * Resolve a posted product permalink choice to the value that gets persisted for it. * * The render path checks a radio by comparing the stored base against this, and the save path * stores what this returns, so both derive the base from one set of rules instead of restating * them separately -- which is what made every predefined structure revert to "Custom base". * See https://github.com/woocommerce/woocommerce/issues/29050. * * Must run inside a wc_switch_to_site_locale() window: the Default base is a translated slug, * and an administrator whose profile language differs from the site language would otherwise * store one translation and compare against another. * * Sharing the rules is not the same as receiving identical input: the save path runs the * posted value through sanitize_text_field() first, which strips percent-encoded octets, so a * Shop page whose slug carries a literal percent-escape can still resolve differently on the * two sides. * * @param string $posted_base Posted `product_permalink` radio value. * @param string|null $posted_structure Posted `product_permalink_structure` value, or null when that field is absent or not a string. * @return string The base as it is stored. */ private function get_stored_product_base( string $posted_base, ?string $posted_structure = null ): string { $default_base = wc_sanitize_permalink( _x( 'product', 'slug', 'woocommerce' ) ); if ( 'custom' === $posted_base ) { if ( null === $posted_structure ) { // A missing or non-string field resolves to the default base, so the stored slug // stays deterministic and in the site locale. $base = $default_base; } else { // Remove every `#` so the base cannot open a URL fragment, prepend the leading // slash, then collapse each run of slashes into one. $base = (string) preg_replace( '~/+~', '/', '/' . str_replace( '#', '', trim( $posted_structure ) ) ); } // A base of nothing but the category token gives products the same URL shape as the // category archives they sit under, so the two collide. Prefix the default base. if ( '/%product_cat%/' === trailingslashit( $base ) ) { $base = '/' . $default_base . $base; } } else { // The Default radio posts an empty value; store the site-locale slug. $base = '' === $posted_base ? $default_base : $posted_base; } $base = wc_sanitize_permalink( $base ); /* * Resolve an empty base to the default one. A custom base that is blank, whitespace, or * nothing but hashes and slashes sanitizes down to empty, and an empty base does not * survive: wc_get_permalink_structure() drops it and refills the option from the request * locale, outside any locale window, persisting a slug the site locale never chose -- the * same divergence this resolver exists to prevent. * * This narrows that window rather than closing it. The default base is itself a translated * slug run through wc_sanitize_permalink(), so a translation or a gettext filter that * resolves it to something sanitizing away -- `/` untrailingslashits to nothing -- leaves * this assigning empty to empty. */ if ( '' === $base ) { $base = $default_base; } /* * A base equal to the Default structure is reported in Default's bare form. The Custom * base field is the next tab stop after the radio group and focusing it selects Custom * base, so a single Tab from Default posts the field's prefilled `/product/` through the * custom branch, which prepends a slash. The two forms are not interchangeable: under * index.php (PATHINFO) permalinks the leading slash reaches register_post_type() and * produces an `index.php//product/%product%` permastruct whose URLs do not resolve, while * the bare slug works everywhere. Converging on the bare form keeps Default checked after * that keystroke and never persists the broken shape. */ return '/' . $default_base === $base ? $default_base : $base; } /** * Show the settings. */ public function settings() { /* translators: %s: Home URL */ echo wp_kses_post( wpautop( sprintf( __( 'If you like, you may enter custom structures for your product URLs here. For example, using shop would make your product links like %sshop/sample-product/. This setting affects product URLs only, not things such as product categories.', 'woocommerce' ), esc_url( home_url( '/' ) ) ) ) ); /* * Resolve the Shop page and the translated slugs inside the same window settings_save() * opens, so the values compared below are the values a save would store. * * wc_get_page_id() is resolved here too because settings_save() resolves it inside its own * window, and the woocommerce_get_shop_page_id filter multilingual plugins attach to can * return a different page per locale. * * This holds only for a persisted product_base: wc_get_permalink_structure() initializes a * missing one in the request locale, outside any window, before this screen renders. * See https://github.com/woocommerce/woocommerce/issues/67507. */ wc_switch_to_site_locale(); $shop_page_id = wc_get_page_id( 'shop' ); $base_slug = urldecode( $this->get_shop_base_slug( $shop_page_id ) ); // The value each radio posts. The Shop entries exist only when their rows render below, so // a stored base matching a hidden row reports as Custom instead of checking nothing. $structures = array( 0 => '' ); if ( $shop_page_id ) { $structures[1] = '/' . trailingslashit( $base_slug ); $structures[2] = '/' . trailingslashit( $base_slug ) . trailingslashit( '%product_cat%' ); } // What a save would store for each of them, so the comparison below cannot drift from // settings_save(). $stored_forms = array_map( array( $this, 'get_stored_product_base' ), $structures ); wc_restore_locale(); $default_product_base = $stored_forms[0]; $stored_product_base = $this->permalinks['product_base']; $default_product_structure = trailingslashit( '/' . ltrim( $default_product_base, '/' ) ); // The index of the predefined structure the stored base corresponds to, or false for a custom one. $selected_structure = array_search( $stored_product_base, $stored_forms, true ); $product_permalink_structure = 0 === $selected_structure ? $default_product_structure : ( $stored_product_base ? trailingslashit( $stored_product_base ) : '' ); ?> get_stored_product_base( $product_base, $posted_structure ); update_option( 'woocommerce_permalinks', $permalinks ); wc_restore_locale(); } } } return new WC_Admin_Permalink_Settings();