class-wc-admin-permalink-settings.php
<?php
/**
* Adds settings to the permalinks admin settings page
*
* @class WC_Admin_Permalink_Settings
* @package WooCommerce\Admin
* @version 2.3.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
if ( class_exists( 'WC_Admin_Permalink_Settings', false ) ) {
return new WC_Admin_Permalink_Settings();
}
/**
* WC_Admin_Permalink_Settings Class.
*/
class WC_Admin_Permalink_Settings {
/**
* Permalink settings.
*
* @var array
*/
private $permalinks = array();
/**
* Hook in tabs.
*/
public function __construct() {
$this->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() {
?>
<input name="woocommerce_product_category_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['category_base'] ); ?>" placeholder="<?php echo esc_attr_x( 'product-category', 'slug', 'woocommerce' ); ?>" />
<?php
}
/**
* Show a slug input box.
*/
public function product_tag_slug_input() {
?>
<input name="woocommerce_product_tag_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['tag_base'] ); ?>" placeholder="<?php echo esc_attr_x( 'product-tag', 'slug', 'woocommerce' ); ?>" />
<?php
}
/**
* Show a slug input box.
*/
public function product_attribute_slug_input() {
?>
<input name="woocommerce_product_attribute_slug" type="text" class="regular-text code" value="<?php echo esc_attr( $this->permalinks['attribute_base'] ); ?>" /><code>/attribute-name/attribute/</code>
<?php
}
/**
* Resolve the Shop page URI that serves as the Shop base, or the default slug.
*
* Shared by the render and the save paths so both resolve the base the same way.
*
* wc_get_page_id() returns -1 when no page is set, so the ID is tested against zero rather
* than for truthiness -- and the page it names can still be gone, which get_post() catches.
*
* @param int $shop_page_id Shop page ID, as resolved by wc_get_page_id( 'shop' ).
* @return string Shop base slug.
*/
private function get_shop_base_slug( int $shop_page_id ): string {
return (string) ( ( $shop_page_id > 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 <code>shop</code> would make your product links like <code>%sshop/sample-product/</code>. 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 ) : '' );
?>
<table class="form-table wc-permalink-structure">
<tbody>
<tr>
<th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[0] ); ?>" data-permalink-structure="<?php echo esc_attr( $default_product_structure ); ?>" class="wctog" <?php checked( 0 === $selected_structure ); ?> /> <?php esc_html_e( 'Default', 'woocommerce' ); ?></label></th>
<td><code class="default-example"><?php echo esc_html( home_url() ); ?>/?product=sample-product</code> <code class="non-default-example"><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $default_product_base ); ?>/sample-product/</code></td>
</tr>
<?php if ( $shop_page_id ) : ?>
<tr>
<th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[1] ); ?>" data-permalink-structure="<?php echo esc_attr( $structures[1] ); ?>" class="wctog" <?php checked( 1 === $selected_structure ); ?> /> <?php esc_html_e( 'Shop base', 'woocommerce' ); ?></label></th>
<td><code><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $base_slug ); ?>/sample-product/</code></td>
</tr>
<tr>
<th><label><input name="product_permalink" type="radio" value="<?php echo esc_attr( $structures[2] ); ?>" data-permalink-structure="<?php echo esc_attr( $structures[2] ); ?>" class="wctog" <?php checked( 2 === $selected_structure ); ?> /> <?php esc_html_e( 'Shop base with category', 'woocommerce' ); ?></label></th>
<td><code><?php echo esc_html( home_url() ); ?>/<?php echo esc_html( $base_slug ); ?>/product-category/sample-product/</code></td>
</tr>
<?php endif; ?>
<tr>
<th><label><input name="product_permalink" id="woocommerce_custom_selection" type="radio" value="custom" class="tog" <?php checked( false === $selected_structure ); ?> />
<?php esc_html_e( 'Custom base', 'woocommerce' ); ?></label></th>
<td>
<input name="product_permalink_structure" id="woocommerce_permalink_structure" type="text" value="<?php echo esc_attr( $product_permalink_structure ); ?>" class="regular-text code" aria-label="<?php esc_attr_e( 'Custom product permalink base', 'woocommerce' ); ?>" aria-describedby="woocommerce_permalink_structure_description"> <span class="description" id="woocommerce_permalink_structure_description"><?php esc_html_e( 'Enter a custom base to use. A base must be set or WordPress will use default instead.', 'woocommerce' ); ?></span>
</td>
</tr>
</tbody>
</table>
<?php wp_nonce_field( 'wc-permalinks', 'wc-permalinks-nonce' ); ?>
<script type="text/javascript">
jQuery( function() {
jQuery('input.wctog').on( 'change', function() {
jQuery('#woocommerce_permalink_structure').val( jQuery( this ).attr( 'data-permalink-structure' ) );
});
jQuery('.permalink-structure input').on( 'change', function() {
jQuery('.wc-permalink-structure').find('code.non-default-example, code.default-example').hide();
if ( jQuery(this).val() ) {
jQuery('.wc-permalink-structure code.non-default-example').show();
jQuery('.wc-permalink-structure input').prop('disabled', false);
} else {
jQuery('.wc-permalink-structure code.default-example').show();
jQuery('.wc-permalink-structure input:eq(0)').trigger( 'click' );
jQuery('.wc-permalink-structure input').attr('disabled', 'disabled');
}
});
jQuery('.permalink-structure input:checked').trigger( 'change' );
// Selecting Custom base takes a click or a typed character, the pair core binds to
// its own structure field. Focus alone must not: the radios share one tab stop, so
// tabbing forward lands here, and flipping on focus would move the checked radio
// off the structure the store actually uses.
jQuery('#woocommerce_permalink_structure').on( 'click input', function(){
jQuery('#woocommerce_custom_selection').trigger( 'click' );
} );
} );
</script>
<?php
}
/**
* Save the settings.
*/
public function settings_save() {
if ( ! is_admin() ) {
return;
}
// We need to save the options ourselves; settings api does not trigger save for the permalinks page.
if ( isset( $_POST['permalink_structure'], $_POST['wc-permalinks-nonce'], $_POST['woocommerce_product_category_slug'], $_POST['woocommerce_product_tag_slug'], $_POST['woocommerce_product_attribute_slug'] ) && wp_verify_nonce( wp_unslash( $_POST['wc-permalinks-nonce'] ), 'wc-permalinks' ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce is verified; permalink tokens require domain-specific cleaning.
wc_switch_to_site_locale();
$permalinks = (array) get_option( 'woocommerce_permalinks', array() );
$permalinks['category_base'] = wc_sanitize_permalink( wp_unslash( $_POST['woocommerce_product_category_slug'] ) );
$permalinks['tag_base'] = wc_sanitize_permalink( wp_unslash( $_POST['woocommerce_product_tag_slug'] ) );
$permalinks['attribute_base'] = wc_sanitize_permalink( wp_unslash( $_POST['woocommerce_product_attribute_slug'] ) );
/*
* The form only ever posts strings for these two fields, but nothing enforces that,
* and the resolver requires one. A non-string radio value resolves to the default
* product base; a non-string structure is passed as null, which resolves to the
* default base only when the radio selects the custom branch that reads it.
*/
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by sanitize_text_field().
$product_base = sanitize_text_field( isset( $_POST['product_permalink'] ) && is_string( $_POST['product_permalink'] ) ? wp_unslash( $_POST['product_permalink'] ) : '' );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by wc_sanitize_permalink() inside the resolver.
$posted_structure = isset( $_POST['product_permalink_structure'] ) && is_string( $_POST['product_permalink_structure'] ) ? wp_unslash( $_POST['product_permalink_structure'] ) : null;
// Resolved inside the wc_switch_to_site_locale() window opened above, so the stored
// value is the same site-locale form settings() compares against.
$permalinks['product_base'] = $this->get_stored_product_base( $product_base, $posted_structure );
update_option( 'woocommerce_permalinks', $permalinks );
wc_restore_locale();
}
}
}
return new WC_Admin_Permalink_Settings();