diff --git a/.gitignore b/.gitignore index 7ff4c88..9eaee33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,21 @@ node_modules/ -dist/ -*.log demo-dist/ +*.log + +# Build output. `npm run build` emits into the theme. +site/wp-content/themes/tlig/assets/ + +# WordPress core lives in site/ but is not ours — it is extracted from the +# Docker image so the two cannot drift, and can be recreated at any time. Only +# our theme and the asset mirror are the deliverable. +site/* +!site/wp-content/ +site/wp-content/* +!site/wp-content/themes/ +!site/wp-content/includes/ +site/wp-content/uploads/ +site/wp-content/plugins/* +!site/wp-content/plugins/index.php +site/wp-content/themes/* +!site/wp-content/themes/tlig/ +site/wp-content/upgrade/ diff --git a/README.md b/README.md index f122c18..ab83d9a 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,39 @@ This first pass covers the common foundation only: fonts, colour, type scale, links, buttons, form fields and the footer. Page-specific styling (messages, bookstore, menus, the wood and scroll backgrounds) is not here yet. +## Layout + + src/sass/ the stylesheet + src/images/ art: the parchment 9-slice, the scroll, the wood ground + src/demo/ the static style guide's own chrome + site/ WordPress docroot (core is gitignored; see below) + wp-content/themes/tlig/ THE DELIVERABLE + local/ docker-compose for the clean rig + +WordPress core sits in `site/` but is not ours: it is extracted from the Docker +image so the two cannot drift, and is gitignored. Only the theme and the asset +mirror are versioned. To recreate core: + + docker run --rm wordpress:php8.3-apache tar -cC /usr/src/wordpress . | tar -xC site/ + ## Commands npm install - npm run dev # demo with live reload, at localhost:5173 - npm run preview # demo as a static page, at localhost:4173 - npm run build # the deliverable: dist/tlig.css + dist/fonts/ + npm run dev # static style guide, live reload, localhost:5173 + npm run preview # static style guide as a built page, localhost:4173 + npm run build # the theme's stylesheet -> site/wp-content/themes/tlig/assets/ + + cd local && RUN_UID=$(id -u) RUN_GID=$(id -g) docker compose up -d + # the theme on real WordPress at localhost:8090 (admin / admin) + +The rig is deliberately **not** the `wp-migrate` one. That mounts the production +tree — Avada, 55 plugins, a 1.5 GB database — and exists to mirror what ships. +This one boots with nothing but core, so the theme has to stand on its own. Keep +both: the other is the reference to compare against. It runs on 8080, this on +8090, so they can run side by side. + +A `npm run build` lands directly in the running theme — the docroot is this +project's own `site/`, so there is no copy step and no container restart. There is no way to open `src/index.html` from disk — it is an ES module that imports `.scss`, so it needs one of the two servers above. `file://` gives a diff --git a/local/README.md b/local/README.md new file mode 100644 index 0000000..f804608 --- /dev/null +++ b/local/README.md @@ -0,0 +1,87 @@ +# The demo WordPress + +A clean install with **no Avada and no plugins**, so the theme has to stand on +its own. The `wp-migrate` rig is the opposite — the production tree, kept as the +reference to compare against — and is never touched from here. + + cd local + docker compose up -d # http://localhost:8090/ + +Docroot is the project's own `../site`, so a `npm run build` lands in the running +theme with no copy step and no restart. + +## Logging in + + http://localhost:8090/wp-admin/ admin / tlig + +Throwaway credentials for a throwaway local install. Nothing here is ever +deployed, and the database lives in a named volume, not in the repo. + +## wp-cli + +The `cli` service is behind a Compose profile so it never starts with the site. +Run it one command at a time: + + docker compose --profile cli run --rm cli wp user list + docker compose --profile cli run --rm cli wp plugin list + docker compose --profile cli run --rm cli wp post list --post_type=page + +Adding a user, for instance: + + docker compose --profile cli run --rm cli \ + wp user create editor editor@example.org --role=editor --user_pass=tlig + +It talks to MySQL directly — it does not go through Apache — so it works even +when the site itself is down. It shares the same `../site` mount and the same +`wp-config.php` as the web container, so the two can never disagree about which +database they are looking at. + +## Resetting + + docker compose down -v # -v also drops the database volume + +`site/` survives, because it is the repo's own tree. Re-running `wp core install` +gives a fresh site against the same files. + +## Offline by design + +`wp-config.php` sets `WP_HTTP_BLOCK_EXTERNAL`: the rig boots with no network and +never reports this install to WordPress.org. Blocking the request does not stop +WordPress making it, though — the update checks still ran, each got a `WP_Error` +back, and under `WP_DEBUG` each printed a warning *before* the headers, so every +redirect and cookie after it failed too. The message it printed ("could not +establish a secure connection") was misleading: nothing was wrong with TLS, the +request was refused locally. + +`site/wp-content/mu-plugins/tlig-offline.php` stops the checks at the source +instead of letting them fail. Keep it; without it the admin fills with warnings. + +## The menus + +`site/wp-content/import/` holds the fifteen desktop menu trees lifted from the +live site — 748 items, structure and labels only, with UberMenu's 149-key +settings blob reduced to the eight fields the audit shows are ever used. Re-run +it after changing `menus.json`; it replaces each tree rather than doubling it: + + docker compose --profile cli run --rm cli \ + wp eval-file wp-content/import/import-menus.php + +It takes a couple of minutes — `wp_update_nav_menu_item` is not fast — and +finishes with a count of menus, items and images. + +`?lang=el` picks the language (WPML is not installed; `inc/demo-languages.php` +stands in for it). `?open=3` holds the third panel open and `?open=all` drops +every panel into the flow — the second is the argument in one picture: seven +panels of seven heights, all from one 328px file. + +## Regenerating the parchment + + python3 tools/cut-parchment.py && npm run build + +It reads `papyrus_submenu_450_cnd.png` from the production tree, so that path +has to exist. The tile is the master whole — 1200x450, WebP at quality 90 — +because every narrower cut has to manufacture junctions the artwork does not +have, and the corners showed it badly. The script's header records what was +tried; its output prints the four corner junctions (the master's own, so a +regression check rather than a correction) and how many vertical repeats each +panel height needs. diff --git a/local/apache-local.conf b/local/apache-local.conf new file mode 100644 index 0000000..80eca3e --- /dev/null +++ b/local/apache-local.conf @@ -0,0 +1,22 @@ +# Logs and the PID file must go to writable paths: /var/log/apache2 and +# /var/run/apache2 are root-owned in the image, and this container runs as an +# unprivileged uid so that it matches the owner of the bind-mounted tree. +ErrorLog /dev/stderr +PidFile /tmp/apache2.pid +ServerName localhost + + + Options FollowSymLinks + AllowOverride All + Require all granted + + +# Fonts are the only subresource the browser always fetches in CORS mode, and +# WordPress emits asset URLs from WP_HOME — so opening the site at any other +# host turns every font into a blocked cross-origin request. Fine on a local +# rig; never in production. + + + Header always set Access-Control-Allow-Origin "*" + + diff --git a/local/apache-ports.conf b/local/apache-ports.conf new file mode 100644 index 0000000..521b61f --- /dev/null +++ b/local/apache-ports.conf @@ -0,0 +1,4 @@ +# Apache runs as an unprivileged uid here, so it cannot bind port 80. It listens +# on 8080 inside the container; compose publishes that as 8090 on the host, one +# port along from the wp-migrate rig so both can run side by side. +Listen 8080 diff --git a/local/apache-vhost.conf b/local/apache-vhost.conf new file mode 100644 index 0000000..496e28b --- /dev/null +++ b/local/apache-vhost.conf @@ -0,0 +1,12 @@ +# Replaces the image's 000-default.conf, which is bound to port 80. + + DocumentRoot /var/www/html + + ErrorLog /dev/stderr + CustomLog /dev/stdout combined + + + AllowOverride All + Require all granted + + diff --git a/local/docker-compose.yml b/local/docker-compose.yml new file mode 100644 index 0000000..173ef22 --- /dev/null +++ b/local/docker-compose.yml @@ -0,0 +1,88 @@ +# A clean WordPress for the TLIG theme. +# +# Deliberately NOT the wp-migrate rig. That one bind-mounts the production tree +# — Avada, 55 plugins, a 1.5 GB database — and exists to mirror what ships. This +# one boots in seconds with nothing but core, so the theme has to stand on its +# own. Keep both: the other is the reference to compare against. +name: tlig-theme + +services: + db: + image: mysql:8.4 + command: ["--mysql-native-password=ON"] + environment: + MYSQL_DATABASE: tlig + MYSQL_USER: tlig + MYSQL_PASSWORD: tlig + MYSQL_ROOT_PASSWORD: tlig + volumes: + - db:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-ptlig"] + interval: 5s + timeout: 5s + retries: 20 + + wp: + image: wordpress:php8.3-apache + # Skip the image's core-unpacking entrypoint: core is already on disk in + # site/, extracted from this same image so the versions cannot drift. The + # entrypoint would otherwise try to chmod a bind mount it does not own. + entrypoint: ["apache2-foreground"] + # Fedora runs SELinux enforcing, which blocks container access to bind + # mounts — including, silently, the config files below, which is why Apache + # kept binding port 80 despite a mounted ports.conf. + security_opt: + - label=disable + # Rootless podman maps the host user to container root by default, but + # Apache refuses to run workers as root. keep-id maps host uid 1000 to + # container uid 1000, so the container process is literally the owner of the + # bind-mounted tree. Under rootful docker, drop this and set RUN_UID=1000. + userns_mode: "keep-id" + depends_on: + db: + condition: service_healthy + ports: + - "8090:8080" + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_NAME: tlig + WORDPRESS_DB_USER: tlig + WORDPRESS_DB_PASSWORD: tlig + WORDPRESS_DEBUG: "1" + WORDPRESS_CONFIG_EXTRA: | + define( 'WP_DEBUG_DISPLAY', true ); + define( 'SCRIPT_DEBUG', true ); + define( 'DISALLOW_FILE_EDIT', true ); + # The project's own site/ is the docroot, so a Vite build lands in the + # running theme immediately — no copy step, no container restart. Config is + # mounted over the top, so site/ never holds environment-specific settings. + volumes: + - ../site:/var/www/html + - ./wp-config.php:/var/www/html/wp-config.php:ro + - ./apache-ports.conf:/etc/apache2/ports.conf:ro + - ./apache-vhost.conf:/etc/apache2/sites-enabled/000-default.conf:ro + - ./apache-local.conf:/etc/apache2/conf-enabled/zz-local.conf:ro + user: "${RUN_UID:-1000}:${RUN_GID:-1000}" + + cli: + image: wordpress:cli-php8.3 + security_opt: + - label=disable + userns_mode: "keep-id" + depends_on: + db: + condition: service_healthy + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_NAME: tlig + WORDPRESS_DB_USER: tlig + WORDPRESS_DB_PASSWORD: tlig + volumes: + - ../site:/var/www/html + - ./wp-config.php:/var/www/html/wp-config.php:ro + user: "${RUN_UID:-1000}:${RUN_GID:-1000}" + profiles: ["cli"] + +volumes: + db: diff --git a/local/wp-config.php b/local/wp-config.php new file mode 100644 index 0000000..a7fdbd1 --- /dev/null +++ b/local/wp-config.php @@ -0,0 +1,47 @@ + + + + diff --git a/site/wp-content/themes/tlig/functions.php b/site/wp-content/themes/tlig/functions.php new file mode 100644 index 0000000..b1550aa --- /dev/null +++ b/site/wp-content/themes/tlig/functions.php @@ -0,0 +1,67 @@ + __( 'Primary navigation', 'tlig' ), + ] ); + + load_theme_textdomain( 'tlig', get_template_directory() . '/languages' ); +} +add_action( 'after_setup_theme', 'tlig_setup' ); + +/** + * The stylesheet is built by Vite into assets/tlig.css. + * + * Versioned by filemtime so each build busts the browser cache — the same + * approach the old child theme uses for tlig_en.css. + */ +function tlig_enqueue_assets(): void { + $rel = '/assets/tlig.css'; + $path = get_template_directory() . $rel; + + if ( ! file_exists( $path ) ) { + return; // Not built yet; say nothing rather than enqueue a 404. + } + + wp_enqueue_style( 'tlig', get_template_directory_uri() . $rel, [], (string) filemtime( $path ) ); + + $js = get_template_directory() . '/assets/tlig.js'; + + if ( file_exists( $js ) ) { + wp_enqueue_script( + 'tlig', + get_template_directory_uri() . '/assets/tlig.js', + [], + (string) filemtime( $js ), + [ 'strategy' => 'defer' ] + ); + } +} +add_action( 'wp_enqueue_scripts', 'tlig_enqueue_assets' ); diff --git a/site/wp-content/themes/tlig/header.php b/site/wp-content/themes/tlig/header.php new file mode 100644 index 0000000..8b8f377 --- /dev/null +++ b/site/wp-content/themes/tlig/header.php @@ -0,0 +1,25 @@ + + +> + + + + + +> + + + + + + + diff --git a/site/wp-content/themes/tlig/inc/demo-languages.php b/site/wp-content/themes/tlig/inc/demo-languages.php new file mode 100644 index 0000000..594f7e0 --- /dev/null +++ b/site/wp-content/themes/tlig/inc/demo-languages.php @@ -0,0 +1,158 @@ + 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 $args wp_nav_menu arguments. + * @return array + */ +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 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 '
'; + echo '' . esc_html__( 'Language', 'tlig' ) . ''; + echo '
    '; + + foreach ( tlig_demo_languages() as $code => $name ) { + $is_current = ( $code === $current ); + + printf( + '
  • %s
  • ', + 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 '
