Add from-disk rebuild for font manifest
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-23 21:46:09 +01:00
parent a33bf0e3a4
commit 14bb375674
4 changed files with 2224 additions and 203 deletions

View File

@@ -12,7 +12,7 @@ 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 $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} {--from-disk : Rebuild manifest + CSS from existing font files without downloading}';
protected $description = 'Download the most popular Google Fonts to the local public/fonts/google directory and generate a manifest + CSS file.';
@@ -20,6 +20,17 @@ class SyncGoogleFonts extends Command
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$fromDisk = (bool) $this->option('from-disk');
$pathOption = $this->option('path');
$basePath = $pathOption
? (Str::startsWith($pathOption, DIRECTORY_SEPARATOR) ? $pathOption : base_path($pathOption))
: public_path('fonts/google');
if ($fromDisk) {
return $this->syncFromDisk($basePath, $dryRun);
}
$apiKey = config('services.google_fonts.key');
if (! $apiKey) {
@@ -32,16 +43,10 @@ class SyncGoogleFonts extends Command
$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'));
@@ -206,6 +211,204 @@ class SyncGoogleFonts extends Command
return self::SUCCESS;
}
private function syncFromDisk(string $basePath, bool $dryRun): int
{
if (! File::isDirectory($basePath)) {
$this->error(sprintf('Font directory not found: %s', $basePath));
return self::FAILURE;
}
if ($this->option('prune')) {
$this->warn('Ignoring --prune when rebuilding from disk.');
}
$fonts = $this->buildManifestFromDisk($basePath);
if (! count($fonts)) {
$this->warn('No fonts found on disk.');
}
if ($dryRun) {
$this->info(sprintf('Dry run complete: %d font families would be written to %s', count($fonts), $basePath));
return self::SUCCESS;
}
$this->writeManifest($basePath, $fonts);
$this->writeCss($basePath, $fonts);
Cache::forget('fonts:manifest');
$this->info(sprintf('Rebuilt manifest for %d font families from %s', count($fonts), $basePath));
return self::SUCCESS;
}
/**
* @return array<int, array<string, mixed>>
*/
private function buildManifestFromDisk(string $basePath): array
{
$directories = File::directories($basePath);
$fonts = [];
foreach ($directories as $dir) {
$slug = basename($dir);
$files = collect(File::files($dir))
->filter(function (\SplFileInfo $file) {
$extension = strtolower($file->getExtension());
return in_array($extension, ['woff2', 'woff', 'otf', 'ttf'], true);
})
->values();
if (! $files->count()) {
continue;
}
$variantsByKey = [];
foreach ($files as $file) {
$filename = $file->getFilename();
$extension = strtolower($file->getExtension());
$style = $this->extractStyleFromFilename($filename);
$weight = $this->extractWeightFromFilename($filename);
$variantKey = $this->buildVariantKey($weight, $style);
$priority = $this->extensionPriority($extension);
$relativePath = sprintf('/fonts/google/%s/%s', $slug, $filename);
$existing = $variantsByKey[$variantKey] ?? null;
if ($existing && ($existing['priority'] ?? 0) >= $priority) {
continue;
}
$variantsByKey[$variantKey] = [
'variant' => $variantKey,
'weight' => $weight,
'style' => $style,
'url' => $relativePath,
'priority' => $priority,
];
}
if (! count($variantsByKey)) {
continue;
}
$variants = array_values(array_map(function (array $variant) {
unset($variant['priority']);
return $variant;
}, $variantsByKey));
usort($variants, function (array $left, array $right) {
$weightCompare = ($left['weight'] ?? 400) <=> ($right['weight'] ?? 400);
if ($weightCompare !== 0) {
return $weightCompare;
}
return strcmp((string) ($left['style'] ?? 'normal'), (string) ($right['style'] ?? 'normal'));
});
$fonts[] = [
'family' => $this->familyFromSlug($slug),
'slug' => $slug,
'category' => null,
'variants' => $variants,
];
}
usort($fonts, fn (array $left, array $right) => strcmp((string) $left['family'], (string) $right['family']));
return $fonts;
}
private function familyFromSlug(string $slug): string
{
$parts = array_filter(explode('-', $slug), fn ($part) => $part !== '');
$words = array_map(function (string $part) {
if (is_numeric($part)) {
return $part;
}
if (strlen($part) <= 3) {
return strtoupper($part);
}
return ucfirst(strtolower($part));
}, $parts);
return trim(implode(' ', $words));
}
private function extractStyleFromFilename(string $filename): string
{
$lower = strtolower($filename);
return str_contains($lower, 'italic') || str_contains($lower, 'oblique') ? 'italic' : 'normal';
}
private function extractWeightFromFilename(string $filename): int
{
if (preg_match('/(?:^|[^0-9])(100|200|300|400|500|600|700|800|900)(?:[^0-9]|$)/', $filename, $matches)) {
return (int) $matches[1];
}
$lower = strtolower($filename);
$weightMap = [
'thin' => 100,
'extralight' => 200,
'ultralight' => 200,
'light' => 300,
'regular' => 400,
'book' => 400,
'medium' => 500,
'semibold' => 600,
'demibold' => 600,
'bold' => 700,
'extrabold' => 800,
'ultrabold' => 800,
'black' => 900,
'heavy' => 900,
];
foreach ($weightMap as $label => $weight) {
if (str_contains($lower, $label)) {
return $weight;
}
}
return 400;
}
private function buildVariantKey(int $weight, string $style): string
{
if ($weight === 400 && $style === 'normal') {
return 'regular';
}
if ($weight === 400 && $style === 'italic') {
return 'italic';
}
if ($style === 'italic') {
return $weight.'italic';
}
return (string) $weight;
}
private function extensionPriority(string $extension): int
{
return match ($extension) {
'woff2' => 4,
'woff' => 3,
'otf' => 2,
'ttf' => 1,
default => 0,
};
}
/**
* @return array<int, string>
*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -305,4 +305,50 @@ class SyncGoogleFontsTest extends TestCase
File::deleteDirectory($targetPath);
}
public function test_it_rebuilds_manifest_from_disk_without_downloading(): void
{
$targetPath = storage_path('app/test-fonts');
File::deleteDirectory($targetPath);
File::ensureDirectoryExists($targetPath.'/alpha-sans');
File::put($targetPath.'/alpha-sans/AlphaSans-400-normal.ttf', 'font-binary');
File::put($targetPath.'/alpha-sans/AlphaSans-700-italic.woff2', 'font-binary');
File::ensureDirectoryExists($targetPath.'/beta-serif');
File::put($targetPath.'/beta-serif/BetaSerif-Regular.ttf', 'font-binary');
File::ensureDirectoryExists($targetPath.'/ibm-plex-sans');
File::put($targetPath.'/ibm-plex-sans/IBMPlexSans-Bold.ttf', 'font-binary');
Http::fake();
Artisan::call('fonts:sync-google', [
'--from-disk' => true,
'--path' => 'storage/app/test-fonts',
]);
Http::assertNothingSent();
$manifestPath = $targetPath.'/manifest.json';
$cssPath = $targetPath.'/fonts.css';
$this->assertFileExists($manifestPath);
$this->assertFileExists($cssPath);
$manifest = json_decode(File::get($manifestPath), true);
$this->assertSame(3, $manifest['count']);
$this->assertTrue(collect($manifest['fonts'])->pluck('family')->contains('Alpha Sans'));
$this->assertTrue(collect($manifest['fonts'])->pluck('family')->contains('Beta Serif'));
$this->assertTrue(collect($manifest['fonts'])->pluck('family')->contains('IBM Plex Sans'));
$this->assertTrue(str_contains(File::get($cssPath), "font-family: 'IBM Plex Sans';"));
$alpha = collect($manifest['fonts'])->firstWhere('family', 'Alpha Sans');
$alphaVariants = collect($alpha['variants'] ?? [])->pluck('variant')->all();
$this->assertContains('regular', $alphaVariants);
$this->assertContains('700italic', $alphaVariants);
File::deleteDirectory($targetPath);
}
}