From ce4b62ad8e8d09fe1fe89f1533e061e25a0373fd Mon Sep 17 00:00:00 2001 From: Amirreza Nazemi Date: Sun, 9 Nov 2025 10:55:44 +0330 Subject: [PATCH 1/3] feat(function): add anys_merge_numeric_attributes method to function type --- .../modules/shortcodes/types/function.php | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/includes/modules/shortcodes/types/function.php b/includes/modules/shortcodes/types/function.php index 50d44a2..7f519e3 100644 --- a/includes/modules/shortcodes/types/function.php +++ b/includes/modules/shortcodes/types/function.php @@ -56,6 +56,7 @@ protected function get_defaults() { * @return string */ public function render( array $attributes, string $content ) { + $attributes = $this->anys_merge_numeric_attributes($attributes); $attributes = $this->get_attributes( $attributes ); // Parses dynamic attributes. @@ -129,4 +130,59 @@ static function ( $a ) { return $a !== ''; } // Returns sanitized output. return wp_kses_post( (string) $output ); } + + /** + * Merges numeric attributes into the previous named key. + * + * @since NEXT + * + * @param array $attributes Parsed shortcode attributes. + * + * @return array + */ + private function anys_merge_numeric_attributes( array $attributes ): array { + $normalized = []; + $buffer = ''; + $last_key = null; + $appended = []; + + foreach ( $attributes as $key => $value ) { + // Collect numeric tokens. + if ( is_int( $key ) ) { + $val = trim( (string) $value ); + if ( $val !== '' ) { + $buffer .= ($buffer === '' ? '' : ' ') . $val; + } + continue; + } + + // Append buffer to the previous named key. + if ( $last_key && $buffer !== '' ) { + $base = rtrim( (string) $normalized[$last_key], " \t\n\r\0\x0B," ); + $sep = empty( $appended[$last_key]) && !strpbrk( substr( $base, -1 ), ' ,' ) + ? ', ' + : ' '; + $normalized[$last_key] = $base . $sep . $buffer; + $appended[$last_key] = true; + $buffer = ''; + } + + // Store the current named key. + $normalized[$key] = trim( (string) $value ); + $last_key = $key; + } + + // Flush any remaining buffer. + if ( $last_key && $buffer !== '' ) { + $base = rtrim( (string) $normalized[$last_key], " \t\n\r\0\x0B," ); + $sep = empty( $appended[$last_key]) && !strpbrk( substr( $base, -1 ), ' ,' ) + ? ', ' + : ' '; + $normalized[$last_key] = $base . $sep . $buffer; + } + + return $normalized; + } + + } From f8ed3af4e7fb824e289ed4e74f86443eb8385eb9 Mon Sep 17 00:00:00 2001 From: Amirreza Nazemi Date: Sun, 9 Nov 2025 16:57:38 +0330 Subject: [PATCH 2/3] bugfix(nav-menu): fix shortcode link handling and remove old merge helper --- includes/modules/nav-menu/nav-menu.php | 112 +++++++++++++++++- .../modules/shortcodes/types/function.php | 56 --------- 2 files changed, 110 insertions(+), 58 deletions(-) diff --git a/includes/modules/nav-menu/nav-menu.php b/includes/modules/nav-menu/nav-menu.php index 044e608..e9b08df 100644 --- a/includes/modules/nav-menu/nav-menu.php +++ b/includes/modules/nav-menu/nav-menu.php @@ -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->anys_resolve_menu_input($url_decoded, ''); } // Processes shortcodes in title. @@ -126,6 +125,115 @@ public function admin_menu_item_preview( $item_id, $item, $depth, $args ) { echo ''; } } + + /** + * 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 anys_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( '"', '"', $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 anys_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->anys_quote_shortcode_attributes( $candidate ); + $rendered = do_shortcode( $normalized ); + $value = trim( wp_strip_all_tags( (string) $rendered ) ); + + if ( $value === '' ) { + return $fallback; + } + + // Blocks unsafe schemes (security hardening). + if ( preg_match( '#^(?:javascript:|data:)#i', $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; + } } /** diff --git a/includes/modules/shortcodes/types/function.php b/includes/modules/shortcodes/types/function.php index 7f519e3..50d44a2 100644 --- a/includes/modules/shortcodes/types/function.php +++ b/includes/modules/shortcodes/types/function.php @@ -56,7 +56,6 @@ protected function get_defaults() { * @return string */ public function render( array $attributes, string $content ) { - $attributes = $this->anys_merge_numeric_attributes($attributes); $attributes = $this->get_attributes( $attributes ); // Parses dynamic attributes. @@ -130,59 +129,4 @@ static function ( $a ) { return $a !== ''; } // Returns sanitized output. return wp_kses_post( (string) $output ); } - - /** - * Merges numeric attributes into the previous named key. - * - * @since NEXT - * - * @param array $attributes Parsed shortcode attributes. - * - * @return array - */ - private function anys_merge_numeric_attributes( array $attributes ): array { - $normalized = []; - $buffer = ''; - $last_key = null; - $appended = []; - - foreach ( $attributes as $key => $value ) { - // Collect numeric tokens. - if ( is_int( $key ) ) { - $val = trim( (string) $value ); - if ( $val !== '' ) { - $buffer .= ($buffer === '' ? '' : ' ') . $val; - } - continue; - } - - // Append buffer to the previous named key. - if ( $last_key && $buffer !== '' ) { - $base = rtrim( (string) $normalized[$last_key], " \t\n\r\0\x0B," ); - $sep = empty( $appended[$last_key]) && !strpbrk( substr( $base, -1 ), ' ,' ) - ? ', ' - : ' '; - $normalized[$last_key] = $base . $sep . $buffer; - $appended[$last_key] = true; - $buffer = ''; - } - - // Store the current named key. - $normalized[$key] = trim( (string) $value ); - $last_key = $key; - } - - // Flush any remaining buffer. - if ( $last_key && $buffer !== '' ) { - $base = rtrim( (string) $normalized[$last_key], " \t\n\r\0\x0B," ); - $sep = empty( $appended[$last_key]) && !strpbrk( substr( $base, -1 ), ' ,' ) - ? ', ' - : ' '; - $normalized[$last_key] = $base . $sep . $buffer; - } - - return $normalized; - } - - } From f91d835b099763ebd64f746ca6c55e162cf05bd4 Mon Sep 17 00:00:00 2001 From: Amirreza Nazemi Date: Mon, 17 Nov 2025 16:19:33 +0330 Subject: [PATCH 3/3] Refactor nav menu shortcode helpers - Removed Nav_Menu specific prefix from helper method names. - Kept shortcode resolution logic focused without extra validations. --- includes/modules/nav-menu/nav-menu.php | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/includes/modules/nav-menu/nav-menu.php b/includes/modules/nav-menu/nav-menu.php index e9b08df..ae6b2d9 100644 --- a/includes/modules/nav-menu/nav-menu.php +++ b/includes/modules/nav-menu/nav-menu.php @@ -48,7 +48,7 @@ public function process_menu_shortcodes( $items ) { $url_decoded = rawurldecode( html_entity_decode( $url_raw, ENT_QUOTES ) ); if ( anys_has_shortcode( $url_decoded ) ) { - $item->url = $this->anys_resolve_menu_input($url_decoded, ''); + $item->url = $this->resolve_menu_input($url_decoded, ''); } // Processes shortcodes in title. @@ -137,7 +137,7 @@ public function admin_menu_item_preview( $item_id, $item, $depth, $args ) { * * @return string Quoted shortcode or original input on failure. */ - private function anys_quote_shortcode_attributes( string $shortcode ): string { + private function quote_shortcode_attributes( string $shortcode ): string { if ( ! preg_match( '/^\s*\[([A-Za-z0-9_\-]+)\s*(.*?)\]\s*$/s', $shortcode, $m ) ) { return $shortcode; } @@ -175,7 +175,7 @@ private function anys_quote_shortcode_attributes( string $shortcode ): string { * * @return string Resolved absolute URL or fallback. */ - private function anys_resolve_menu_input( string $raw, string $fallback = '' ): string { + private function resolve_menu_input( string $raw, string $fallback = '' ): string { $candidate = trim( $raw ); // Extracts optional http(s) scheme before the shortcode. @@ -193,7 +193,7 @@ private function anys_resolve_menu_input( string $raw, string $fallback = '' ): // Handles an embedded shortcode. if ( isset( $candidate[0] ) && $candidate[0] === '[' ) { - $normalized = $this->anys_quote_shortcode_attributes( $candidate ); + $normalized = $this->quote_shortcode_attributes( $candidate ); $rendered = do_shortcode( $normalized ); $value = trim( wp_strip_all_tags( (string) $rendered ) ); @@ -201,11 +201,6 @@ private function anys_resolve_menu_input( string $raw, string $fallback = '' ): return $fallback; } - // Blocks unsafe schemes (security hardening). - if ( preg_match( '#^(?:javascript:|data:)#i', $value ) ) { - return $fallback; - } - // Allows non-HTTP schemes. if ( preg_match( '#^(?:mailto:|tel:)#i', $value ) ) { return $value;