'; +} + +/** + * 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( "\n", $css ); +} +add_action( 'wp_head', 'tlig_demo_open_panels', 99 ); diff --git a/site/wp-content/themes/tlig/inc/nav-fields.php b/site/wp-content/themes/tlig/inc/nav-fields.php new file mode 100644 index 0000000..f8dc62a --- /dev/null +++ b/site/wp-content/themes/tlig/inc/nav-fields.php @@ -0,0 +1,234 @@ +> + */ +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 '
'; + echo '' . esc_html__( 'TLIG navigation', 'tlig' ) . ''; + + 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 '

'; + + switch ( $field['type'] ) { + case 'checkbox': + printf( + '', + esc_attr( $id ), + esc_attr( $name ), + checked( ! empty( $value ), true, false ), + esc_html( $field['label'] ) + ); + break; + + case 'select': + printf( ''; + break; + + case 'textarea': + printf( + '', + esc_attr( $id ), + esc_html( $field['label'] ), + esc_attr( $name ), + esc_textarea( (string) $value ) + ); + break; + + default: + printf( + '', + esc_attr( $id ), + esc_html( $field['label'] ), + esc_attr( $name ), + esc_attr( (string) $value ) + ); + } + + if ( ! empty( $field['help'] ) ) { + printf( '%s', esc_html( $field['help'] ) ); + } + + echo '

