WP test container created

This commit is contained in:
jasa
2026-09-24 18:06:11 +02:00
parent 3ee471bf39
commit e2442d1d15
44 changed files with 2244 additions and 47 deletions
@@ -0,0 +1,158 @@
<?php
/**
* Demo scaffolding: a language switcher, standing in for WPML.
*
* The real site resolves the menu through WPML, which is not installed here and
* is not what the demo is arguing about. But the argument *is* about languages
* — the claim is that one tree per language renders correctly without anyone
* cutting a bitmap — so the demo has to be able to show fifteen of them.
*
* So the fifteen imported trees are all real menus, and `?lang=xx` picks which
* one the `primary` location resolves to. Under a port this file goes away and
* WPML's own resolution takes over; nothing else in the theme knows about it.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* The languages the demo has a menu for, in the order they are offered.
*
* @return array<string,string> Code => endonym.
*/
function tlig_demo_languages(): array {
return [
'en' => 'English',
'fr' => 'Français',
'de' => 'Deutsch',
'es' => 'Español',
'it' => 'Italiano',
'pt' => 'Português',
'nl' => 'Nederlands',
'da' => 'Dansk',
'sv' => 'Svenska',
'pl' => 'Polski',
'cs' => 'Čeština',
'bg' => 'Български',
'ru' => 'Русский',
'el' => 'Ελληνικά',
'ar' => 'العربية',
];
}
/**
* The language currently being shown.
*/
function tlig_demo_lang(): string {
$want = isset( $_GET['lang'] ) ? sanitize_key( wp_unslash( $_GET['lang'] ) ) : 'en';
return isset( tlig_demo_languages()[ $want ] ) ? $want : 'en';
}
/**
* Arabic is the reason the whole layout is written in logical properties.
*/
function tlig_demo_is_rtl(): bool {
return 'ar' === tlig_demo_lang();
}
/**
* Points the primary location at the menu for the chosen language.
*
* @param array<string,mixed> $args wp_nav_menu arguments.
* @return array<string,mixed>
*/
function tlig_demo_pick_menu( $args ) {
if ( ( $args['theme_location'] ?? '' ) !== 'primary' ) {
return $args;
}
$menu = wp_get_nav_menu_object( 'TLIG ' . strtoupper( tlig_demo_lang() ) );
if ( $menu ) {
$args['menu'] = $menu;
}
return $args;
}
add_filter( 'wp_nav_menu_args', 'tlig_demo_pick_menu' );
/**
* Flips the document for Arabic.
*
* @param string $output The <html> attributes.
* @return string
*/
function tlig_demo_html_dir( $output ) {
if ( ! tlig_demo_is_rtl() ) {
return $output;
}
return preg_replace( '/\bdir="[^"]*"/', '', $output ) . ' dir="rtl" lang="ar"';
}
add_filter( 'language_attributes', 'tlig_demo_html_dir' );
/**
* The switcher itself.
*/
function tlig_demo_language_bar(): void {
$current = tlig_demo_lang();
echo '<div class="lang-bar"><div class="lang-bar__inner">';
echo '<span class="filter-bar__label">' . esc_html__( 'Language', 'tlig' ) . '</span>';
echo '<ul class="filter-bar">';
foreach ( tlig_demo_languages() as $code => $name ) {
$is_current = ( $code === $current );
printf(
'<li><a class="%s" href="%s" hreflang="%s"%s>%s</a></li>',
esc_attr( $is_current ? 'filter filter--sm is-active' : 'filter filter--sm' ),
esc_url( add_query_arg( 'lang', $code, home_url( '/' ) ) ),
esc_attr( $code ),
$is_current ? ' aria-current="page"' : '',
esc_html( $name )
);
}
echo '</ul></div></div>';
}
/**
* Demo scaffolding: force panels open, for screenshots and for the pitch.
*
* `?open=3` holds the third panel open; `?open=all` drops every panel into the
* flow, one under another. The second is the argument in one picture — seven
* panels of seven different heights, all drawn from the same 328px parchment
* file, where the old site needs a separately cut bitmap for each.
*
* It is a stylesheet, not a change to the markup, so nothing about the menu
* itself depends on it.
*/
function tlig_demo_open_panels(): void {
$open = isset( $_GET['open'] ) ? sanitize_key( wp_unslash( $_GET['open'] ) ) : '';
if ( '' === $open ) {
return;
}
if ( 'all' === $open ) {
$css = '.site-nav__bar{display:block}'
. '.site-nav__item{display:block}'
. '.site-nav__panel{display:block;position:static;margin-block:0 1.5rem}';
} else {
$n = max( 1, (int) $open );
$css = sprintf(
// The bar too: below the tablet breakpoint it is hidden until the
// toggle opens it, and a screenshot cannot press the toggle.
'.site-nav__bar{display:flex}.site-nav__item:nth-child(%d) > .site-nav__panel{display:block}',
$n
);
}
printf( "<style id=\"tlig-demo-open\">%s</style>\n", $css );
}
add_action( 'wp_head', 'tlig_demo_open_panels', 99 );
@@ -0,0 +1,234 @@
<?php
/**
* Eight fields on the menu item screen.
*
* WordPress core has offered `wp_nav_menu_item_custom_fields` since 5.4, so
* this needs no plugin and no custom admin page — the fields appear inside the
* item WordPress already draws, and save with it.
*
* Eight against UberMenu's 149. That is a real reduction in control and it is
* deliberate: what went away is per-item colour, per-item background image and
* per-item height, which is exactly the set that made the old menu a picture
* instead of a design. Colour comes from tokens, the ground comes from the
* parchment border-image, and height comes from the content.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* The field definitions, in the order they are drawn.
*
* @return array<string,array<string,mixed>>
*/
function tlig_nav_fields(): array {
return [
'panel' => [
'type' => 'checkbox',
'label' => __( 'Opens a mega panel', 'tlig' ),
'help' => __( 'Top-level items only. The panel is as tall as its content.', 'tlig' ),
],
'heading' => [
'type' => 'checkbox',
'label' => __( 'Heading, not a link', 'tlig' ),
],
'span' => [
'type' => 'select',
'label' => __( 'Column width', 'tlig' ),
'options' => [
'' => __( 'Automatic', 'tlig' ),
'3' => __( 'Quarter', 'tlig' ),
'4' => __( 'Third', 'tlig' ),
'6' => __( 'Half', 'tlig' ),
'8' => __( 'Two thirds', 'tlig' ),
'12' => __( 'Full width', 'tlig' ),
],
],
'layout' => [
'type' => 'select',
'label' => __( 'Image position', 'tlig' ),
'options' => [
'' => __( 'No image', 'tlig' ),
'image_left' => __( 'Beside the label', 'tlig' ),
'image_below' => __( 'Above the label', 'tlig' ),
],
],
'image' => [
'type' => 'number',
'label' => __( 'Image (attachment ID)', 'tlig' ),
],
'icon' => [
'type' => 'select',
'label' => __( 'Icon', 'tlig' ),
'options' => [
'' => __( 'None', 'tlig' ),
'search' => __( 'Search', 'tlig' ),
'book' => __( 'Book', 'tlig' ),
'headphones' => __( 'Headphones', 'tlig' ),
],
],
'feed' => [
'type' => 'number',
'label' => __( 'List children of page ID', 'tlig' ),
'help' => __( 'Generated at render time, so new pages appear by themselves.', 'tlig' ),
],
'block' => [
'type' => 'textarea',
'label' => __( 'HTML block', 'tlig' ),
'help' => __( 'An escape hatch. Filtered through wp_kses_post, so it cannot carry a stylesheet.', 'tlig' ),
],
];
}
/**
* Draws the fields inside the menu item.
*
* @param int $item_id Menu item post ID.
*/
function tlig_nav_item_fields( $item_id ): void {
$nav = tlig_nav_meta( (int) $item_id );
wp_nonce_field( 'tlig_nav_' . $item_id, 'tlig_nav_nonce_' . $item_id );
echo '<fieldset class="field-tlig-nav description-wide" style="margin:10px 0;padding:8px 10px;border:1px solid #dcdcde;">';
echo '<legend style="font-weight:600;padding:0 4px;">' . esc_html__( 'TLIG navigation', 'tlig' ) . '</legend>';
foreach ( tlig_nav_fields() as $key => $field ) {
$name = sprintf( 'tlig_nav[%d][%s]', $item_id, $key );
$id = sprintf( 'tlig-nav-%d-%s', $item_id, $key );
$value = $nav[ $key ] ?? '';
// `feed` is stored as an array so the walker can grow it later; the
// screen only ever asks for the parent, which is the part an editor
// would change.
if ( 'feed' === $key && is_array( $value ) ) {
$value = $value['parent'] ?? '';
}
echo '<p class="description description-wide" style="margin:6px 0;">';
switch ( $field['type'] ) {
case 'checkbox':
printf(
'<label for="%1$s"><input type="checkbox" id="%1$s" name="%2$s" value="1"%3$s> %4$s</label>',
esc_attr( $id ),
esc_attr( $name ),
checked( ! empty( $value ), true, false ),
esc_html( $field['label'] )
);
break;
case 'select':
printf( '<label for="%s">%s<br>', esc_attr( $id ), esc_html( $field['label'] ) );
printf( '<select id="%s" name="%s" class="widefat">', esc_attr( $id ), esc_attr( $name ) );
foreach ( $field['options'] as $opt => $text ) {
printf(
'<option value="%s"%s>%s</option>',
esc_attr( $opt ),
selected( (string) $value, (string) $opt, false ),
esc_html( $text )
);
}
echo '</select></label>';
break;
case 'textarea':
printf(
'<label for="%1$s">%2$s<br><textarea id="%1$s" name="%3$s" rows="3" class="widefat">%4$s</textarea></label>',
esc_attr( $id ),
esc_html( $field['label'] ),
esc_attr( $name ),
esc_textarea( (string) $value )
);
break;
default:
printf(
'<label for="%1$s">%2$s<br><input type="number" id="%1$s" name="%3$s" value="%4$s" class="widefat"></label>',
esc_attr( $id ),
esc_html( $field['label'] ),
esc_attr( $name ),
esc_attr( (string) $value )
);
}
if ( ! empty( $field['help'] ) ) {
printf( '<span class="description" style="display:block;color:#646970;">%s</span>', esc_html( $field['help'] ) );
}
echo '</p>';
}
echo '</fieldset>';
}
add_action( 'wp_nav_menu_item_custom_fields', 'tlig_nav_item_fields', 10, 1 );
/**
* Saves them.
*
* Stored as one array under `_tlig_nav` rather than eight meta rows: the whole
* point is that an item's configuration is small enough to read at a glance.
* An empty configuration deletes the row instead of writing an empty array, so
* the 214 items that need no settings carry no meta at all.
*
* @param int $menu_id Menu term ID.
* @param int $item_id Menu item post ID.
*/
function tlig_nav_item_save( $menu_id, $item_id ): void {
$nonce = $_POST[ 'tlig_nav_nonce_' . $item_id ] ?? '';
if ( ! wp_verify_nonce( sanitize_key( $nonce ), 'tlig_nav_' . $item_id ) ) {
return;
}
$raw = $_POST['tlig_nav'][ $item_id ] ?? [];
if ( ! is_array( $raw ) ) {
$raw = [];
}
$nav = [];
foreach ( tlig_nav_fields() as $key => $field ) {
$value = $raw[ $key ] ?? '';
switch ( $field['type'] ) {
case 'checkbox':
if ( ! empty( $value ) ) {
$nav[ $key ] = 1;
}
break;
case 'select':
$value = (string) $value;
if ( '' !== $value && isset( $field['options'][ $value ] ) ) {
$nav[ $key ] = 'span' === $key ? (int) $value : $value;
}
break;
case 'textarea':
$value = wp_kses_post( (string) $value );
if ( '' !== trim( $value ) ) {
$nav[ $key ] = $value;
}
break;
default:
$value = (int) $value;
if ( $value > 0 ) {
$nav[ $key ] = 'feed' === $key
? [ 'parent' => $value, 'count' => -1, 'orderby' => 'title' ]
: $value;
}
}
}
if ( $nav ) {
update_post_meta( $item_id, '_tlig_nav', $nav );
} else {
delete_post_meta( $item_id, '_tlig_nav' );
}
}
add_action( 'wp_update_nav_menu_item', 'tlig_nav_item_save', 10, 2 );
+528
View File
@@ -0,0 +1,528 @@
<?php
/**
* The navigation: one tree, rendered from the menu itself.
*
* What this replaces is worth stating, because it is the whole argument.
*
* The site it comes from runs UberMenu, whose per-item settings blob carries
* 149 keys. Across all 15 desktop menus — 748 items — exactly eight of them are
* ever set to something that matters:
*
* span 167 the column's width, stored as a fraction ("1-4", "2-3")
* layout 165 image_left | image_below
* image 159 a thumbnail beside the label
* panel 96 this top-level item opens a mega panel
* heading 94 the item is a heading, not a link
* block 56 raw HTML dropped into the panel
* icon 52 one of exactly three icons, site-wide
* feed 14 list child pages of a given parent
*
* Everything else in the blob is the plugin's own defaults written back
* verbatim — 667 items carry `dt_orderby: name` and `tab_layout: left` without
* a tab or a taxonomy anywhere in the menu.
*
* So the eight live in one `_tlig_nav` array, and the structure comes from the
* tree with no configuration at all: depth 0 is the bar, depth 1 a column,
* depth 2 a link. No per-item heights, no per-item colours, no background image
* per panel — the panel's ground is the parchment border-image, which is one
* file at any height.
*
* @package TLIG
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Read an item's TLIG settings.
*
* @param int $item_id Menu item post ID.
* @return array<string,mixed>
*/
function tlig_nav_meta( int $item_id ): array {
$meta = get_post_meta( $item_id, '_tlig_nav', true );
return is_array( $meta ) ? $meta : [];
}
/**
* The three icons the entire site uses, inline.
*
* A whole icon font was loaded for these. Counted across all 15 menus: 24
* `fa-search`, 14 `fa-book-open`, 14 `fa-headphones`, and nothing else. Inline
* SVG costs no request and inherits `currentColor`, so an icon on the dark
* ground needs no second rule.
*
* @param string $name Icon key.
* @return string SVG markup, or '' for an unknown name.
*/
function tlig_nav_icon( string $name ): string {
static $icons = [
'search' => '<path d="M11 4a7 7 0 1 0 4.19 12.6l4.1 4.1 1.41-1.42-4.1-4.1A7 7 0 0 0 11 4Zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z"/>',
'book' => '<path d="M3 5c2.5-1 5-1 8 .5v14c-3-1.5-5.5-1.5-8-.5V5Zm18 0c-2.5-1-5-1-8 .5v14c3-1.5 5.5-1.5 8-.5V5Z"/>',
'headphones' => '<path d="M12 3a9 9 0 0 0-9 9v5a3 3 0 0 0 3 3h2v-8H5v0a7 7 0 0 1 14 0v0h-3v8h2a3 3 0 0 0 3-3v-5a9 9 0 0 0-9-9Z"/>',
];
if ( ! isset( $icons[ $name ] ) ) {
return '';
}
return sprintf(
'<svg class="site-nav__icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false">%s</svg>',
$icons[ $name ]
);
}
/**
* Renders the primary menu.
*
* @param string $location Registered menu location.
*/
function tlig_nav( string $location = 'primary' ): void {
if ( ! has_nav_menu( $location ) ) {
return;
}
// The toggle is part of the menu's own markup rather than the header's, so
// `aria-controls` can name the list it opens without the template having to
// know the id. Below the tablet breakpoint it is the entire mobile menu.
$toggle = sprintf(
'<button type="button" class="site-nav__toggle" aria-expanded="false" aria-controls="%s">%s</button>',
'tlig-menu',
esc_html__( 'Menu', 'tlig' )
);
wp_nav_menu( [
'theme_location' => $location,
'container' => 'nav',
'container_class' => 'site-nav',
'container_aria_label' => __( 'Primary navigation', 'tlig' ),
'menu_id' => 'tlig-menu',
'menu_class' => 'site-nav__bar',
'items_wrap' => $toggle . '<ul id="%1$s" class="%2$s">%3$s</ul>',
'depth' => 3,
'walker' => new TLIG_Nav_Walker(),
'fallback_cb' => false,
] );
}
/**
* Turns a three-level menu into a bar of mega panels.
*
* `Walker_Nav_Menu` hands `start_lvl()` a depth but not the item that owns the
* level, so the current item is stashed in `start_el()` — the panel wrapper has
* to know whose panel it is in order to give it an id for `aria-controls`.
*/
class TLIG_Nav_Walker extends Walker_Nav_Menu {
/** @var WP_Post|null The item most recently opened, for start_lvl(). */
private $current;
/** @var array<int,array<string,mixed>> Settings of the open ancestors, by depth. */
private $stack = [];
/** @var array<int,int> How many children each item has, by item ID. */
private $siblings = [];
/**
* Counts each item's children before walking.
*
* A column with no width of its own should share the row with its
* neighbours, which means knowing how many there are. UberMenu stored that
* as `columns: auto` and worked it out at render time; the walker is handed
* the whole tree here, so it can do the same.
*
* @param array<int,WP_Post> $elements All menu items.
* @param int $max_depth Depth limit.
* @param mixed ...$args Passed through.
* @return string
*/
public function walk( $elements, $max_depth, ...$args ) {
$this->siblings = [];
foreach ( (array) $elements as $element ) {
$parent = (int) ( $element->menu_item_parent ?? 0 );
$this->siblings[ $parent ] = ( $this->siblings[ $parent ] ?? 0 ) + 1;
}
return parent::walk( $elements, $max_depth, ...$args );
}
/**
* Opens a submenu level.
*
* Depth 0 → the panel and its grid. Depth 1 → the list of links in a column.
*
* @param string $output Accumulated markup, by reference.
* @param int $depth Depth of the *parent* item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function start_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
if ( 0 === $depth ) {
$id = $this->current ? 'tlig-panel-' . (int) $this->current->ID : '';
$output .= sprintf(
"\n%s<div class=\"site-nav__panel\" id=\"%s\">\n",
$indent,
esc_attr( $id )
);
// The panel repeats its own item as a link, and the stylesheet shows
// it only in the stacked layout. There, a tap on the bar item opens
// the panel instead of following the link — which is what a phone
// user expects, and what the old site got wrong in the opposite
// direction: with hover the only way in, its top-level pages were
// unreachable on a phone. This is the way back to that page.
if ( $this->current && '' !== trim( (string) $this->current->url ) && '#' !== $this->current->url ) {
$output .= sprintf(
"%s\t<a class=\"site-nav__self\" href=\"%s\">%s</a>\n",
$indent,
esc_url( (string) $this->current->url ),
esc_html( trim( wp_strip_all_tags( $this->current->title ) ) )
);
}
$output .= sprintf( "%s\t<ul class=\"site-nav__grid\">\n", $indent );
return;
}
$output .= sprintf( "\n%s<ul class=\"site-nav__links\">\n", $indent );
}
/**
* Closes a submenu level.
*
* @param string $output Accumulated markup, by reference.
* @param int $depth Depth of the parent item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function end_lvl( &$output, $depth = 0, $args = null ) {
$indent = str_repeat( "\t", $depth );
$output .= 0 === $depth
? sprintf( "%s\t</ul>\n%s</div>\n", $indent, $indent )
: sprintf( "%s</ul>\n", $indent );
}
/**
* Opens an item.
*
* @param string $output Accumulated markup, by reference.
* @param WP_Post $item Menu item.
* @param int $depth Depth of the item.
* @param stdClass $args wp_nav_menu arguments.
* @param int $current_object_id Unused.
*/
public function start_el( &$output, $item, $depth = 0, $args = null, $current_object_id = 0 ) {
$this->current = $item;
$nav = tlig_nav_meta( (int) $item->ID );
$this->stack[ $depth ] = $nav;
$has_children = in_array( 'menu-item-has-children', (array) $item->classes, true );
$indent = str_repeat( "\t", $depth + 1 );
switch ( $depth ) {
case 0:
$output .= $indent . $this->bar_item( $item, $nav, $has_children );
break;
case 1:
$output .= $indent . $this->column( $item, $nav, $has_children );
break;
default:
// A leaf can carry a block or a feed too — the English
// TESTIMONIES panel is two of them, and dropping them here is
// how they went missing the first time.
$output .= $indent . '<li class="site-nav__leaf">'
. $this->link( $item, $nav, 'site-nav__link' )
. $this->block( $nav )
. $this->feed( $nav );
}
}
/**
* Closes an item.
*
* @param string $output Accumulated markup, by reference.
* @param WP_Post $item Menu item.
* @param int $depth Depth of the item.
* @param stdClass $args wp_nav_menu arguments.
*/
public function end_el( &$output, $item, $depth = 0, $args = null ) {
$output .= "</li>\n";
}
/**
* A top-level item: the thing you see in the bar.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param bool $has_children Whether it opens a panel.
* @return string
*/
private function bar_item( WP_Post $item, array $nav, bool $has_children ): string {
$panel = $has_children && ! empty( $nav['panel'] );
$classes = [ 'site-nav__item' ];
if ( $panel ) {
$classes[] = 'site-nav__item--panel';
}
if ( in_array( 'current-menu-item', (array) $item->classes, true ) ) {
$classes[] = 'is-current';
}
$attrs = '';
if ( $panel ) {
// The link still goes where it always went; the button beside it is
// what opens the panel. A single element cannot both navigate and
// disclose, and hover alone leaves touch and keyboard out.
$attrs = sprintf(
' aria-expanded="false" aria-controls="%s"',
esc_attr( 'tlig-panel-' . (int) $item->ID )
);
}
$out = sprintf( '<li class="%s">', esc_attr( implode( ' ', $classes ) ) );
$out .= $this->link( $item, $nav, 'site-nav__top' );
if ( $panel ) {
$out .= sprintf(
'<button type="button" class="site-nav__disclose"%s><span class="visually-hidden">%s</span></button>',
$attrs,
/* translators: %s: menu item label. */
esc_html( sprintf( __( 'Open the %s menu', 'tlig' ), wp_strip_all_tags( $item->title ) ) )
);
}
return $out;
}
/**
* A column inside a panel.
*
* The width is a grid span, not a pixel height: `--tlig-span` feeds
* `grid-column: span var(--tlig-span)`. UberMenu stored it as a fraction of
* the row, which is the same thing said less directly.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param bool $has_children Whether it holds links.
* @return string
*/
private function column( WP_Post $item, array $nav, bool $has_children ): string {
$span = isset( $nav['span'] ) ? max( 1, min( 12, (int) $nav['span'] ) ) : 0;
// No width of its own: divide the row by however many columns this
// panel has. Without this every unsized column fell back to a quarter,
// which is right for a panel of four and wrong for the 28 panels —
// UNITY in DIVERSITY, MISSION, SEARCH, across the languages — that
// hold a single column. Those were rendering a 1200px banner into a
// quarter of the width.
if ( ! $span ) {
$count = max( 1, (int) ( $this->siblings[ (int) $item->menu_item_parent ] ?? 1 ) );
$span = max( 1, min( 12, (int) round( 12 / $count ) ) );
}
$style = sprintf( ' style="--tlig-span:%d"', $span );
$out = sprintf( '<li class="site-nav__col"%s>', $style );
// A heading is an item with its link switched off. It was a link with
// `disable_link: on` before; here it is simply not an anchor, which is
// also what a screen reader needs to hear.
$title = trim( wp_strip_all_tags( $item->title ) );
$linked = false;
if ( ! empty( $nav['heading'] ) && '' !== $title ) {
$out .= sprintf( '<h3 class="site-nav__heading">%s</h3>', esc_html( $title ) );
} elseif ( ! $has_children && '' !== $title ) {
$out .= $this->link( $item, $nav, 'site-nav__link' );
$linked = true;
}
// `link()` already emits the thumbnail, so only a column that did not
// render one needs it here — otherwise a childless column with an image
// shows it twice.
if ( ! $linked ) {
$out .= $this->figure( $nav );
}
$out .= $this->block( $nav );
$out .= $this->feed( $nav );
return $out;
}
/**
* An anchor, with its optional thumbnail and icon.
*
* @param WP_Post $item Menu item.
* @param array<string,mixed> $nav Its TLIG settings.
* @param string $class Class for the anchor.
* @return string
*/
private function link( WP_Post $item, array $nav, string $class ): string {
$title = trim( wp_strip_all_tags( $item->title ) );
$inner = $this->figure( $nav );
if ( ! empty( $nav['icon'] ) ) {
$inner .= tlig_nav_icon( (string) $nav['icon'] );
}
if ( '' !== $title ) {
$inner .= sprintf( '<span class="site-nav__label">%s</span>', esc_html( $title ) );
}
if ( '' === $inner ) {
return '';
}
// An item with its link switched off still has something to show — the
// English menu's fourth column is a single picture with `disable_link`
// set, and wrapping it in an anchor to nowhere would be a lie to anyone
// tabbing through. It renders as the content without the anchor.
// No destination either: an item can lose its link because the editor
// switched it off, or because it never had one — 137 of the 748 items
// point at an `#ubermenu-column` placeholder. Both are content without
// a link, and neither should be an anchor to nowhere.
if ( ! empty( $nav['heading'] ) || '' === trim( (string) $item->url ) || '#' === $item->url ) {
$static = $class . ' is-static';
if ( '' === $title && ! empty( $nav['image'] ) ) {
$static .= ' ' . $class . '--figure';
}
return sprintf( '<span class="%s">%s</span>', esc_attr( $static ), $inner );
}
$classes = [ $class ];
if ( ! empty( $nav['layout'] ) ) {
$classes[] = $class . '--' . str_replace( '_', '-', (string) $nav['layout'] );
}
// A picture with nothing to label is the column's content, not a
// thumbnail beside a word, and the stylesheet sizes the two differently.
if ( '' === $title && ! empty( $nav['image'] ) ) {
$classes[] = $class . '--figure';
}
return sprintf(
'<a class="%s" href="%s"%s>%s</a>',
esc_attr( implode( ' ', $classes ) ),
esc_url( (string) $item->url ),
$item->target ? sprintf( ' target="%s" rel="noopener"', esc_attr( $item->target ) ) : '',
$inner
);
}
/**
* The item's thumbnail, if it has one.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function figure( array $nav ): string {
if ( empty( $nav['image'] ) ) {
return '';
}
$id = (int) $nav['image'];
if ( ! $id ) {
return '';
}
// `large`, not `medium`. Four of these are a column's whole content, and
// two of them are 1200 and 1380 pixels wide in the library — asking for
// `medium` handed the browser a 300px copy to stretch across a 730px
// column. `large` carries a srcset, so a 56px thumbnail still costs a
// small file.
return wp_get_attachment_image(
$id,
'large',
false,
[
'class' => 'site-nav__thumb',
'loading' => 'lazy',
'alt' => '',
]
);
}
/**
* Raw HTML attached to the item.
*
* 56 items carry one. They are not arbitrary — across every language only
* five tags appear in them, and 14 of the 56 are the search form. They are
* kept as an escape hatch, not as a design tool, and the content is filtered
* so a menu cannot inject a stylesheet the way the Danish one does today.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function block( array $nav ): string {
if ( empty( $nav['block'] ) ) {
return '';
}
$html = (string) $nav['block'];
if ( false !== strpos( $html, '[search-form]' ) ) {
return '<div class="site-nav__block">' . get_search_form( [ 'echo' => false ] ) . '</div>';
}
return '<div class="site-nav__block">' . wp_kses_post( do_shortcode( $html ) ) . '</div>';
}
/**
* A list of child pages, generated rather than hand-maintained.
*
* This is the one setting that makes the menu content-driven in the literal
* sense: publish a page under the named parent and it appears, in every
* language, without anyone editing a menu.
*
* @param array<string,mixed> $nav Item settings.
* @return string
*/
private function feed( array $nav ): string {
if ( empty( $nav['feed'] ) || ! is_array( $nav['feed'] ) ) {
return '';
}
$feed = $nav['feed'];
$parent = isset( $feed['parent'] ) ? (int) $feed['parent'] : 0;
if ( ! $parent ) {
return '';
}
$children = get_posts( [
'post_type' => 'page',
'post_parent' => $parent,
'posts_per_page' => isset( $feed['count'] ) ? (int) $feed['count'] : -1,
'orderby' => isset( $feed['orderby'] ) ? (string) $feed['orderby'] : 'title',
'order' => 'ASC',
'suppress_filters' => false,
] );
if ( ! $children ) {
return '';
}
$out = '<ul class="site-nav__links site-nav__links--feed">';
foreach ( $children as $child ) {
$out .= sprintf(
'<li class="site-nav__leaf"><a class="site-nav__link" href="%s"><span class="site-nav__label">%s</span></a></li>',
esc_url( (string) get_permalink( $child ) ),
esc_html( get_the_title( $child ) )
);
}
return $out . '</ul>';
}
}