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 ) : '' );
?>
/?product=sample-product //sample-product/ |
|
//sample-product/ |
|
//product-category/sample-product/ |
|