Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 105 additions & 2 deletions includes/modules/nav-menu/nav-menu.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ public function process_menu_shortcodes( $items ) {
$url_decoded = rawurldecode( html_entity_decode( $url_raw, ENT_QUOTES ) );

if ( anys_has_shortcode( $url_decoded ) ) {
$output = do_shortcode( $url_decoded );
$item->url = trim( wp_strip_all_tags( (string) $output ) );
$item->url = $this->resolve_menu_input($url_decoded, '');
}

// Processes shortcodes in title.
Expand Down Expand Up @@ -126,6 +125,110 @@ public function admin_menu_item_preview( $item_id, $item, $depth, $args ) {
echo '</div>';
}
}

/**
* Normalizes unquoted shortcode attributes into a quoted form.
*
* Preserves internal spaces/commas and escapes internal double quotes.
*
* @since NEXT
*
* @param string $shortcode Shortcode input.
*
* @return string Quoted shortcode or original input on failure.
*/
private function quote_shortcode_attributes( string $shortcode ): string {
if ( ! preg_match( '/^\s*\[([A-Za-z0-9_\-]+)\s*(.*?)\]\s*$/s', $shortcode, $m ) ) {
return $shortcode;
}

$tag = $m[1];
$body = $m[2];
$out = '[' . $tag;

// Matches key=value pairs until the next key or closing bracket.
if ( preg_match_all(
'/([A-Za-z0-9_\-]+)=((?:(?!\s+[A-Za-z0-9_\-]+=).)*)/s',
$body,
$pairs,
PREG_SET_ORDER
) ) {
foreach ( $pairs as $p ) {
$key = $p[1];
$val = rtrim( $p[2] ); // trims only trailing spaces at the end of value.
$val = str_replace( '"', '&quot;', $val );
$out .= ' ' . $key . '="' . $val . '"';
}
$out .= ']';
return $out;
}
return $shortcode;
}

/**
* Resolves a menu input that may embed a shortcode, optionally prefixed by a scheme.
*
* @since NEXT
*
* @param string $raw Raw menu input.
* @param string $fallback Fallback URL on failure.
*
* @return string Resolved absolute URL or fallback.
*/
private function resolve_menu_input( string $raw, string $fallback = '' ): string {
$candidate = trim( $raw );

// Extracts optional http(s) scheme before the shortcode.
$forced_scheme = null;
if ( preg_match( '#^\s*(https?://)\s*(%5B|\[)#i', $candidate, $m ) ) {
$forced_scheme = stripos( $m[1], 'https://' ) === 0 ? 'https' : 'http';
// Strips the leading scheme to isolate the shortcode block.
$candidate = preg_replace( '#^\s*https?://\s*#i', '', $candidate, 1 );
}

// Decodes percent-encoded input.
if ( strpos( $candidate, '%' ) !== false ) {
$candidate = rawurldecode( $candidate );
}

// Handles an embedded shortcode.
if ( isset( $candidate[0] ) && $candidate[0] === '[' ) {
$normalized = $this->quote_shortcode_attributes( $candidate );
$rendered = do_shortcode( $normalized );
$value = trim( wp_strip_all_tags( (string) $rendered ) );

if ( $value === '' ) {
return $fallback;
}

// Allows non-HTTP schemes.
if ( preg_match( '#^(?:mailto:|tel:)#i', $value ) ) {
return $value;
}

// Handles protocol-relative URLs.
if ( strpos( $value, '//' ) === 0 ) {
return $forced_scheme ? $forced_scheme . ':' . $value : set_url_scheme( $value, 'https' );
}

// Normalizes scheme for absolute URLs when a scheme is forced.
if ( wp_http_validate_url( $value ) ) {
return $forced_scheme ? set_url_scheme( $value, $forced_scheme ) : $value;
}

// Builds an absolute URL from a relative path.
$path = ltrim( $value, '/' ); // Keeps query/fragment intact.
$base = $forced_scheme ? home_url( '/', $forced_scheme ) : home_url( '/' );
return rtrim( $base, '/' ) . '/' . $path;
}

// Normalizes plain URLs with the forced scheme when present.
if ( wp_http_validate_url( $candidate ) ) {
return $forced_scheme ? set_url_scheme( $candidate, $forced_scheme ) : $candidate;
}

return $fallback;
}
}

/**
Expand Down