Files
sm-sitemap-plugin/tests/php/gen-goldens.php
Jakub Zych 4a82240429 feat(17-03): full sitemap generation, settings, hooks and import commands
- generator in Definition::addItemToSet order: 50000 URL cap, lastmod from
  the record or the updates manifest (regular file, 5 MiB cap, fail safe),
  BeforeAddItem (skip) and AddItem (extra XML) festival events, tier or item
  priority, per-alternate expansion of list type children
- priority tiers from golem15.sitemap.priority_tiers, compiled at boot,
  defaults equal to the PHP getPriorityForUrl rules
- frontend_url rewrite of loc, xhtml:link href and the stylesheet href;
  GET /sitemap.xsl serves the PHP stylesheet byte for byte
- golem15_sitemap_settings singleton and Settings model
- sitemap:import (PHP ImportCommand) and sitemap:import-winter with the
  read-only Winter export SQL
- urlto and full goldens generated from PHP; TestSitemapImportSmoke
2026-10-12 17:07:39 +02:00

466 lines
15 KiB
PHP

<?php
/**
* Golden generator for the Go sitemap port.
*
* Runs the PHP Golem15.Sitemap generation code (Definition::generateSitemap,
* getXmlObject, getUrlSet, addItemToSet, lookupManifestLastmod,
* getPriorityForUrl and the routes.php frontend_url rewrite, copied below
* with the Winter facades replaced by the case's inputs) and the vendored
* Winter URL generator (Url::to, Url::buildUrl) over a JSON input, and
* writes the bytes PHP produces. The Go tests compare their output with the
* committed goldens, so CI needs no PHP.
*
* Usage (from the plugin root, with a WinterCMS vendor directory):
*
* WINTER_VENDOR=/path/to/winter-app/vendor \
* php tests/php/gen-goldens.php classes/testdata/tracer.json classes/testdata/tracer.golden
*
* Input shapes:
*
* {"case": {...}} one sitemap case; the golden is its XML
* {"cases": [{"name": ..., ...}]} several cases; each written as
* "=== name\n" followed by its XML
* {"root": ..., "urls": [...]} Url::to of each input, one per line
*
* A sitemap case: root (the backend root URL), items (stored definition
* items, PHP DefinitionItem keys), types (registry item type => reference =>
* resolved item {url, mtime, alternates: [[locale, url], ...], items}),
* settings (Sitemap settings values; absent keys take the PHP defaults),
* manifest (the updates manifest object), skip (URLs a beforeAddItem
* listener drops) and extra (URL => text of an <extra> element an addItem
* listener appends).
*
* A registry type's resolved item is given in the Go shape (one item per
* record with ordered alternates); this script turns it into what the PHP
* headless resolvers returned: a list type (no url) yields one entry per
* alternate for each child (a child without alternates once), any other type
* its children with their default URL.
*/
$vendor = getenv('WINTER_VENDOR');
if (!$vendor || !is_file($vendor . '/autoload.php')) {
fwrite(STDERR, "set WINTER_VENDOR to the vendor directory of a WinterCMS application\n");
exit(2);
}
require $vendor . '/autoload.php';
use Illuminate\Http\Request;
use Illuminate\Routing\RouteCollection;
use Winter\Storm\Router\UrlGenerator;
date_default_timezone_set('UTC');
if ($argc !== 3) {
fwrite(STDERR, "usage: php gen-goldens.php INPUT.json OUTPUT.golden\n");
exit(2);
}
$input = json_decode(file_get_contents($argv[1]), true);
if (!is_array($input)) {
fwrite(STDERR, "invalid JSON input\n");
exit(1);
}
function urlGenerator(string $root): UrlGenerator
{
return new UrlGenerator(new RouteCollection, Request::create(rtrim($root, '/') . '/sitemap.xml'));
}
if (isset($input['urls'])) {
$url = urlGenerator($input['root']);
$out = '';
foreach ($input['urls'] as $path) {
$out .= $url->to($path) . "\n";
}
file_put_contents($argv[2], $out);
exit(0);
}
if (isset($input['case'])) {
file_put_contents($argv[2], (new GoldenSitemap($input['case']))->render());
exit(0);
}
$out = '';
foreach ($input['cases'] as $case) {
$out .= '=== ' . $case['name'] . "\n" . (new GoldenSitemap($case))->render();
}
file_put_contents($argv[2], $out);
/**
* Golem15\Sitemap\Classes\DefinitionItem, unchanged properties.
*/
class GoldenDefinitionItem
{
public $changefreq;
public $priority;
public $nesting = false;
public $type = 'url';
public $url = null;
public $code = null;
public $reference = null;
public $cmsPage = null;
public $urlPattern = null;
public $exists = false;
public static function initFromArray(array $items): array
{
$result = [];
foreach ($items as $itemData) {
$obj = new self;
foreach ($itemData as $name => $value) {
if (property_exists($obj, $name)) {
$obj->$name = $value;
}
}
$result[] = $obj;
}
return $result;
}
}
/**
* The generation code of Golem15\Sitemap\Models\Definition and routes.php.
*/
class GoldenSitemap
{
const MAX_URLS = 50000;
protected $case;
protected $url;
protected $items;
protected $updatesManifest = null;
protected $urlCount = 0;
protected $urlSet;
protected $xmlObject;
public function __construct(array $case)
{
$this->case = $case;
$this->url = urlGenerator($case['root']);
$this->items = GoldenDefinitionItem::initFromArray($case['items'] ?? []);
}
protected function setting(string $key, $default)
{
$settings = $this->case['settings'] ?? [];
return array_key_exists($key, $settings) ? $settings[$key] : $default;
}
/** routes.php */
public function render(): string
{
$xml = $this->generateSitemap();
$frontendUrl = $this->setting('frontend_url', null);
if ($frontendUrl) {
$frontendUrl = rtrim($frontendUrl, '/');
$backendUrl = rtrim($this->url->to('/'), '/');
$xml = preg_replace_callback('/<loc>(.*?)<\/loc>/', function ($m) use ($backendUrl, $frontendUrl) {
return '<loc>' . str_replace($backendUrl, $frontendUrl, $m[1]) . '</loc>';
}, $xml);
$xml = preg_replace_callback('/(<xhtml:link[^>]*href=")([^"]*)(")/', function ($m) use ($backendUrl, $frontendUrl) {
return $m[1] . str_replace($backendUrl, $frontendUrl, $m[2]) . $m[3];
}, $xml);
$xml = str_replace(
'href="' . $backendUrl . '/sitemap.xsl"',
'href="' . $frontendUrl . '/sitemap.xsl"',
$xml
);
}
return $xml;
}
/**
* What the PHP headless resolvers returned for a resolved item in the
* Go shape.
*/
protected function phpResolved(array $r, bool $listChildren): array
{
$info = [];
if (!empty($r['url'])) {
$info['url'] = $r['url'];
}
if (array_key_exists('mtime', $r)) {
$info['mtime'] = $r['mtime'] === null ? null : new DateTime($r['mtime']);
}
$links = [];
foreach ($r['alternates'] ?? [] as [$locale, $href]) {
$links[$locale] = $href;
}
$info['alternateLinks'] = $links;
if (isset($r['items'])) {
$info['items'] = [];
foreach ($r['items'] as $child) {
$childInfo = $this->phpResolved($child, false);
if ($listChildren && $childInfo['alternateLinks']) {
foreach ($childInfo['alternateLinks'] as $locale => $href) {
$perLocale = $childInfo;
$perLocale['url'] = $href;
$info['items'][] = $perLocale;
}
} else {
$info['items'][] = $childInfo;
}
}
}
return $info;
}
protected function resolveItem(GoldenDefinitionItem $item)
{
$types = $this->case['types'] ?? [];
$ref = (string) $item->reference;
if (!isset($types[$item->type][$ref])) {
return null;
}
$r = $types[$item->type][$ref];
return $this->phpResolved($r, empty($r['url']));
}
public function generateSitemap(): string
{
if (!$this->items) {
return '';
}
foreach ($this->items as $item) {
if ($item->type === 'url') {
$this->addItemToSet($item);
} else {
$menuItemInfo = $this->resolveItem($item);
if (!is_array($menuItemInfo)) {
continue;
}
if (isset($menuItemInfo['url'])) {
$this->addItemToSet($item, $menuItemInfo);
}
if (isset($menuItemInfo['items'])) {
$menuItemIterator = function ($menuItems) use (&$menuItemIterator, $item) {
foreach ($menuItems as $menuItem) {
if (isset($menuItem['url'])) {
$this->addItemToSet($item, $menuItem);
}
if (isset($menuItem['items'])) {
$menuItemIterator($menuItem['items']);
}
}
};
$menuItemIterator($menuItemInfo['items']);
}
}
}
$urlSet = $this->getUrlSet();
$xml = $this->getXmlObject();
$xml->appendChild($urlSet);
return $xml->saveXML();
}
protected function getXmlObject(): DOMDocument
{
if ($this->xmlObject !== null) {
return $this->xmlObject;
}
$xml = new DOMDocument;
$xml->encoding = 'UTF-8';
$xss = $xml->createProcessingInstruction('xml-stylesheet',
'type="text/xsl" href="' . UrlGenerator::buildUrl($this->url->current(), ['path' => 'sitemap.xsl']) . '"'
);
$xml->appendChild($xss);
return $this->xmlObject = $xml;
}
protected function getUrlSet(): DOMElement
{
if ($this->urlSet !== null) {
return $this->urlSet;
}
$xml = $this->getXmlObject();
$urlSet = $xml->createElement('urlset');
$urlSet->setAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
$urlSet->setAttribute('xmlns:xhtml', 'http://www.w3.org/1999/xhtml');
$urlSet->setAttribute('xmlns:xsi', 'http://www.w3.org/2001/XMLSchema-instance');
$urlSet->setAttribute('xsi:schemaLocation', 'http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd');
return $this->urlSet = $urlSet;
}
protected function addItemToSet(GoldenDefinitionItem $item, ?array $itemInfo = null): void
{
$xml = $this->getXmlObject();
$urlSet = $this->getUrlSet();
if ($this->urlCount >= self::MAX_URLS) {
return;
}
if (!isset($itemInfo['url'])) {
$itemInfo['url'] = $this->url->to($item->url);
}
$lastModified = $itemInfo['mtime'] ?? null;
if ($lastModified === null) {
$lastModified = $this->lookupManifestLastmod($itemInfo['url']);
}
if ($lastModified instanceof \DateTime) {
$lastModified = $lastModified->getTimestamp();
}
$itemInfo['lastModified'] = $lastModified ? date('c', $lastModified) : null;
// golem15.sitemap.beforeAddItem: a listener that drops the case's
// skip URLs.
if (in_array($itemInfo['url'], $this->case['skip'] ?? [], true)) {
return;
}
if (empty($itemInfo['url'])) {
return;
} else {
$itemInfo['url'] = $this->url->to($itemInfo['url']);
}
$this->urlCount++;
$tierPriority = $this->getPriorityForUrl($itemInfo['url']);
$effectivePriority = $tierPriority ?? $item->priority;
$urlElement = $xml->createElement('url');
$loc = $xml->createElement('loc');
$loc->appendChild($xml->createTextNode($itemInfo['url']));
$urlElement->appendChild($loc);
if (!empty($itemInfo['lastModified'])) {
$lastmod = $xml->createElement('lastmod');
$lastmod->appendChild($xml->createTextNode((string) $itemInfo['lastModified']));
$urlElement->appendChild($lastmod);
}
$changefreq = $xml->createElement('changefreq');
$changefreq->appendChild($xml->createTextNode((string) $item->changefreq));
$urlElement->appendChild($changefreq);
$priority = $xml->createElement('priority');
$priority->appendChild($xml->createTextNode((string) $effectivePriority));
$urlElement->appendChild($priority);
if (!empty($itemInfo['alternateLinks']) && count($itemInfo['alternateLinks']) > 1) {
foreach ($itemInfo['alternateLinks'] as $hreflang => $href) {
$link = $xml->createElement('xhtml:link');
$link->setAttribute('rel', 'alternate');
$link->setAttribute('hreflang', $hreflang);
$link->setAttribute('href', $this->url->to($href));
$urlElement->appendChild($link);
}
}
$urlSet->appendChild($urlElement);
// golem15.sitemap.addItem: a listener that appends the case's extra
// element to the URL.
$extra = $this->case['extra'] ?? [];
if (isset($extra[$itemInfo['url']])) {
$element = $xml->createElement('extra');
$element->appendChild($xml->createTextNode($extra[$itemInfo['url']]));
$urlElement->appendChild($element);
}
}
protected function lookupManifestLastmod(string $url): ?int
{
$manifest = $this->getUpdatesManifest();
if (!$manifest) {
return null;
}
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$path = $this->normalizeManifestPath($path);
if (!isset($manifest[$path])) {
return null;
}
$timestamp = strtotime((string) $manifest[$path]);
return $timestamp ?: null;
}
protected function getUpdatesManifest(): array
{
if ($this->updatesManifest !== null) {
return $this->updatesManifest;
}
$decoded = $this->case['manifest'] ?? null;
if (!is_array($decoded)) {
return $this->updatesManifest = [];
}
$normalized = [];
foreach ($decoded as $key => $value) {
$normalized[$this->normalizeManifestPath((string) $key)] = $value;
}
return $this->updatesManifest = $normalized;
}
protected function normalizeManifestPath(string $path): string
{
$path = '/' . ltrim($path, '/');
return $path === '/' ? '/' : rtrim($path, '/');
}
protected function getPriorityForUrl(string $url): ?string
{
if (!$this->setting('enable_priority_tiers', true)) {
return null;
}
$path = parse_url($url, PHP_URL_PATH) ?: '/';
$path = rtrim($path, '/');
if ($path === '' || $path === '/en') {
return $this->setting('priority_homepage', '1.0');
}
if (preg_match('#^/(uslugi|en/services|realizacje|en/work)/[^/]+$#', $path)) {
return $this->setting('priority_services', '0.8');
}
if (preg_match('#^/(blog|en/blog)/[^/]+/[^/]+$#', $path)) {
return $this->setting('priority_blog', '0.6');
}
if (preg_match('#^/(uslugi|en/services|realizacje|en/work|blog|en/blog)(/[^/]+)?$#', $path)) {
return $this->setting('priority_categories', '0.4');
}
$legalPaths = [
'/polityka-prywatnosci', '/en/privacy-policy',
'/regulamin', '/en/terms',
'/o-nas', '/en/about',
'/kontakt', '/en/contact',
'/laboratorium-ai', '/en/ai-lab',
'/laboratorium-ai/zabawa-animacja', '/en/ai-lab/blob-playground',
];
if (in_array($path, $legalPaths)) {
return $this->setting('priority_legal', '0.2');
}
return null;
}
}