'; + } + + echo '
'; +} +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 ); diff --git a/site/wp-content/themes/tlig/inc/nav.php b/site/wp-content/themes/tlig/inc/nav.php new file mode 100644 index 0000000..7caf4f2 --- /dev/null +++ b/site/wp-content/themes/tlig/inc/nav.php @@ -0,0 +1,528 @@ + + */ +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' => '', + 'book' => '', + 'headphones' => '', + ]; + + if ( ! isset( $icons[ $name ] ) ) { + return ''; + } + + return sprintf( + '', + $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( + '', + '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 . '
    %3$s
', + '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> Settings of the open ancestors, by depth. */ + private $stack = []; + + /** @var array 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 $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
\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%s\n", + $indent, + esc_url( (string) $this->current->url ), + esc_html( trim( wp_strip_all_tags( $this->current->title ) ) ) + ); + } + + $output .= sprintf( "%s\t
    \n", $indent ); + + return; + } + + $output .= sprintf( "\n%s
      \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
    \n%s
\n", $indent, $indent ) + : sprintf( "%s\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 . '\n"; + } + + /** + * A top-level item: the thing you see in the bar. + * + * @param WP_Post $item Menu item. + * @param array $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( '
  • ', esc_attr( implode( ' ', $classes ) ) ); + $out .= $this->link( $item, $nav, 'site-nav__top' ); + + if ( $panel ) { + $out .= sprintf( + '', + $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 $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( '