Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -4,95 +4,271 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Sitemaps\BuildSitemapReleaseJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Sitemaps\SitemapBuildService;
|
||||
use App\Services\Sitemaps\SitemapPublishService;
|
||||
use App\Services\Sitemaps\SitemapImage;
|
||||
use App\Services\Sitemaps\SitemapIndexItem;
|
||||
use App\Services\Sitemaps\SitemapUrl;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class BuildSitemapsCommand extends Command
|
||||
{
|
||||
protected $signature = 'skinbase:sitemaps:build
|
||||
{--only=* : Limit the build to one or more sitemap families}
|
||||
{--release= : Override the generated release id}
|
||||
{--shards : Show per-shard output in the command report}
|
||||
{--queue : Dispatch the release build to the queue}
|
||||
{--force : Accepted for backward compatibility; release builds are always fresh}
|
||||
{--clear : Accepted for backward compatibility; release builds are isolated}
|
||||
{--dry-run : Build a release artifact set without activating it}';
|
||||
{--only=* : Limit to specific sitemap families (comma or space separated)}
|
||||
{--disk= : Override the target filesystem disk (default: sitemaps.static_publish.disk)}
|
||||
{--progress : Show a progress bar tracking processed URLs for each family}';
|
||||
|
||||
protected $description = 'Build a versioned sitemap release artifact set.';
|
||||
protected $description = 'Build all sitemaps and write them as static .xml files to public/.';
|
||||
|
||||
public function handle(SitemapBuildService $build, SitemapPublishService $publish): int
|
||||
public function handle(SitemapBuildService $build): int
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
$releaseId = ($value = $this->option('release')) !== null && trim((string) $value) !== '' ? trim((string) $value) : null;
|
||||
$totalStart = microtime(true);
|
||||
$families = $this->selectedFamilies($build);
|
||||
|
||||
if ($families === []) {
|
||||
$this->error('No valid sitemap families were selected.');
|
||||
$this->error('No valid sitemap families selected.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$showShards = (bool) $this->option('shards');
|
||||
$diskName = (string) ($this->option('disk') ?: config('sitemaps.static_publish.disk', 'sitemaps_public'));
|
||||
$disk = Storage::disk($diskName);
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
if ((bool) $this->option('queue')) {
|
||||
BuildSitemapReleaseJob::dispatch($families, $releaseId);
|
||||
$this->info('Queued sitemap release build' . ($releaseId !== null ? ' for [' . $releaseId . '].' : '.'));
|
||||
$this->info('Disk: ' . $diskName);
|
||||
$this->info('Families: ' . implode(', ', $families));
|
||||
$this->newLine();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = $publish->buildRelease($families, $releaseId);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$totalUrls = 0;
|
||||
$totalDocuments = 0;
|
||||
// ── Root sitemap index ────────────────────────────────────────────
|
||||
$t = microtime(true);
|
||||
$this->line(' Building sitemap index…');
|
||||
$index = $build->buildIndex(force: true, persist: false, families: $families);
|
||||
$disk->put('sitemap.xml', $index['content']);
|
||||
$written++;
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> sitemap.xml %d entries <comment>%.3fs</comment>',
|
||||
$index['url_count'],
|
||||
microtime(true) - $t,
|
||||
));
|
||||
|
||||
// ── Per-family documents ──────────────────────────────────────────
|
||||
foreach ($families as $family) {
|
||||
$names = (array) data_get($manifest, 'families.' . $family . '.documents', []);
|
||||
$familyUrls = 0;
|
||||
$familyStart = microtime(true);
|
||||
|
||||
if (! $showShards) {
|
||||
$this->line('Building family [' . $family . '] with ' . count($names) . ' document(s).');
|
||||
$this->newLine();
|
||||
|
||||
if ($family === 'artworks') {
|
||||
// Direct MySQL path — no cursor-scan shard window computation
|
||||
[$shardNames, $fw, $ff] = $this->buildArtworksDirect($disk, (bool) $this->option('progress'));
|
||||
$written += $fw;
|
||||
$failed += $ff;
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>artworks</> done %d file(s) <comment>%.3fs</comment>',
|
||||
$fw,
|
||||
microtime(true) - $familyStart,
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($names as $name) {
|
||||
$documentType = str_ends_with((string) $name, '-index') ? 'index' : ((string) $family === (string) config('sitemaps.news.google_variant_name', 'news-google') ? 'google-news' : 'urlset');
|
||||
$familyUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalUrls += (int) data_get($manifest, 'families.' . $family . '.url_count', 0);
|
||||
$totalDocuments++;
|
||||
$names = $build->canonicalDocumentNamesForFamily($family);
|
||||
|
||||
if ($showShards || ! str_contains((string) $name, '-000')) {
|
||||
$this->line(sprintf(' <fg=cyan>%s</> (%d document(s))', $family, count($names)));
|
||||
|
||||
foreach ($names as $documentName) {
|
||||
$t = microtime(true);
|
||||
$this->line(sprintf(' Building %s…', $documentName));
|
||||
|
||||
$built = $build->buildNamed($documentName, force: true, persist: false);
|
||||
|
||||
if ($built === null) {
|
||||
$this->line(sprintf(' <comment>–</comment> %s <fg=red>SKIPPED</> (builder returned null)', $documentName));
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->put('sitemaps/' . $documentName . '.xml', $built['content']);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
|
||||
$documentName . '.xml',
|
||||
$built['url_count'] ?? 0,
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>%s</> done <comment>%.3fs</comment>',
|
||||
$family,
|
||||
microtime(true) - $familyStart,
|
||||
));
|
||||
}
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────────
|
||||
$this->newLine();
|
||||
$this->info(sprintf(
|
||||
'Done: %d file(s) written, %d failed total <comment>%.3fs</comment>',
|
||||
$written,
|
||||
$failed,
|
||||
microtime(true) - $totalStart,
|
||||
));
|
||||
|
||||
return $failed > 0 ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream artworks directly from MySQL using chunkById — avoids cursor-scan shard windows.
|
||||
*
|
||||
* @return array{0: list<string>, 1: int, 2: int} [shardNames, written, failed]
|
||||
*/
|
||||
private function buildArtworksDirect(Filesystem $disk, bool $showProgress = false): array
|
||||
{
|
||||
$chunkSize = max(1, (int) config('sitemaps.shards.artworks.size', 10_000));
|
||||
$padLen = max(1, (int) config('sitemaps.shards.zero_pad_length', 4));
|
||||
$shardNum = 0;
|
||||
$shardNames = [];
|
||||
$written = 0;
|
||||
$failed = 0;
|
||||
|
||||
$baseQuery = Artwork::query()
|
||||
->public()
|
||||
->published();
|
||||
|
||||
$total = $showProgress ? (clone $baseQuery)->count() : null;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <fg=cyan>artworks</> (chunk size: %d%s)',
|
||||
$chunkSize,
|
||||
$total !== null ? ', total: ' . number_format($total) : '',
|
||||
));
|
||||
|
||||
$bar = null;
|
||||
if ($total !== null) {
|
||||
$bar = $this->output->createProgressBar($total);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% shard %message% elapsed: %elapsed:6s% mem: %memory:6s%');
|
||||
$bar->setMessage('—');
|
||||
$bar->start();
|
||||
$this->newLine();
|
||||
}
|
||||
|
||||
$baseQuery
|
||||
->select(['id', 'slug', 'title', 'updated_at', 'published_at', 'created_at', 'hash', 'file_path', 'file_name'])
|
||||
->orderBy('id')
|
||||
->chunkById($chunkSize, function ($artworks) use ($disk, $padLen, $bar, &$shardNum, &$shardNames, &$written, &$failed): void {
|
||||
$shardNum++;
|
||||
$t = microtime(true);
|
||||
$name = 'artworks-' . str_pad((string) $shardNum, $padLen, '0', STR_PAD_LEFT);
|
||||
|
||||
if ($bar !== null) {
|
||||
$bar->setMessage($name);
|
||||
} else {
|
||||
$this->line(sprintf(' Building %s (%d rows)…', $name, $artworks->count()));
|
||||
}
|
||||
|
||||
/** @var list<SitemapUrl> $items */
|
||||
$items = $artworks
|
||||
->map(fn (Artwork $artwork): ?SitemapUrl => $this->artworkSitemapUrl($artwork))
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$xml = view('sitemaps.urlset', [
|
||||
'items' => $items,
|
||||
'hasImages' => collect($items)->contains(fn (SitemapUrl $item): bool => $item->images !== []),
|
||||
])->render();
|
||||
|
||||
if (! $disk->put('sitemaps/' . $name . '.xml', $xml)) {
|
||||
if ($bar !== null) {
|
||||
$bar->advance($artworks->count());
|
||||
$this->newLine();
|
||||
}
|
||||
$this->line(sprintf(' <comment>–</comment> %s <fg=red>WRITE FAILED</>', $name . '.xml'));
|
||||
$failed++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$shardNames[] = $name;
|
||||
$written++;
|
||||
|
||||
if ($bar !== null) {
|
||||
$bar->advance($artworks->count());
|
||||
} else {
|
||||
$this->line(sprintf(
|
||||
' - %s [%s]',
|
||||
$name,
|
||||
$documentType,
|
||||
' <info>✔</info> %s %d URLs <comment>%.3fs</comment>',
|
||||
$name . '.xml',
|
||||
count($items),
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf('Family [%s] complete: urls=%d documents=%d', $family, (int) data_get($manifest, 'families.' . $family . '.url_count', 0), count($names)));
|
||||
if ($bar !== null) {
|
||||
$bar->setMessage('done');
|
||||
$bar->finish();
|
||||
$this->newLine();
|
||||
}
|
||||
$totalDocuments++;
|
||||
|
||||
$this->info(sprintf(
|
||||
'Sitemap release [%s] complete: families=%d documents=%d urls=%d status=%s duration=%.2fs',
|
||||
(string) $manifest['release_id'],
|
||||
(int) data_get($manifest, 'totals.families', 0),
|
||||
(int) data_get($manifest, 'totals.documents', 0),
|
||||
(int) data_get($manifest, 'totals.urls', 0),
|
||||
(string) ($manifest['status'] ?? 'built'),
|
||||
microtime(true) - $startedAt,
|
||||
if ($shardNames === []) {
|
||||
return [[], 0, $failed];
|
||||
}
|
||||
|
||||
// Write artworks-index.xml when there are multiple shards (matches SitemapShardService behaviour)
|
||||
if (count($shardNames) > 1) {
|
||||
$t = microtime(true);
|
||||
$indexItems = array_map(
|
||||
fn (string $n): SitemapIndexItem => new SitemapIndexItem(url('/sitemaps/' . $n . '.xml')),
|
||||
$shardNames,
|
||||
);
|
||||
|
||||
$indexXml = view('sitemaps.index', ['items' => $indexItems])->render();
|
||||
$disk->put('sitemaps/artworks-index.xml', $indexXml);
|
||||
$written++;
|
||||
|
||||
$this->line(sprintf(
|
||||
' <info>✔</info> artworks-index.xml %d shards <comment>%.3fs</comment>',
|
||||
count($shardNames),
|
||||
microtime(true) - $t,
|
||||
));
|
||||
}
|
||||
|
||||
return [$shardNames, $written, $failed];
|
||||
}
|
||||
|
||||
private function artworkSitemapUrl(Artwork $artwork): ?SitemapUrl
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($slug === '') {
|
||||
$slug = (string) $artwork->id;
|
||||
}
|
||||
|
||||
$preview = ThumbnailPresenter::present($artwork, 'xl');
|
||||
|
||||
$images = [];
|
||||
if (! empty($preview['url'])) {
|
||||
$images[] = new SitemapImage((string) $preview['url'], $artwork->title ?: null);
|
||||
}
|
||||
|
||||
$timestamps = array_filter(array_map(
|
||||
static fn (mixed $v): ?Carbon => $v instanceof Carbon ? $v : (is_string($v) ? Carbon::parse($v) : null),
|
||||
[$artwork->updated_at, $artwork->published_at, $artwork->created_at],
|
||||
));
|
||||
$this->line('Sitemap index complete');
|
||||
|
||||
return self::SUCCESS;
|
||||
usort($timestamps, static fn (Carbon $a, Carbon $b): int => $b->timestamp <=> $a->timestamp);
|
||||
|
||||
return new SitemapUrl(
|
||||
route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
$timestamps[0] ?? null,
|
||||
$images,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user