switcher = $switcher;
$this->language_tag = new LanguageTag();
}
/**
* Registers classic navigation menu hooks.
*
* @return void
*/
public function register() {
add_action( 'admin_head-nav-menus.php', array( $this, 'register_meta_box' ) );
add_action( 'wp_nav_menu_item_custom_fields', array( $this, 'render_item_fields' ), 10, 5 );
add_action( 'wp_update_nav_menu_item', array( $this, 'save_item_fields' ), 10, 3 );
add_filter( 'wp_nav_menu_objects', array( $this, 'filter_menu_items' ), 10, 2 );
add_filter( 'nav_menu_link_attributes', array( $this, 'menu_link_attributes' ), 10, 2 );
}
/**
* Adds the ShapeTranslate menu-item selector to Appearance > Menus.
*
* @return void
*/
public function register_meta_box() {
if ( ! current_user_can( 'edit_theme_options' ) ) {
return;
}
add_meta_box(
'add-shapetranslate-language-switcher',
__( 'ShapeTranslate', 'shapetranslate' ),
array( $this, 'render_meta_box' ),
'nav-menus',
'side',
'default'
);
}
/**
* Renders the virtual menu item selector.
*
* @return void
*/
public function render_meta_box() {
$item_index = -1;
?>
is_switcher_item( $menu_item ) ) {
return;
}
$settings = $this->get_item_settings( $item_id );
$field = 'shapetranslate-switcher[' . absint( $item_id ) . ']';
?>
render_checkbox( $field, $item_id, 'hide_current', $settings['hide_current'], __( 'Hide current language', 'shapetranslate' ) ); ?>
render_checkbox( $field, $item_id, 'hide_missing', $settings['hide_missing'], __( 'Hide languages the site has no content in', 'shapetranslate' ) ); ?>
render_checkbox( $field, $item_id, 'show_flags', $settings['show_flags'], __( 'Show flags', 'shapetranslate' ) ); ?>
render_checkbox( $field, $item_id, 'show_disabled', $settings['show_disabled'], __( 'Show disabled languages as unavailable', 'shapetranslate' ) ); ?>
$args Core menu item arguments.
* @return void
*/
public function save_item_fields( $menu_id, $menu_item_db_id, $args ) {
unset( $menu_id );
$url = isset( $args['menu-item-url'] ) && is_scalar( $args['menu-item-url'] )
? (string) $args['menu-item-url']
: (string) get_post_meta( $menu_item_db_id, '_menu_item_url', true );
if ( self::MENU_ITEM_URL !== $url || ! current_user_can( 'edit_theme_options' ) ) {
return;
}
$nonce = isset( $_POST['update-nav-menu-nonce'] ) && is_scalar( $_POST['update-nav-menu-nonce'] )
? sanitize_text_field( wp_unslash( $_POST['update-nav-menu-nonce'] ) )
: '';
if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'update-nav_menu' ) ) {
return;
}
$posted = isset( $_POST['shapetranslate-switcher'][ $menu_item_db_id ] )
&& is_array( $_POST['shapetranslate-switcher'][ $menu_item_db_id ] )
? map_deep(
wp_unslash( $_POST['shapetranslate-switcher'][ $menu_item_db_id ] ),
'sanitize_text_field'
)
: array();
foreach ( array( 'hide_current', 'hide_missing', 'show_flags', 'show_disabled' ) as $boolean_key ) {
$posted[ $boolean_key ] = isset( $posted[ $boolean_key ] );
}
$settings = $this->switcher->normalize_args( $posted );
$settings = array_intersect_key( $settings, LanguageSwitcher::DEFAULT_ARGS );
unset( $settings['class_name'], $settings['context'] );
update_post_meta( $menu_item_db_id, self::SETTINGS_META_KEY, $settings );
}
/**
* Expands the virtual switcher item into one menu item per language.
*
* Emitting real menu items, rather than injecting switcher markup into a
* single item's anchor, lets the theme's own walker render each language.
* Spacing, colors, hover states, and the responsive overlay then come from
* the theme for free, exactly as they do for every other menu item.
*
* This runs on `wp_nav_menu_objects` instead of the earlier
* `wp_get_nav_menu_items` so that the generated items appear after
* NavigationModule has translated the menu. Each generated URL already
* points at its own language, and NavigationModule would otherwise rewrite
* every one of them to the language currently being viewed.
*
* @param array $items Menu items.
* @param \stdClass $args Menu arguments.
* @return array
*/
public function filter_menu_items( $items, $args ) {
$expanded = array();
$offset = 0;
foreach ( $items as $item ) {
if ( ! $this->is_switcher_item( $item ) ) {
if ( 0 !== $offset && isset( $item->menu_order ) ) {
$item->menu_order = (int) $item->menu_order + $offset;
}
$expanded[] = $item;
continue;
}
if ( ! $this->switcher->is_available() ) {
continue;
}
$generated = $this->build_menu_items( $item, $args, $offset );
if ( empty( $generated ) ) {
continue;
}
$offset += count( $generated ) - 1;
$expanded = array_merge( $expanded, $generated );
}
return $expanded;
}
/**
* Adds language metadata to a generated switcher link.
*
* @param array $atts Anchor attributes.
* @param WP_Post $menu_item Menu item object.
* @return array
*/
public function menu_link_attributes( $atts, $menu_item ) {
if ( ! $menu_item instanceof WP_Post || ! isset( $menu_item->shapetranslate_language ) ) {
return $atts;
}
$language = $menu_item->shapetranslate_language;
if ( ! is_array( $language ) ) {
return $atts;
}
$tag = $this->language_tag->format( $language );
if ( '' !== $tag ) {
$atts['lang'] = $tag;
$atts['hreflang'] = $tag;
$atts['rel'] = isset( $atts['rel'] ) && '' !== (string) $atts['rel']
? $atts['rel'] . ' alternate'
: 'alternate';
}
$atts['dir'] = LanguageTag::direction( $language );
if ( empty( $menu_item->shapetranslate_available ) && empty( $atts['href'] ) ) {
$atts['aria-disabled'] = 'true';
}
return $atts;
}
/**
* Builds the menu items that replace one virtual switcher item.
*
* @param WP_Post $item Virtual switcher menu item.
* @param \stdClass $args Menu arguments.
* @param int $offset Menu order offset accumulated so far.
* @return array
*/
private function build_menu_items( WP_Post $item, $args, $offset ) {
$settings = $this->get_item_settings( $item->ID );
$settings['context'] = 'menu';
/**
* Filters switcher arguments for a classic navigation menu item.
*
* @param array $settings Switcher settings.
* @param WP_Post $menu_item Menu item object.
* @param \stdClass $args Menu arguments.
*/
$filtered = apply_filters( 'shapetranslate_switcher_menu_args', $settings, $item, $args );
$settings = is_array( $filtered ) ? $this->switcher->normalize_args( $filtered ) : $settings;
/*
* A dropdown labels its parent with the current language, which the
* hide_current option would otherwise remove, so the full list is
* resolved first and trimmed afterwards.
*/
$languages = $this->switcher->get_items( array_merge( $settings, array( 'hide_current' => false ) ) );
$current = null;
foreach ( $languages as $language_item ) {
if ( ! empty( $language_item['current'] ) ) {
$current = $language_item;
break;
}
}
if ( ! empty( $settings['hide_current'] ) ) {
$languages = array_values(
array_filter(
$languages,
static function ( $language_item ) {
return empty( $language_item['current'] );
}
)
);
}
if ( empty( $languages ) ) {
return array();
}
/*
* render() enqueues this for the markup it owns, and menus no longer go
* through it. Only the flag and label rules apply here; the theme styles
* everything else, so arriving late costs no layout shift.
*/
wp_enqueue_style( 'shapetranslate-switcher' );
$dropdown = 'dropdown' === $settings['layout'];
$order = (int) $item->menu_order + (int) $offset;
$built = array();
$index = 0;
if ( $dropdown ) {
$parent = clone $item;
$parent->title = $this->switcher->get_item_content(
null !== $current ? $current : $languages[0],
$settings
);
$parent->url = '';
$parent->attr_title = '';
$parent->target = '';
$parent->xfn = '';
$parent->current = false;
$parent->menu_order = $order;
/*
* WordPress adds menu-item-has-children from the stored menu structure,
* before the filter this runs on, so a parent whose children appear here
* never receives it. Themes hang their submenu arrow and their mobile
* toggle off that class, so it has to be set by hand.
*/
$parent->classes = $this->item_classes(
$item,
array( 'shapetranslate-switcher-parent', 'menu-item-has-children' )
);
$built[] = $parent;
++$index;
}
foreach ( $languages as $language_item ) {
$language = isset( $language_item['language'] ) && is_array( $language_item['language'] )
? $language_item['language']
: array();
$classes = array( 'shapetranslate-switcher-language' );
if ( ! empty( $language_item['id'] ) ) {
$classes[] = 'shapetranslate-language-' . sanitize_html_class( (string) $language_item['id'] );
}
if ( ! empty( $language_item['current'] ) ) {
$classes[] = 'current-lang';
}
if ( empty( $language_item['available'] ) ) {
$classes[] = 'shapetranslate-switcher-unavailable';
}
$language_menu_item = clone $item;
/*
* Cloned items would otherwise share the source item's db_id, which
* makes a dropdown child its own parent and sends Walker::walk() into
* infinite recursion. Nothing links to these items, so they need no
* identifier of their own.
*/
$language_menu_item->db_id = 0;
$language_menu_item->menu_item_parent = $dropdown ? (int) $item->db_id : $item->menu_item_parent;
$language_menu_item->ID = $item->ID . '-' . (string) $language_item['id'];
$language_menu_item->title = $this->switcher->get_item_content( $language_item, $settings );
$language_menu_item->url = isset( $language_item['url'] ) ? (string) $language_item['url'] : '';
$language_menu_item->attr_title = '';
$language_menu_item->target = '';
$language_menu_item->xfn = '';
$language_menu_item->current = false;
$language_menu_item->menu_order = $order + $index;
$language_menu_item->classes = $this->item_classes( $item, $classes );
// Read back by menu_link_attributes() once the walker builds the anchor.
$language_menu_item->shapetranslate_language = $language;
$language_menu_item->shapetranslate_available = ! empty( $language_item['available'] )
|| ! empty( $language_item['fallback'] );
$built[] = $language_menu_item;
++$index;
}
/**
* Filters the menu items generated for one language switcher item.
*
* @param array $built Generated menu items.
* @param WP_Post $item Virtual switcher menu item.
* @param array $settings Normalized switcher settings.
* @param \stdClass $args Menu arguments.
*/
$result = apply_filters( 'shapetranslate_switcher_menu_items', $built, $item, $settings, $args );
return is_array( $result ) ? array_values( $result ) : $built;
}
/**
* Merges generated classes onto a copy of the source item's classes.
*
* The virtual item is a custom link, so WordPress may have marked it as the
* current page before this ran. A language entry is never the current page.
*
* @param WP_Post $item Virtual switcher menu item.
* @param array $classes Generated classes.
* @return array
*/
private function item_classes( WP_Post $item, array $classes ) {
$inherited = is_array( $item->classes ) ? $item->classes : array();
$inherited = array_diff(
$inherited,
array(
'current-menu-item',
'current_page_item',
'current-menu-ancestor',
'current-menu-parent',
'current_page_parent',
'current_page_ancestor',
)
);
return array_values( array_unique( array_merge( $inherited, array( 'menu-item-shapetranslate-switcher' ), $classes ) ) );
}
/**
* Reports whether a menu item is the ShapeTranslate virtual item.
*
* @param mixed $menu_item Menu item object.
* @return bool
*/
private function is_switcher_item( $menu_item ) {
return $menu_item instanceof WP_Post
&& isset( $menu_item->url )
&& self::MENU_ITEM_URL === $menu_item->url;
}
/**
* Loads and normalizes settings for one switcher menu item.
*
* @param int $item_id Menu item identifier.
* @return array
*/
private function get_item_settings( $item_id ) {
$settings = get_post_meta( absint( $item_id ), self::SETTINGS_META_KEY, true );
return $this->switcher->normalize_args( is_array( $settings ) ? $settings : array() );
}
/**
* Renders one menu item checkbox field.
*
* @param string $field Input name prefix.
* @param int $item_id Menu item identifier.
* @param string $key Setting key.
* @param bool $checked Checked state.
* @param string $label Field label.
* @return void
*/
private function render_checkbox( $field, $item_id, $key, $checked, $label ) {
?>