- 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
466 lines
15 KiB
PHP
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;
|
|
}
|
|
}
|