435 lines
15 KiB
PHP
435 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Filesystem\Filesystem;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
|
|
class SyncGoogleFonts extends Command
|
|
{
|
|
protected $signature = 'fonts:sync-google {--count=50 : Number of popular fonts to fetch} {--weights=400,700 : Comma separated numeric font weights to download} {--italic : Also download italic variants where available} {--force : Re-download files even if they exist} {--path= : Optional custom output directory (defaults to public/fonts/google)} {--family= : Download specific family name(s), comma separated (case-insensitive)} {--category= : Filter by category, comma separated (e.g. sans-serif,serif)} {--prune : Remove local font families not included in this sync} {--dry-run : Show what would be downloaded without writing files}';
|
|
|
|
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
|
|
|
|
private const API_ENDPOINT = 'https://www.googleapis.com/webfonts/v1/webfonts';
|
|
|
|
public function handle(): int
|
|
{
|
|
$apiKey = config('services.google_fonts.key');
|
|
|
|
if (! $apiKey) {
|
|
$this->error('GOOGLE_FONTS_API_KEY is missing in the environment.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$count = max(1, (int) $this->option('count'));
|
|
$weights = $this->prepareWeights($this->option('weights'));
|
|
$includeItalic = (bool) $this->option('italic');
|
|
$force = (bool) $this->option('force');
|
|
$dryRun = (bool) $this->option('dry-run');
|
|
$families = $this->normalizeFamilyOption($this->option('family'));
|
|
$categories = $this->prepareCategories($this->option('category'));
|
|
$prune = (bool) $this->option('prune');
|
|
|
|
$pathOption = $this->option('path');
|
|
$basePath = $pathOption
|
|
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
|
|
: public_path('fonts/google');
|
|
|
|
if (count($families)) {
|
|
$label = count($families) > 1 ? 'families' : 'family';
|
|
$this->info(sprintf('Fetching Google Font %s "%s" (weights: %s, italic: %s)...', $label, implode(', ', $families), implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
|
} else {
|
|
$this->info(sprintf('Fetching top %d Google Fonts (weights: %s, italic: %s)...', $count, implode(', ', $weights), $includeItalic ? 'yes' : 'no'));
|
|
}
|
|
|
|
if (count($categories)) {
|
|
$this->line('Category filter: '.implode(', ', $categories));
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->warn('Dry run enabled: no files will be written.');
|
|
}
|
|
|
|
$response = Http::retry(2, 200)
|
|
->timeout(30)
|
|
->get(self::API_ENDPOINT, [
|
|
'key' => $apiKey,
|
|
'sort' => 'popularity',
|
|
]);
|
|
|
|
if (! $response->ok()) {
|
|
$this->error(sprintf('Google Fonts API failed: %s', $response->body()));
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$items = Arr::get($response->json(), 'items', []);
|
|
if (! is_array($items) || ! count($items)) {
|
|
$this->warn('Google Fonts API returned no items.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$items = $this->filterFonts($items, $families, $categories);
|
|
|
|
if (count($families)) {
|
|
$matchedFamilies = collect($items)
|
|
->pluck('family')
|
|
->filter()
|
|
->map(fn ($family) => strtolower((string) $family))
|
|
->unique()
|
|
->all();
|
|
|
|
$missingFamilies = collect($families)
|
|
->filter(fn ($family) => ! in_array(strtolower($family), $matchedFamilies, true))
|
|
->values()
|
|
->all();
|
|
|
|
if (count($missingFamilies)) {
|
|
$label = count($missingFamilies) > 1 ? 'families' : 'family';
|
|
$this->error(sprintf('Font %s not found: %s.', $label, implode(', ', $missingFamilies)));
|
|
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
|
|
if (! count($items)) {
|
|
$this->warn('No fonts matched the provided filters.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$selected = count($families) ? $items : array_slice($items, 0, $count);
|
|
$manifestFonts = [];
|
|
$filesystem = new Filesystem;
|
|
|
|
if (! $dryRun) {
|
|
File::ensureDirectoryExists($basePath);
|
|
}
|
|
|
|
foreach ($selected as $index => $font) {
|
|
if (! is_array($font) || ! isset($font['family'])) {
|
|
continue;
|
|
}
|
|
|
|
$family = (string) $font['family'];
|
|
$slug = Str::slug($family);
|
|
$familyDir = $basePath.DIRECTORY_SEPARATOR.$slug;
|
|
if (! $dryRun) {
|
|
File::ensureDirectoryExists($familyDir);
|
|
}
|
|
|
|
$variantMap = $this->buildVariantMap($font, $weights, $includeItalic);
|
|
if (! count($variantMap)) {
|
|
$this->warn("Skipping {$family} (no matching variants)");
|
|
|
|
continue;
|
|
}
|
|
|
|
$variants = [];
|
|
foreach ($variantMap as $variantKey => $fileUrl) {
|
|
$style = str_contains($variantKey, 'italic') ? 'italic' : 'normal';
|
|
$weight = (int) preg_replace('/[^0-9]/', '', $variantKey) ?: 400;
|
|
$extension = pathinfo(parse_url($fileUrl, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION) ?: 'woff2';
|
|
$filename = sprintf('%s-%s-%s.%s', Str::studly($slug), $weight, $style, $extension);
|
|
$targetPath = $familyDir.DIRECTORY_SEPARATOR.$filename;
|
|
|
|
$alreadyExists = $filesystem->exists($targetPath);
|
|
|
|
if ($dryRun) {
|
|
$this->line(sprintf('◦ DRY RUN: %s %s would %s (%s)', $family, $variantKey, $alreadyExists && ! $force ? 'reuse existing file' : 'download', $targetPath));
|
|
} elseif (! $force && $alreadyExists) {
|
|
$this->line("✔ {$family} {$variantKey} already exists");
|
|
} else {
|
|
$this->line("↓ Downloading {$family} {$variantKey}");
|
|
$fileResponse = Http::retry(2, 200)->timeout(30)->get($fileUrl);
|
|
|
|
if (! $fileResponse->ok()) {
|
|
$this->warn(" Skipped {$family} {$variantKey} (download failed)");
|
|
|
|
continue;
|
|
}
|
|
|
|
$filesystem->put($targetPath, $fileResponse->body());
|
|
}
|
|
|
|
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
|
|
$variants[] = [
|
|
'variant' => $variantKey,
|
|
'weight' => $weight,
|
|
'style' => $style,
|
|
'url' => $relativePath,
|
|
];
|
|
}
|
|
|
|
if (! count($variants)) {
|
|
continue;
|
|
}
|
|
|
|
$manifestFonts[] = [
|
|
'family' => $family,
|
|
'slug' => $slug,
|
|
'category' => $font['category'] ?? null,
|
|
'variants' => $variants,
|
|
];
|
|
}
|
|
|
|
if ($dryRun) {
|
|
$this->info(sprintf('Dry run complete: %d font families would be synced to %s', count($manifestFonts), $basePath));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$finalFonts = $manifestFonts;
|
|
if (count($families) && ! $prune) {
|
|
$existingFonts = $this->loadExistingManifestFonts($basePath);
|
|
$finalFonts = $this->mergeManifestFonts($existingFonts, $manifestFonts);
|
|
}
|
|
|
|
if ($prune || ! count($families)) {
|
|
$this->pruneStaleFamilies($basePath, $finalFonts);
|
|
}
|
|
|
|
$this->writeManifest($basePath, $finalFonts);
|
|
$this->writeCss($basePath, $finalFonts);
|
|
Cache::forget('fonts:manifest');
|
|
|
|
$this->info(sprintf('Synced %d font families to %s', count($finalFonts), $basePath));
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function normalizeFamilyOption(?string $family): array
|
|
{
|
|
$family = trim((string) $family);
|
|
if ($family === '') {
|
|
return [];
|
|
}
|
|
|
|
$parts = array_filter(array_map('trim', explode(',', $family)));
|
|
|
|
return collect($parts)
|
|
->unique(fn ($value) => strtolower((string) $value))
|
|
->values()
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function prepareCategories(?string $categories): array
|
|
{
|
|
$parts = array_filter(array_map('trim', explode(',', (string) $categories)));
|
|
|
|
return array_values(array_unique(array_map(static function ($category) {
|
|
$normalized = Str::of($category)->lower()->replace(' ', '-')->toString();
|
|
|
|
return (string) $normalized;
|
|
}, $parts)));
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $items
|
|
* @param array<int, string> $families
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function filterFonts(array $items, array $families, array $categories): array
|
|
{
|
|
$filtered = collect($items)
|
|
->filter(fn ($font) => is_array($font) && isset($font['family']))
|
|
->filter(function ($font) use ($categories) {
|
|
if (! count($categories)) {
|
|
return true;
|
|
}
|
|
|
|
$category = strtolower((string) ($font['category'] ?? ''));
|
|
|
|
return in_array($category, $categories, true);
|
|
});
|
|
|
|
if (count($families)) {
|
|
$normalizedFamilies = collect($families)
|
|
->map(fn ($family) => strtolower((string) $family))
|
|
->all();
|
|
|
|
$filtered = $filtered->filter(function ($font) use ($normalizedFamilies) {
|
|
return in_array(strtolower((string) $font['family']), $normalizedFamilies, true);
|
|
});
|
|
}
|
|
|
|
return $filtered->values()->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<int, int>
|
|
*/
|
|
private function prepareWeights(string $weights): array
|
|
{
|
|
$parts = array_filter(array_map('trim', explode(',', $weights)));
|
|
$numeric = array_map(fn ($weight) => (int) $weight, $parts);
|
|
$numeric = array_filter($numeric, fn ($weight) => $weight >= 100 && $weight <= 900);
|
|
|
|
return $numeric ? array_values(array_unique($numeric)) : [400, 700];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $font
|
|
* @return array<string, string>
|
|
*/
|
|
private function buildVariantMap(array $font, array $weights, bool $includeItalic): array
|
|
{
|
|
$files = $font['files'] ?? [];
|
|
if (! is_array($files)) {
|
|
return [];
|
|
}
|
|
|
|
$variants = [];
|
|
foreach ($weights as $weight) {
|
|
$normalKey = $weight === 400 ? 'regular' : (string) $weight;
|
|
$italicKey = $weight === 400 ? 'italic' : $weight.'italic';
|
|
|
|
if (isset($files[$normalKey])) {
|
|
$variants[$normalKey] = (string) $files[$normalKey];
|
|
}
|
|
|
|
if ($includeItalic && isset($files[$italicKey])) {
|
|
$variants[$italicKey] = (string) $files[$italicKey];
|
|
}
|
|
}
|
|
|
|
// Fallback: if no requested weights present, try any provided
|
|
if (! count($variants)) {
|
|
foreach ($files as $key => $url) {
|
|
if (is_string($key) && is_string($url)) {
|
|
$variants[$key] = $url;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $variants;
|
|
}
|
|
|
|
private function pruneStaleFamilies(string $basePath, array $manifestFonts): void
|
|
{
|
|
$keep = collect($manifestFonts)->pluck('slug')->filter()->all();
|
|
$directories = File::directories($basePath);
|
|
|
|
foreach ($directories as $dir) {
|
|
$name = basename($dir);
|
|
if (! in_array($name, $keep, true)) {
|
|
File::deleteDirectory($dir);
|
|
$this->line("🗑 Removed stale font directory {$name}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function loadExistingManifestFonts(string $basePath): array
|
|
{
|
|
$manifestPath = $basePath.'/manifest.json';
|
|
if (! File::exists($manifestPath)) {
|
|
return [];
|
|
}
|
|
|
|
$manifest = json_decode(File::get($manifestPath), true);
|
|
if (! is_array($manifest)) {
|
|
return [];
|
|
}
|
|
|
|
$fonts = $manifest['fonts'] ?? [];
|
|
|
|
return is_array($fonts) ? $fonts : [];
|
|
}
|
|
|
|
/**
|
|
* @param array<int, mixed> $existing
|
|
* @param array<int, mixed> $incoming
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function mergeManifestFonts(array $existing, array $incoming): array
|
|
{
|
|
$merged = collect($existing)
|
|
->filter(fn ($font) => is_array($font) && isset($font['family']))
|
|
->keyBy(fn ($font) => strtolower((string) $font['family']))
|
|
->all();
|
|
|
|
foreach ($incoming as $font) {
|
|
if (! is_array($font) || ! isset($font['family'])) {
|
|
continue;
|
|
}
|
|
|
|
$merged[strtolower((string) $font['family'])] = $font;
|
|
}
|
|
|
|
return array_values($merged);
|
|
}
|
|
|
|
private function writeManifest(string $basePath, array $fonts): void
|
|
{
|
|
$manifest = [
|
|
'generated_at' => now()->toIso8601String(),
|
|
'source' => 'google-webfonts',
|
|
'count' => count($fonts),
|
|
'fonts' => $fonts,
|
|
];
|
|
|
|
File::put($basePath.'/manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
}
|
|
|
|
private function writeCss(string $basePath, array $fonts): void
|
|
{
|
|
$lines = [
|
|
'/* Auto-generated by fonts:sync-google */',
|
|
];
|
|
|
|
foreach ($fonts as $font) {
|
|
$family = $font['family'] ?? null;
|
|
$variants = $font['variants'] ?? [];
|
|
if (! $family || ! is_array($variants)) {
|
|
continue;
|
|
}
|
|
|
|
foreach ($variants as $variant) {
|
|
if (! isset($variant['url'])) {
|
|
continue;
|
|
}
|
|
|
|
$lines[] = sprintf(
|
|
"@font-face {\n font-family: '%s';\n font-style: %s;\n font-weight: %s;\n font-display: swap;\n src: url('%s') format('%s');\n}\n",
|
|
$family,
|
|
$variant['style'] ?? 'normal',
|
|
$variant['weight'] ?? 400,
|
|
$variant['url'],
|
|
$this->guessFormat((string) ($variant['url'] ?? ''))
|
|
);
|
|
}
|
|
}
|
|
|
|
File::put($basePath.'/fonts.css', implode("\n", $lines));
|
|
}
|
|
|
|
private function guessFormat(string $url): string
|
|
{
|
|
$extension = strtolower(pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION));
|
|
|
|
return match ($extension) {
|
|
'ttf' => 'truetype',
|
|
'otf' => 'opentype',
|
|
'woff' => 'woff',
|
|
default => 'woff2',
|
|
};
|
|
}
|
|
}
|