` wrapping header + Slot + footer. Cutting at the
* Slot leaves the top half with an unclosed `
` and the bottom half with a stray `
`.
* Emitting that directly puts the page content inside `.af-root` — and BASE_CSS contains
* `:where(.af-root p,h1..h4){margin:0}` and
* `:where(.af-root a){color:inherit;text-decoration:none}`. The checkout page would lose all its paragraph
* spacing, and its links their colour and underline.
*
* So the top half's unclosed tags are closed one by one and reopened verbatim in the bottom half — the
* page content then sits between two `.af-root` elements rather than inside either.
*
* @return array{head:string,foot:string}|null null when there is no shell, or the shell has no Slot
*/
public static function shell_parts(): ?array
{
// Caching is required: header.php and footer.php each call this once, and without it the entire
// shell (queries and shortcodes included) would render twice.
static $cache = false;
if ($cache !== false) {
return $cache;
}
$cache = null;
$shell = Router::shell();
if (!$shell) {
return null;
}
$html = self::render_baked(
(string) $shell['baked_html'],
self::queries_of($shell),
DataContext::current()
);
if (!preg_match('/.*?/s', $html, $m, PREG_OFFSET_CAPTURE)) {
// The shell has no Slot — it was never meant to wrap page content, so do not guess
return null;
}
$cut = (int) $m[0][1];
$head = substr($html, 0, $cut);
$foot = substr($html, $cut + strlen((string) $m[0][0]));
$open = self::unclosed_tags($head);
foreach (array_reverse($open) as $t) {
$head .= '' . $t['name'] . '>';
}
foreach ($open as $t) {
$foot = $t['raw'] . $foot;
}
/*
* The responsive-images pass runs once on each half.
* The cost is that "the first few are not lazy-loaded" is counted separately in each — and a shell has
* very few images (usually one logo), so the effect is negligible. The full-page path is unaffected.
*/
$cache = [
'head' => self::add_responsive_images($head),
'foot' => self::add_responsive_images($foot),
];
return $cache;
}
/**
* The tags left unclosed in a block of HTML, in the order they were opened.
*
* Each entry is `['name' => 'div', 'raw' => '
']` — reopening has to carry the original
* attributes, or `.af-root`'s classes and styles are lost.
*
* @return array
*/
private static function unclosed_tags(string $html): array
{
// Self-closing elements do not go on the stack
static $void = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
'link', 'meta', 'param', 'source', 'track', 'wbr'];
$stack = [];
if (!preg_match_all('/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?(\/?)>/s', $html, $all, PREG_SET_ORDER)) {
return [];
}
foreach ($all as $t) {
$name = strtolower($t[2]);
if ($t[1] === '/') {
// A closing tag: pop until the matching one (tolerating tags that were left unclosed)
for ($i = count($stack) - 1; $i >= 0; $i--) {
if ($stack[$i]['name'] === $name) {
$stack = array_slice($stack, 0, $i);
break;
}
}
continue;
}
if (in_array($name, $void, true) || $t[3] === '/') {
continue;
}
$stack[] = ['name' => $name, 'raw' => $t[0]];
}
return $stack;
}
/** The CSS this request needs (the template plus the shell). */
public static function css(): string
{
$css = '';
$tpl = Router::match();
if ($tpl) {
$css .= (string) $tpl['baked_css'];
}
$shell = Router::shell();
if ($shell) {
$css .= (string) $shell['baked_css'];
}
/*
* Which components this request needs.
*
* A component's stylesheet covers its own nodes only, so a component used solely
* inside another one is reached by following the markers in that one's markup. The
* walk continues until no new slug appears, and `$seen` ends it on a cycle.
*/
$pending = [];
foreach ([$tpl, $shell] as $src) {
if (!$src) {
continue;
}
if (preg_match_all('/