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) { $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'); $families = $this->normalizeFamilyOption($this->option('family')); $categories = $this->prepareCategories($this->option('category')); $prune = (bool) $this->option('prune'); 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; } 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> */ 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 */ 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 */ 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 $items * @param array $families * @return array */ 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 */ 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 $font * @return array */ 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 */ 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 $existing * @param array $incoming * @return array */ 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', }; } }