dataContext = [ 'post' => [...], 'term' => [...] ] * * core knows only dataContext and knows nothing about WordPress. * Connecting a headless CMS or a standalone database later means writing another translator like this one. */ class DataContext { /** The base context for the current page. */ public static function current(): array { /* * Site-level information. * * `aumframe/site_row` lets the theme add to it — the logo arrives through it. * Why the opening is needed: the theme settings hold the logo (in light and dark versions), and the * theme's `header.php` never executes on a taken-over page, so those images have **no outlet at * all**. The result is a buyer who uploaded a logo in the theme settings and still sees a line of * hard-coded text on their site. * * Structurally the same as `aumframe/post_row`: the plugin does not know what any of it is and * only provides the place. */ $ctx = [ 'site' => apply_filters('aumframe/site_row', [ 'title' => get_bloginfo('name'), 'description' => get_bloginfo('description'), 'url' => home_url('/'), ]), ]; if (is_singular()) { $ctx['post'] = self::post(get_post()); } if (is_tax() || is_category() || is_tag()) { $ctx['term'] = self::term(get_queried_object()); } if (is_search()) { /* * Everything a search results page needs to describe itself. * * `query` alone was not enough to build one: a results page has to say how many it found * and what it is filtered to, and with neither exposed the only honest layout was a bare * list of titles — which is exactly what the first search template ended up being. * * `hasResults` / `hasType` follow the project's convention for conditions ('1' or ''), so a * node can be hidden when there is nothing to say rather than rendering an empty heading. * * `type` is read from the query's own `post_type`, not from any particular URL parameter: * whatever narrowed the search — the theme's dropdown, a plugin, a hand-written link — the * page reports what it is actually showing. */ global $wp_query; $found = $wp_query instanceof \WP_Query ? (int) $wp_query->found_posts : 0; $type_label = ''; $post_type = $wp_query instanceof \WP_Query ? $wp_query->get('post_type') : ''; if (is_string($post_type) && $post_type !== '') { $obj = get_post_type_object($post_type); $type_label = $obj ? (string) $obj->labels->name : ''; } $ctx['search'] = [ 'query' => get_search_query(), 'count' => (string) $found, 'hasResults' => $found > 0 ? '1' : '', 'type' => $type_label, 'hasType' => $type_label !== '' ? '1' : '', ]; } return $ctx; } /** * What the archive level is called. * * `has_archive` can be a string (the archive's path), which is how Woo attaches its shop page. In * that case the page's title is more accurate — otherwise a detail page's breadcrumb says "Products" * while the archive itself says "Shop": two names for one place. */ private static function archive_label(\WP_Post_Type $pt): string { if (is_string($pt->has_archive) && $pt->has_archive !== '') { $page = get_page_by_path($pt->has_archive); if ($page) { return (string) get_the_title($page); } } return (string) $pt->labels->name; } /** * The breadcrumb levels. * * It deliberately does not guess a "primary" term: when a post has three terms, only a person knows * which belongs in the breadcrumb. This takes the first term of the first taxonomy — a simple, * predictable rule. Specifying a primary term is a future field, not something to guess at here. */ /** * Rows for `source: 'menu'`. Flat core list -> a tree the Repeater can walk. * * @param array $q * @return array> */ private static function menu_rows(array $q): array { if (!function_exists('wp_get_nav_menu_items')) { return []; } /* * Go through the same two filters `wp_nav_menu()` does, in the same order. * * Reading the menu with `wp_get_nav_menu_items()` alone gets the items and **skips the entire * ecosystem**: every multilingual plugin translates navigation by hooking one of these two, and * none of them hook the raw getter. Measured against AumLang 1.0.0, whose `MenuTranslator` hooks * `wp_nav_menu_objects` — a layout reading the menu directly would render the **default * language's** items on every translated page, with no error anywhere. * * - `wp_nav_menu_args` runs before the menu is resolved, so a plugin can swap *which* menu a * location uses (the "one menu per language" pattern). * - `wp_nav_menu_objects` runs after `_wp_menu_item_classes_by_context()`, so a plugin can * rewrite each item's url and title (the "one menu, translated per request" pattern — AumLang's). * * Both are cheap when nothing is listening. `$args` is the object shape core passes, so a listener * reading `$args->theme_location` finds what it expects. */ $args = [ 'menu' => $q['menu'] ?? '', 'theme_location' => (string) ($q['location'] ?? ''), 'container' => 'div', 'menu_class' => 'menu', 'echo' => false, 'depth' => (int) ($q['depth'] ?? 2), 'walker' => '', 'items_wrap' => '', ]; /** This filter is documented in wp-includes/nav-menu-template.php */ $args = apply_filters('wp_nav_menu_args', $args); $args = (object) $args; $items = null; $loc = (string) ($args->theme_location ?? ''); if ($loc !== '') { $locations = get_nav_menu_locations(); if (!empty($locations[$loc])) { $items = wp_get_nav_menu_items((int) $locations[$loc]); } } if ($items === null && !empty($args->menu)) { $obj = wp_get_nav_menu_object($args->menu); if ($obj) { $items = wp_get_nav_menu_items($obj->term_id); } } if (!is_array($items) || !$items) { return []; } // Core decides what "current" means — see the note at the call site. if (function_exists('_wp_menu_item_classes_by_context')) { _wp_menu_item_classes_by_context($items); } /** This filter is documented in wp-includes/nav-menu-template.php */ $items = apply_filters('wp_nav_menu_objects', $items, $args); if (!is_array($items) || !$items) { return []; } $maxDepth = (int) ($q['depth'] ?? 2); if ($maxDepth < 1) { $maxDepth = 1; } $byParent = []; foreach ($items as $it) { if (!empty($it->_invalid)) { continue; // target deleted — a link to nothing } $url = (string) ($it->url ?? ''); if ($url === '' || $url === '#') { continue; } $byParent[(int) ($it->menu_item_parent ?? 0)][] = $it; } $build = function (int $parent, int $depth) use (&$build, &$byParent, $maxDepth): array { $rows = []; foreach ($byParent[$parent] ?? [] as $it) { $kids = $depth < $maxDepth ? $build((int) $it->ID, $depth + 1) : []; $rows[] = [ 'id' => (int) $it->ID, 'title' => (string) ($it->title ?? ''), 'url' => (string) ($it->url ?? ''), 'target' => (string) ($it->target ?? ''), 'description' => (string) ($it->description ?? ''), /* * Booleans go out as '' / '1'. The condition ops the editor offers are * `empty` / `notEmpty`, and PHP `false` and `true` would both have to be * stringified somewhere — doing it here keeps "highlight the current item" * expressible as `notEmpty`, the same shape as every other flag in this file. */ 'isCurrent' => !empty($it->current) ? '1' : '', 'isAncestor' => (!empty($it->current_item_ancestor) || !empty($it->current_item_parent)) ? '1' : '', 'children' => $kids, 'hasChildren' => $kids ? '1' : '', ]; } return $rows; }; $rows = $build(0, 1); /* * `offset` / `limit` slice the top level (1.7.76, template #12). A split header — three items left of * a centred wordmark, the rest to its right (shell `S7`) — is one menu read twice: `{ limit: 3 }` and * `{ offset: 3 }`. Before this the two halves could only come from two different sources (a taxonomy on * one side, the menu on the other), which is what templates #5 and #10 did to get round it. */ $offset = max(0, (int) ($q['offset'] ?? 0)); $limit = (int) ($q['limit'] ?? 0); if ($offset || $limit > 0) { $rows = array_slice($rows, $offset, $limit > 0 ? $limit : null); } return $rows; } private static function breadcrumb(): array { $rows = [[ 'title' => __('Home', 'aumframe'), 'url' => home_url('/'), 'isCurrent' => false, ]]; $push = function (string $title, string $url = '') use (&$rows) { if ($title !== '') { $rows[] = ['title' => $title, 'url' => $url, 'isCurrent' => false]; } }; if (is_singular()) { $post = get_post(); $pt = get_post_type_object($post->post_type); if ($pt && $pt->has_archive) { $link = get_post_type_archive_link($post->post_type); if ($link) { $push(self::archive_label($pt), (string) $link); } } if ($pt && $pt->hierarchical) { foreach (array_reverse((array) get_post_ancestors($post)) as $anc) { $push((string) get_the_title($anc), (string) get_permalink($anc)); } } else { /* * Keep trying until a term is found; never stop at the first taxonomy. * * With Woo installed, `product_brand` sorts before `product_cat`, and bikes usually have no * brand — trying only the first leaves every product breadcrumb across the site missing its * "series" level, with no error, looking exactly like "our breadcrumbs are two levels". */ $taxes = array_values(array_filter( get_object_taxonomies($post->post_type, 'objects'), fn($t) => $t->public && $t->hierarchical )); $terms = false; foreach ($taxes as $tx) { $found = get_the_terms($post, $tx->name); if ($found && !is_wp_error($found)) { $terms = $found; break; } } if ($terms) { $term = $terms[0]; foreach (array_reverse((array) get_ancestors($term->term_id, $term->taxonomy)) as $anc) { $t = get_term($anc); if ($t && !is_wp_error($t)) { $push((string) $t->name, (string) get_term_link($t)); } } $push((string) $term->name, (string) get_term_link($term)); } } $rows[] = ['title' => (string) get_the_title($post), 'url' => '', 'isCurrent' => true]; return $rows; } if (is_tax() || is_category() || is_tag()) { $term = get_queried_object(); foreach (array_reverse((array) get_ancestors($term->term_id, $term->taxonomy)) as $anc) { $t = get_term($anc); if ($t && !is_wp_error($t)) { $push((string) $t->name, (string) get_term_link($t)); } } $rows[] = ['title' => (string) $term->name, 'url' => '', 'isCurrent' => true]; return $rows; } if (is_post_type_archive()) { /* * `get_queried_object()` here is not necessarily a WP_Post_Type. * WooCommerce's shop archive replaces it with the shop page (a WP_Post), so `->labels->name` is * null — the last breadcrumb level comes out blank, with no error, looking like "that level * did not render". */ $o = get_queried_object(); $title = $o instanceof \WP_Post ? get_the_title($o) : (string) ($o->labels->name ?? ''); $rows[] = ['title' => (string) $title, 'url' => '', 'isCurrent' => true]; return $rows; } if (is_search()) { $rows[] = [ /* translators: %s: what the visitor searched for. */ 'title' => sprintf(__('Search: %s', 'aumframe'), get_search_query()), 'url' => '', 'isCurrent' => true, ]; return $rows; } if (is_404()) { $rows[] = ['title' => __('Not found', 'aumframe'), 'url' => '', 'isCurrent' => true]; return $rows; } if (is_home() && !is_front_page()) { $page = (int) get_option('page_for_posts'); $rows[] = [ 'title' => $page ? (string) get_the_title($page) : __('Blog', 'aumframe'), 'url' => '', 'isCurrent' => true, ]; } return $rows; } public static function post($post): array { $post = get_post($post); if (!$post) { return []; } $thumb_id = get_post_thumbnail_id($post); $thumb_url = $thumb_id ? (wp_get_attachment_image_url($thumb_id, 'large') ?: '') : ''; $row = [ 'id' => $post->ID, 'title' => get_the_title($post), // the_content's filters have to run, or shortcodes and block content stop working entirely 'content' => apply_filters('the_content', $post->post_content), 'excerpt' => self::excerpt($post), 'url' => get_permalink($post), 'date' => get_the_date('', $post), 'author' => [ 'name' => get_the_author_meta('display_name', $post->post_author), 'url' => get_author_posts_url($post->post_author), ], /* * The cover image has a fallback chain: the real featured image -> the theme's default cover * -> the plugin's own placeholder. * * Why a fallback is required: a card in an archive with no image renders ``, which * the browser draws as a broken-image icon — and that looks like the site is broken, far * worse than a neutral placeholder. A client missing a cover or two is inevitable, not an * exceptional case. * * `hasImage` is exposed separately because both "show a fallback" and "hide the whole block when * there is no image" are reasonable: the former is the default here, and the latter is built with * conditional rendering bound to `post.thumbnail.hasImage`. Merging the two into one field would * make the second inexpressible. */ 'thumbnail' => [ 'url' => $thumb_url !== '' ? $thumb_url : self::fallback_image($post->ID), // With no alt in the media library, fall back to the post title — nine times out of ten a client // leaves that box empty, and a real title is far more useful than an empty string 'alt' => self::thumb_alt($thumb_id, $post), 'hasImage' => $thumb_url !== '' ? '1' : '', ], // meta is resolved lazily: Renderer only queries when it meets post.meta.xxx 'meta' => new MetaBag($post->ID), ]; /* * Let others add a namespace to a post row. * * Before this, an extension could only push into `post.meta.*` — which is **the client's own * fields' territory**: a product adapter offering `price` would shadow a client's genuine custom * field called price, with no warning. A prefix (`woo_price`) avoids the collision but welds the * layout to one engine: the same "product card" component comes out empty on another site. * * With this opening, an adapter can attach a neutral `post.product.*` and let each engine * (WooCommerce, an enquiry plugin, the content model) fill the same field names — which is what * makes a component reusable across sites. * * Performance: this filter runs for every row, so anything hooking it must attach lazy objects * only (implementing ArrayAccess as MetaBag does, computing on access) and never query here. */ return apply_filters('aumframe/post_row', $row, $post); } /** * What to use when there is no featured image. * * The theme declares it through `aumframe/fallback_image` (it already has a "default cover" setting, and * storing a second one in the plugin would be wrong) — the same channel as the palette, the content * model and forms. When the theme has none either, it falls back to the plugin's own neutral * placeholder. */ private static function thumb_alt($thumb_id, $post): string { $alt = $thumb_id ? trim((string) get_post_meta($thumb_id, '_wp_attachment_image_alt', true)) : ''; return $alt !== '' ? $alt : (string) get_the_title($post); } private static function fallback_image(int $post_id): string { $url = apply_filters('aumframe/fallback_image', '', $post_id); if (is_string($url) && $url !== '') { return esc_url_raw($url); } return AUMFRAME_URL . 'assets/placeholder.svg'; } public static function term($term): array { if (!$term || is_wp_error($term)) { return []; } /* * Whether this row is the term currently being viewed. * * Without it, no category filter bar can highlight its current item — and a filter bar that does not * say where you are is just a row of links. The previous workaround was "the page's headline says * it", which was a limitation rather than a decision. * * It is `'1'` / `''` rather than a boolean: conditional rendering compares strings * (`notEmpty`/`empty`), consistent with `thumbnail.hasImage`. Two mutually exclusive conditions then * produce "the current item looks different". */ $current = ''; if (is_tax() || is_category() || is_tag()) { $qo = get_queried_object(); if ($qo instanceof \WP_Term && (int) $qo->term_id === (int) $term->term_id) { $current = '1'; } } return [ 'id' => $term->term_id, // The taxonomy name. Nested loops (`inTermOf` / `parentOf`) need it to know which taxonomy to look // in — without it the inner query relies on the caller supplying it again, which is one more debt in // the "where does the user fill this in" ledger. 'taxonomy' => $term->taxonomy, 'name' => $term->name, 'description' => $term->description, 'url' => (string) get_term_link($term), 'count' => $term->count, 'isCurrent' => $current, /* * Terms have fields too — a "series" wanting a cover image and an introduction is an entirely * ordinary need. That path did not exist before: term meta is native to WordPress, and it was * simply not exposed here, so the editor could not bind it and the front end could not read it. * Lazy like post meta: Renderer only queries when it meets term.meta.xxx. */ 'meta' => new TermMetaBag((int) $term->term_id), ]; } private static function excerpt($post): string { if ($post->post_excerpt !== '') { return $post->post_excerpt; } return wp_trim_words(wp_strip_all_tags(strip_shortcodes($post->post_content)), 28); } /** * Run a query defined in scene.queries. * * The configuration is JSON rather than a PHP array literal — so an AI can generate it later, and a * standalone version can implement the same configuration against a different data source. */ public static function run_query(array $q, array $ctx = []): array { /* * Source = a field in the current context (a repeater). * * Spec tables, galleries and timelines are "this post's own list", not something WP_Query can fetch. * Without this, a repeater could be stored and never used — which puts it back to "hard-coded in the * template", and hard-coding is exactly what this set out to eliminate. */ /* * Source = the terms of a taxonomy. * * Sections like "browse by series" or "case studies by industry" want the terms themselves (name, * cover image, link), not the content within them. That path did not exist before — the three series * had to be hard-coded into the layout, and the homepage lost a block every time the client added * one, with nobody to remind them. * * Each row is structurally identical to the term context (meta included), so binding * `series.meta.cover.url` inside a loop and `term.meta.cover.url` on an archive page are the same * thing. */ /* * Source = the breadcrumb. * * It is a list, so it is a query source rather than a binding field. Binding * `breadcrumb.1.title` one path at a time can express neither "an indefinite number of levels" nor * an author's control over separators and the styling of the last level. As a loop, one row per * level, the layout can do whatever it likes. * * Each row: `{title, url, isCurrent}`. The last level's `url` is empty — the current page should not * link to itself (and it is noise for a screen reader). */ if (($q['source'] ?? '') === 'breadcrumb') { return self::breadcrumb(); } /* * Source = a WordPress menu. * * `{ source: 'menu', location: 'primary', as: 'item' }` — or `menu: 'main-nav'` to name one * directly (id, slug or name, whatever `wp_get_nav_menu_object()` accepts). * * # Why this exists * * Before it, a site shell's navigation was a hard-coded array in the scene. That is fine for a * five-item, one-level, all-internal nav and stops being fine the moment a buyer wants a submenu, * an external link, or a category as an item — and it has no answer at all for the multilingual * plugins (WPML / Polylang / GTranslate), because a menu is what they hook. Every builder in * this class (Elementor, Bricks, Oxygen) draws the header itself and still reads its items from a * menu; the container is the designer's, the items are the site owner's. * * Each row: `{title, url, isCurrent, isAncestor, target, description, children, hasChildren}`. * Nest a second Repeater on `field: '.children'` for the submenu. * * "Current" is computed by core, not by comparing ids here. `_wp_menu_item_classes_by_context()` * is what resolves the front page, post type archives, taxonomy archives and the ancestor chain. A * hand-rolled `object_id === get_queried_object_id()` gets the simple case right and silently loses * highlighting on exactly the pages where a nav needs it most. * * No menu assigned returns an empty list, not an error. That is the hook the layout needs: * the shell keeps its authored links in a sibling node conditioned on `.count` being empty, * so a site that never configures a menu still has navigation. Never leave a header that can go blank. * * Items whose target was deleted are dropped. Core keeps the menu item and flags `_invalid`; left * in, they render as a link to nothing in the site header on every page. */ if (($q['source'] ?? '') === 'menu') { return self::menu_rows($q); } if (($q['source'] ?? '') === 'terms') { $tax = (string) ($q['taxonomy'] ?? ''); if ($tax === '' || !taxonomy_exists($tax)) { return []; } $args = [ 'taxonomy' => $tax, 'hide_empty' => !empty($q['hideEmpty']), 'number' => (int) ($q['limit'] ?? 12), 'orderby' => $q['orderby'] ?? 'name', 'order' => strtoupper($q['order'] ?? 'ASC') === 'DESC' ? 'DESC' : 'ASC', ]; /* * Which terms this row's content belongs to — the opposite direction from `inTermOf`. * * `inTermOf` goes "term → the content under it", for "list products grouped by category". What * was always missing is the reverse: printing a small term label on a card (a news card's * section, a product card's series). Every site with cards wants this, and until now there * was no path at all — a post row had no terms, and the `terms` query could only list terms * themselves. The only workaround was not showing it. * * Like `inTermOf`: when the outer loop supplies no content row it returns an empty list, * not "all of them". The latter would dump an entire taxonomy onto every card. */ if (!empty($q['ofRow'])) { $row = $ctx[(string) $q['ofRow']] ?? null; if (!is_array($row) || empty($row['id'])) { return []; } $found = get_the_terms((int) $row['id'], $tax); if (!$found || is_wp_error($found)) { return []; } $rows = []; foreach (array_slice($found, 0, (int) ($q['limit'] ?? 12)) as $term) { $rows[] = self::term($term); } return $rows; } /* * Specific terms, in the order given. * * The order in a "browse by series" section is the product line's order (entry → advanced → * combination), not alphabetical. Before this, only name or count ordering was possible — so * "Sheet and tube" sorted before "Sheet cutting" and looked random. Renaming things to appease * the sort is backwards. */ if (!empty($q['slugs']) && is_array($q['slugs'])) { $ids = []; foreach ($q['slugs'] as $slug) { $t = get_term_by('slug', (string) $slug, $tax); if ($t) { $ids[] = (int) $t->term_id; } } if (!$ids) { return []; } $args['include'] = $ids; $args['orderby'] = 'include'; unset($args['order']); } /* * Depth. This exists because a real product catalogue page is three levels: top-level group * → sub-category → product. Before it, only a flat single level could be listed, so such a page * had to hard-code its structure into the layout — and the client adding one category cost it a * block. * * `parent` names one directly (`''`/`0` = top level only), while `parentOf` takes the term from * the outer loop's current row (for nested loops). With both given, `parentOf` wins because * it is more specific. */ if (!empty($q['parentOf'])) { $row = $ctx[(string) $q['parentOf']] ?? null; if (!is_array($row) || empty($row['id'])) { return []; // The outer loop supplied no term: an empty list, not "all of them" } $args['parent'] = (int) $row['id']; if (!empty($row['taxonomy'])) { $args['taxonomy'] = (string) $row['taxonomy']; } } elseif (array_key_exists('parent', $q)) { $parent = $q['parent']; if (is_numeric($parent)) { $args['parent'] = (int) $parent; } elseif (is_string($parent) && $parent !== '') { $t = get_term_by('slug', $parent, $tax); $args['parent'] = $t ? (int) $t->term_id : -1; // Not found = empty; never degrade to "everything" } else { $args['parent'] = 0; // An empty string or null explicitly means "top level only" } } $terms = get_terms($args); if (is_wp_error($terms)) { return []; } $rows = []; foreach ($terms as $term) { $rows[] = self::term($term); } return $rows; } if (($q['source'] ?? '') === 'field') { $path = (string) ($q['field'] ?? ''); $rows = $path !== '' ? Renderer::resolve_raw($path, $ctx) : null; if (!is_array($rows)) { return []; } // Keep genuine rows (arrays) and filter out scalars — binding the wrong field (to a text field, // say) should produce an empty list rather than rendering a string one character at a time $rows = array_values(array_filter($rows, 'is_array')); /* * `offset` / `limit` slice a group the same way they slice the main query (1.7.79, template #15). * * "The first four house rules on every hire page, all ten on the rules page" is one group field * read twice; before this the only way to show a subset was a second, hand-copied group — two * places to edit, and the buyer edits one. */ if (!empty($q['offset'])) { $rows = array_slice($rows, (int) $q['offset']); } if (!empty($q['limit'])) { $rows = array_slice($rows, 0, (int) $q['limit']); } return $rows; } /* * Use the current request's own query results. * * Without this a search results page cannot list anything at all — the engine would only run a * WP_Query it assembled itself, and "what the user searched for" lives in the main query and cannot * be reconstructed. Archives are the same: querying again bypasses the main query, so **pagination, * ordering parameters and every other plugin's filters on the main query all stop working**, with * the symptom "page two is identical to page one". * * So it is a necessity for search pages and the correct default for archives. */ if (($q['source'] ?? '') === 'main') { $rows = []; global $wp_query; if ($wp_query instanceof \WP_Query) { $posts = $wp_query->posts; /* * On the main query, `offset` and `limit` slice this page's results rather than querying * again. * * Why it is needed: a news or blog listing is almost always "one featured post plus the rest * in a grid". Before this it was one or the other — either use `main` for the whole page (and * no featured post is possible) or assemble a WP_Query (and **pagination, the current * archive's filtering and every other plugin's changes to the main query stop working**, with * the symptom "page two is identical to page one"). Slicing keeps both: still the main query, * with one block taking the first row and another skipping it. * * It slices the current page. On page two the "featured" post becomes page two's first * — if that is not wanted, the featured block needs its own "only on page one" condition. */ if (!empty($q['offset'])) { $posts = array_slice($posts, (int) $q['offset']); } if (!empty($q['limit'])) { $posts = array_slice($posts, 0, (int) $q['limit']); } foreach ($posts as $p) { $rows[] = self::post($p); } } return $rows; } $args = [ 'post_type' => $q['postType'] ?? 'post', 'posts_per_page' => (int) ($q['limit'] ?? 6), 'orderby' => $q['orderby'] ?? 'date', 'order' => strtoupper($q['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC', 'ignore_sticky_posts' => true, 'post_status' => 'publish', 'no_found_rows' => true, ]; /* * Specific posts, in the order given. * * Sections like "featured models" or "three picks on the homepage" want exactly this — and before it, * the only approach was gambling on ordering: wanting to feature S4020 meant making it sort first, so * "featured" and "ordering" were forced together. The term query gained a `slugs` of the same name * last round; this completes the post side. */ if (!empty($q['slugs']) && is_array($q['slugs'])) { $args['post_name__in'] = array_map('strval', $q['slugs']); $args['orderby'] = 'post_name__in'; unset($args['order']); } /* * Taxonomy clauses are collected, then combined with AND (1.7.73, template #9 Halfstroke, G2). * * Until then each of the three sources below — the fixed filter (`taxonomy` + `terms`), `inTermOf` and * `inCurrentTerm` — *assigned* `$args['tax_query']`, so the last one silently replaced the others. A * query for "keycap sets that fit this board's layout" (`inCurrentTerm` on `layout` and * `product_cat = keycaps`) came back as every product in the layout, boards included, with nothing * reported. The same shape on a build page ("boards in this layout" / "keycaps in this layout" under one * outer layout loop) is what a cross-taxonomy catalogue is made of. * * `terms` may be a list (the taxonomy is `taxonomy`, the original shape) or a map * `{ product_cat: ['keycaps'], feel: ['linear'] }` — one clause per taxonomy, and `taxonomy` is then * free to name which taxonomy `inCurrentTerm` follows on a single page. */ $clauses = []; $isMapTerms = false; if (!empty($q['terms']) && (is_array($q['terms']) || is_object($q['terms']))) { $terms = (array) $q['terms']; $isMap = array_keys($terms) !== range(0, count($terms) - 1); $isMapTerms = $isMap; if ($isMap) { foreach ($terms as $tax => $slugs) { $slugs = array_values(array_filter(array_map('strval', (array) $slugs), 'strlen')); if ($tax !== '' && $slugs) { $clauses[] = ['taxonomy' => (string) $tax, 'field' => 'slug', 'terms' => $slugs]; } } } elseif (!empty($q['taxonomy'])) { $clauses[] = ['taxonomy' => (string) $q['taxonomy'], 'field' => 'slug', 'terms' => array_map('strval', $terms)]; } } if (!empty($q['offset'])) { $args['offset'] = (int) $q['offset']; } /* * The reverse reference: who points at me (G1, 2026-09-13). * * `post_ref` only ever walked forward (a project → the finishes it used). The other direction — * "which people list this practice area", "which projects used this finish", "which accessories * fit this product" — had no query at all, and the marchbank kit had to turn practice areas into a * taxonomy to get round it. Now: * * refTo: { field: 'practices', sub: 'area', of: 'post' } * * lists the posts whose group field `practices` has a row whose `area` is the current post * (`of` = 'post' on a single, or an outer loop's row alias; default 'post'). Without `sub` the * field is a plain post_ref and is compared exactly. A group is stored as JSON * (`[{"area":"23697"}]`), so the row match is a LIKE on the `"sub":""` pair — the id is * quoted on both sides, so 23697 never matches 123697. * The usual rule when the target cannot be obtained: an empty list, never "everything". */ if (!empty($q['refTo']) && is_array($q['refTo'])) { $ref = $q['refTo']; $ofKey = (string) ($ref['of'] ?? 'post'); $row = $ctx[$ofKey] ?? null; $id = is_array($row) ? (int) ($row['id'] ?? 0) : 0; $field = (string) ($ref['field'] ?? ''); if ($id <= 0 || $field === '') { return []; } $sub = (string) ($ref['sub'] ?? ''); if (!empty($ref['list'])) { /* * `list: true` (1.7.75, template #11): the referencing field is a flat array of ids rather * than a single post_ref — the shape a plugin keeps for "which staff do this service" * (AumReserva's `aumreserva_assigned_staff`) or any checkbox list. Serialised, the id is either * `i:12;` (saved from the admin as ints) or `s:2:"12";` (imported from a kit as strings), so * both spellings are matched; the delimiters on each side keep 12 from matching 112 or 120. */ $args['meta_query'] = [ 'relation' => 'OR', ['key' => $field, 'value' => 'i:' . $id . ';', 'compare' => 'LIKE'], ['key' => $field, 'value' => '"' . $id . '"', 'compare' => 'LIKE'], ]; } else { $args['meta_query'] = [$sub !== '' ? ['key' => $field, 'value' => '"' . $sub . '":"' . $id . '"', 'compare' => 'LIKE'] : ['key' => $field, 'value' => (string) $id, 'compare' => '=']]; } } /* * Exclude the current post. * * Sections like "related models" or "more in this series" inevitably need it — without it, the * first card at the bottom of a post is the post itself, and clicking it lands on the same page. * This is a universal need rather than one site's special case, so it belongs in the engine instead * of being worked around on every site. */ if (!empty($q['notCurrent']) && is_singular()) { $args['post__not_in'] = [(int) get_the_ID()]; } /* * Restricted to the terms of the outer loop's current row. * * Layouts like "list products grouped by category" (an industrial equipment site's catalogue page, a * library grouped by type) require it. Nested loops themselves have long been supported * (`Renderer::expand_loops` recurses, and the inner loop carries the outer row's context); what was * always missing was this connection point — the inner query could only ask for "a hard-coded * term" or "the current archive's term" and could not reach the outer row. */ if (!empty($q['inTermOf'])) { $row = $ctx[(string) $q['inTermOf']] ?? null; if (!is_array($row) || empty($row['id']) || empty($row['taxonomy'])) { return []; // The outer loop supplied no term: an empty list, rather than emptying the whole site into it } $clauses[] = [ 'taxonomy' => (string) $row['taxonomy'], 'field' => 'term_id', 'terms' => [(int) $row['id']], 'include_children' => !empty($q['includeChildren']), ]; } /* * Follow the current term. Both contexts have to be recognised: * * - an archive page: the current term * - a single page: the terms this post itself belongs to — that is, "other content in the same * category" * * It used to recognise archives only. So a "other models in this series" block on a detail page * had no implementation at all: the only option was excluding itself with `notCurrent`, listing * everything, and giving it a heading that sounded as if it were filtered. The page was complete * and nothing was reported, while that block's promise was false — a Vena colour listing Linea * colours beneath it, noticed only by someone who actually read the heading. * * "Other content in the same category" is something every product or content site needs, and should * not be improvised template by template. */ if (!empty($q['inCurrentTerm'])) { if (is_tax() || is_category() || is_tag()) { $t = get_queried_object(); $clauses[] = [ 'taxonomy' => $t->taxonomy, 'field' => 'term_id', 'terms' => [$t->term_id], ]; } elseif (is_singular()) { /* * Which taxonomy to follow: `followTaxonomy` first; otherwise `taxonomy` — but only when it is * not already spoken for by a list-shaped `terms` filter (the original, single-purpose shape). */ $tax = (string) ($q['followTaxonomy'] ?? ''); if ($tax === '' && !empty($q['taxonomy']) && !(!empty($q['terms']) && !$isMapTerms)) { $tax = (string) $q['taxonomy']; } // With no taxonomy specified, use the first one attached to this content type — most types have // exactly one if ($tax === '') { $taxes = get_object_taxonomies((string) get_post_type(), 'names'); $tax = $taxes ? (string) reset($taxes) : ''; } $ids = $tax !== '' ? wp_get_object_terms(get_queried_object_id(), $tax, ['fields' => 'ids']) : []; if (is_wp_error($ids) || !$ids) { // This post has no terms: an empty list, not "all of them". // The same rule as inTermOf and ofRow — when it cannot be obtained, do not pretend it was. return []; } $clauses[] = [ 'taxonomy' => $tax, 'field' => 'term_id', 'terms' => array_map('intval', $ids), ]; } } if (count($clauses) === 1) { $args['tax_query'] = $clauses; } elseif ($clauses) { $args['tax_query'] = array_merge(['relation' => 'AND'], $clauses); } /* * `meta: { key, value?, compare? }` — filter by one custom field (1.7.77, template #12 G11). * * "The lead piece of each collection", "featured", "on the home page": a toggle the site owner ticks on * one post. Until now a query could not see post meta at all, so "which one is featured" had to be a * sort-order convention (menu_order first) — a setting nobody can find. `compare` is `=` (default), * `!=`, `exists` or `notExists`. `!=` also matches posts that have no such key: a toggle that was * never ticked is "not on", and WordPress alone would drop those rows (a missing key fails `!=`). */ $meta = $q['meta'] ?? null; if (is_array($meta) && !empty($meta['key'])) { $key = (string) $meta['key']; $value = self::meta_value((string) ($meta['value'] ?? '1')); $compare = (string) ($meta['compare'] ?? '='); /* * `type: 'date' | 'number'` (1.7.78, template #13): the comparison is done by WordPress as a string * unless told otherwise. `date` matters for the range compares below (`2026-10-03` vs `2026-9-30` * would sort wrong as text); `number` for capacities and prices stored as digits. */ $type = (string) ($meta['type'] ?? ''); $mtype = $type === 'date' ? 'DATE' : ($type === 'number' ? 'NUMERIC' : null); $clause = static function (array $c) use ($mtype) { return $mtype ? $c + ['type' => $mtype] : $c; }; if ($compare === 'exists') { $args['meta_query'] = [['key' => $key, 'compare' => 'EXISTS']]; } elseif ($compare === 'notExists') { $args['meta_query'] = [['key' => $key, 'compare' => 'NOT EXISTS']]; } elseif ($compare === '!=') { $args['meta_query'] = [ 'relation' => 'OR', $clause(['key' => $key, 'value' => $value, 'compare' => '!=']), ['key' => $key, 'compare' => 'NOT EXISTS'], ]; } elseif (in_array($compare, ['<', '<=', '>', '>='], true)) { /* * Range compares (1.7.78, template #13 G1): "events from today on", "published before 2024", * "capacity above 20". The value may be the token `@today` (resolved on the server, in the site's * timezone, see meta_value()) — the one comparison every diary, programme and news site needs and * that no template could write until now (the only date a query knew was the publish date, and a * future-dated post is not published at all). */ $args['meta_query'] = [$clause(['key' => $key, 'value' => $value, 'compare' => $compare])]; } else { $args['meta_query'] = [$clause(['key' => $key, 'value' => $value, 'compare' => '='])]; } } /* * `orderby: 'meta'` (1.7.78, template #13 G2): sort by that same custom field. Without it a list of * "upcoming events" filtered on a date field still came out in publish order, so the first row was not * the next event. The key is `meta.key`, or `orderMeta` when the sort field is not the filtered one; the * type follows `meta.type` (a date sorts as text correctly in `Y-m-d`, a number needs `meta_value_num`). */ if (($q['orderby'] ?? '') === 'meta') { $orderKey = (string) ($q['orderMeta'] ?? (is_array($meta) ? ($meta['key'] ?? '') : '')); if ($orderKey !== '') { $args['meta_key'] = $orderKey; $args['orderby'] = (($meta['type'] ?? '') === 'number') ? 'meta_value_num' : 'meta_value'; } else { $args['orderby'] = 'date'; } } $rows = []; $query = new \WP_Query($args); foreach ($query->posts as $p) { $rows[] = self::post($p); } wp_reset_postdata(); return $rows; } /** * A query value token. `@today` is the site's current date (`Y-m-d`, in the site's timezone — the same * calendar the site owner set the event date in), so "from today on" is written once and never goes stale. * Anything else passes through unchanged. */ private static function meta_value(string $v): string { if ($v === '@today') { return current_time('Y-m-d'); } if ($v === '@now') { return current_time('Y-m-d H:i:s'); } return $v; } } /** * A lazy bag for page-level queries, so paths like `query..count` resolve. * * Why it exists. * * Numbers like "16 documents" or "6 models" could previously only be typed by hand (or left out) — and a * hand-typed number is wrong the moment the buyer adds one item, silently. The list itself has always been * loopable; all that was missing was "how many rows did this query return". * * The `.count` rule already applies to lists (`post.meta.specs.count`), so putting the query results into * the context means counting needs not one new line of code. * * Lazy: a page may define seven or eight queries with the bindings using none of them. The same shape as * `MetaBag`. * * Page level only. The bag holds the page context, so queries that depend on an outer row * (`inTermOf` / `ofRow` / `source:'field'`) return an empty list here — consistent with them returning empty * at page level anyway, not a new exception. * * A mistyped query id returns null rather than 0: a misspelled id printing "0" is harder to notice * than one printing a placeholder. */ class QueryBag implements \ArrayAccess { private $queries; private $ctx; private $cache = []; public function __construct(array $queries, array $ctx) { $this->queries = $queries; $this->ctx = $ctx; } #[\ReturnTypeWillChange] public function offsetExists($key) { return isset($this->queries[$key]); } #[\ReturnTypeWillChange] public function offsetGet($key) { if (!array_key_exists($key, $this->cache)) { $this->cache[$key] = isset($this->queries[$key]) ? DataContext::run_query($this->queries[$key], $this->ctx) : []; } return $this->cache[$key]; } #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->cache[$key] = $value; } #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->cache[$key]); } } /** * A lazy accessor for meta. * It lets bindings like post.meta.price avoid pulling all meta for every post up front. */ class MetaBag implements \ArrayAccess { private $post_id; private $cache = []; public function __construct(int $post_id) { $this->post_id = $post_id; } #[\ReturnTypeWillChange] public function offsetExists($key) { return true; } #[\ReturnTypeWillChange] public function offsetGet($key) { if (!array_key_exists($key, $this->cache)) { // Providers like ACF, Meta Box or Pods take over from this hook $value = apply_filters('aumframe/meta', null, $this->post_id, $key); $this->cache[$key] = $value !== null ? $value : get_post_meta($this->post_id, $key, true); } return $this->cache[$key]; } #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->cache[$key] = $value; } #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->cache[$key]); } } /** * The term version of MetaBag. * * Separate from the post version rather than taking a $kind parameter: the two have different filter names * (`aumframe/meta` and `aumframe/term_meta`), which is how a provider hooking in can tell which it is taking * over. Merged, "adding fields to terms" would incidentally affect posts. */ class TermMetaBag implements \ArrayAccess { private $term_id; private $cache = []; public function __construct(int $term_id) { $this->term_id = $term_id; } #[\ReturnTypeWillChange] public function offsetExists($key) { return true; } #[\ReturnTypeWillChange] public function offsetGet($key) { if (!array_key_exists($key, $this->cache)) { $value = apply_filters('aumframe/term_meta', null, $this->term_id, $key); $this->cache[$key] = $value !== null ? $value : get_term_meta($this->term_id, $key, true); } return $this->cache[$key]; } #[\ReturnTypeWillChange] public function offsetSet($key, $value) { $this->cache[$key] = $value; } #[\ReturnTypeWillChange] public function offsetUnset($key) { unset($this->cache[$key]); } }