query('//*[@data-neo-shared]') as $node) { $key = $node->getAttribute('data-neo-shared'); if ($key !== '') { $found[$key] = self::innerHtml($node); } } return $found; } /** Replace every region marked data-neo-shared="$key"; null when the page has none. */ public static function withShared(string $html, string $key, string $content): ?string { if (!str_contains($html, 'data-neo-shared')) { return null; } $dom = self::load($html); // $key is restricted to [a-zA-Z0-9_-] by the caller, so it cannot break out of the XPath string. $nodes = (new \DOMXPath($dom))->query('//*[@data-neo-shared="' . $key . '"]'); if ($nodes->length === 0) { return null; } foreach ($nodes as $node) { self::replaceInnerHtml($dom, $node, $content); } return $dom->saveHTML(); } /** * Replace the list inside every element marked data-neo-menu="$name", keeping the element's own attributes * and every other byte of the page. Null when nothing needs to change. */ public static function withMenu(string $html, string $name, array $items, string $uri = '', string $basePath = ''): ?string { if (!str_contains($html, 'data-neo-menu')) { return null; } $list = self::menuList($items, $uri, $basePath); $flat = fn(string $s) => preg_replace('/>\s+<', trim($s)); $edits = []; foreach (SiteAnalyser::navs($html) as $nav) { if ($nav['menu'] !== $name || $nav['close'] === null) { continue; } if ($flat(substr($html, $nav['end'], $nav['close'] - $nav['end'])) !== $flat($list)) { $edits[] = [$nav['end'], $nav['close'] - $nav['end']]; } } rsort($edits); foreach ($edits as [$offset, $length]) { $html = substr($html, 0, $offset) . $list . substr($html, $offset + $length); } return $edits ? $html : null; } /** A menu as a standalone