which template. * * The decision centre for full-page takeover (see project docs 5.0). * The key point: one template covers unlimited URLs — a single "all posts" rule takes over every post * on the site; it is not one template per post. * * Matching no template means the URL goes back to the theme and we do not interfere at all. */ class Router { /** @var array|null|false null = not yet computed, false = not taking over */ private static $matched = null; /** A description of the current request's context, which the rules match against. */ /** * The post id a template rule should be matched against. * * A rule that binds a template to particular content stores `post_ids`, and the ids belong to the * site the kit was imported into. A multilingual plugin breaks that assumption: the German About * page is a *different post* with a different id and slug, so `post_ids: [12]` stops matching and the * page falls through to whatever generic rule is left — the buyer translates a page and its design * disappears, with no error. (Measured 2026-09-04 against AumLang: `/about/` rendered `page-about`, * its translation rendered `singular-fallback`.) * * A template is chosen by which piece of content this is, and a translation is the same piece of * content — so the answer is to match on the canonical (source) id. * * The engine must not know any multilingual plugin by name. It asks; whoever integrates * answers. The bridge for our own stack lives in the theme, next to the WooCommerce and AumReserva * ones. With nothing listening this returns the id unchanged, which is the single-language * behaviour and must stay exactly that. * * @param int $post_id The queried post id. * @return int Canonical id to match rules against. */ private static function template_post_id(int $post_id): int { /** * Filters the post id used to match template rules. * * Return the id of the original content when the queried post is a translation of it. * * @param int $post_id The queried post id. */ $resolved = (int) apply_filters('aumframe/template_post_id', $post_id); return $resolved > 0 ? $resolved : $post_id; } public static function context(): array { if (is_404()) { return ['kind' => 'notfound']; } if (is_search()) { return ['kind' => 'search']; } if (is_front_page()) { return ['kind' => 'front_page']; } if (is_singular()) { return [ 'kind' => 'singular', 'post_type' => get_post_type() ?: '', 'post_id' => self::template_post_id((int) (get_the_ID() ?: 0)), ]; } if (is_post_type_archive()) { return ['kind' => 'archive', 'post_type' => (string) get_query_var('post_type')]; } if (is_tax() || is_category() || is_tag()) { $term = get_queried_object(); $tax = $term->taxonomy ?? ''; /* * A term archive also reports which content types the taxonomy is attached to. * * Without it, "the bike archive" and "one series" would need two templates with **identical * content** — one keyed on post_type and one on taxonomy — and a redesign would mean editing * both. Reporting it lets a single { kind:'archive', post_type:'ebike' } cover both /bikes/ * and /series/city/; a special layout for one series is then a more specific rule (with a * taxonomy) at a higher priority. */ return [ 'kind' => 'archive', 'taxonomy' => $tax, 'term_id' => $term->term_id ?? 0, 'post_types' => $tax ? (array) (get_taxonomy($tax)->object_type ?? []) : [], ]; } if (is_home()) { return ['kind' => 'archive', 'post_type' => 'post']; } /* * Author archives are not an archive here — they are the theme's profile page. * * `author.php` is a thousand-line social profile (avatar, bio, follow, liked / favourited / * following tabs, edit profile, the `aumcreate_author_tabs` hook). Reporting `is_author()` * as `kind: archive` let every kit's generic `archive-fallback` template ({kind:'archive'}, * priority 90) take it over, and a buyer's author page came out as a bare title-and-date * list with the whole profile gone (2026-09-14, spotted by the user on template #8). * The shell still wraps it: the theme's header.php / footer.php render the kit's shell * parts, same as the cart and account pages. A kit that really wants its own author page * can still take everything with `{kind:'any'}`. */ if (is_author()) { return ['kind' => 'other']; } if (is_date()) { return ['kind' => 'archive']; } return ['kind' => 'other']; } /** @return array|null the matched template row; null = not taking over */ public static function match(): ?array { if (self::$matched !== null) { return self::$matched ?: null; } $ctx = self::context(); foreach (Storage::published() as $tpl) { if ($tpl['kind'] === 'shell') { continue; // The shell is fetched separately and takes no part in content matching } $rules = json_decode((string) $tpl['rules'], true) ?: []; if (self::rules_match($rules, $ctx)) { /* * The last veto: somebody else may already have decided to take this request over. * * A real case: WooCommerce's "coming soon" calls `get_header()` and starts outputting * at `template_include` priority 10 — Aumframe's filter (999) has not run yet, but * `wp_head` has already fired, and we duly injected the page's full baked CSS (20KB) * into a page we were never going to render. **Matching a rule is not the same as * rendering.** * * Conflicts like this should not be resolved by building knowledge of each plugin into * this one (that is "orchestrating third-party plugins", explicitly out of scope) but by * leaving an opening for the integrator to veto. */ if (!apply_filters('aumframe/should_take_over', true, $tpl, $ctx)) { self::$matched = false; return null; } $tpl = Storage::localize($tpl); self::$matched = $tpl; return $tpl; } } self::$matched = false; return null; } /** The site shell (header + footer). Takes the highest-priority one. */ public static function shell(): ?array { $shells = Storage::published('shell'); // The shell is the header and footer of every page — it has to follow the language too. return $shells ? Storage::localize($shells[0]) : null; } /** * Rule matching. Empty rules match nothing (so a site is never taken over by accident). * * Rules look like: * { "kind": "singular", "post_type": "post" } * { "kind": "archive", "taxonomy": "category" } * { "kind": "singular", "post_type": "page", "post_ids": [12, 34] } * { "kind": "any" } ← takes over everything (use with care) */ /** * public because `PageLink::template_for()` needs it, not because it is generally open. * * That code answers "does this post already have an Aumframe design", and it used to match on * `post_ids` itself — so the homepage and post pages were always judged "not yet" (their rules * are `{kind:front_page}` and `{kind:archive}`, with no post_ids at all). * The consequence: a buyer pressing "design this with Aumframe" on the homepage got a blank * template and the editor, spent an afternoon laying it out, saved, and the front end did not move. * "Who renders this content" has exactly one correct answer — the router's own rules — and an * approximation of them must not be duplicated elsewhere. */ public static function rules_match(array $rules, array $ctx): bool { if (!$rules) { return false; } if (($rules['kind'] ?? '') === 'any') { return true; } if (($rules['kind'] ?? '') !== ($ctx['kind'] ?? '')) { return false; } // taxonomy has a single value, so compare directly if (!empty($rules['taxonomy']) && ($ctx['taxonomy'] ?? '') !== $rules['taxonomy']) { return false; } // post_type has two sources: a single post or a content-type archive reports one value, while // a term archive reports "which content types this taxonomy is attached to" (possibly several) if (!empty($rules['post_type'])) { $ctx_types = !empty($ctx['post_types']) ? (array) $ctx['post_types'] : [$ctx['post_type'] ?? '']; if (!in_array($rules['post_type'], $ctx_types, true)) { return false; } } /* * post_ids present but empty means the author chose "bind to a specific page" and has not * picked one yet. That case must match nothing at all. * * It used to "skip the condition when empty", so a half-filled binding rule quietly became * taking over every post of that type — the user selected "a single page" in the editor, * left the page ID blank, saved, and every page on the site was displaced by a template that * was not even finished, with nothing in the interface telling them it had happened. The most * alarming class of accident: enormous blast radius, entirely ordinary trigger, completely * silent. * * The way to express "all pages" is to omit the post_ids key entirely — unambiguous, and * necessarily deliberate. */ if (array_key_exists('post_ids', $rules)) { $ids = array_map('intval', (array) $rules['post_ids']); if (!$ids || !in_array((int) ($ctx['post_id'] ?? 0), $ids, true)) { return false; } } // As above: term_ids present but empty means "not finished picking", not "all of them" if (array_key_exists('term_ids', $rules)) { $ids = array_map('intval', (array) $rules['term_ids']); if (!$ids || !in_array((int) ($ctx['term_id'] ?? 0), $ids, true)) { return false; } } return true; } /** For testing and previews: force a specific template, bypassing rule matching. */ public static function force(array $tpl): void { self::$matched = $tpl; } }