<?php
namespace App\Console\Commands\Util;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
use Symfony\Component\Process\Exception\ProcessFailedException;
use InvalidArgumentException;
use RuntimeException;
class PostProcessor extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'post:process {--in= : The input file (local path or URL)}
{--out= : The output file path (local or relative to the specified disk)}
{--filetype= : The type of file to process (html, blade, php, js, json, ts, vue)}
{--disk= : (Optional) The filesystem disk for output (e.g., s3, minio). Defaults to local.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Optimizes and formats source files from local paths or URLs, and saves to a local path or a configured filesystem disk.';
/**
* The list of supported file types.
*
* @var array
*/
protected const SUPPORTED_TYPES = ['html', 'blade', 'php', 'js', 'json', 'ts', 'vue'];
/**
* Execute the console command.
*/
public function handle(): int
{
try {
$inPath = $this->option('in');
$outPath = $this->option('out');
$fileType = $this->getValidatedFileType();
$diskName = $this->option('disk');
if (!$inPath || !$outPath) {
throw new InvalidArgumentException('Both --in and --out options are required.');
}
// --- Validate the disk if provided ---
if ($diskName && !config("filesystems.disks.{$diskName}")) {
throw new InvalidArgumentException("The specified disk '{$diskName}' is not configured in your filesystems.php file.");
}
$this->line("🚀 Starting post-processing for '{$fileType}' files...");
$this->line(" - Input: {$inPath}");
$this->line(" - Output: {$outPath}" . ($diskName ? " (on disk: {$diskName})" : " (local)"));
// --- Updated Logic to handle URL or local file/directory ---
if ($this->isUrl($inPath)) {
$this->processSingleFile($inPath, $outPath, $fileType, $diskName);
} elseif (File::exists($inPath)) {
if (File::isFile($inPath)) {
$this->processSingleFile($inPath, $outPath, $fileType, $diskName);
} else {
$this->processDirectory($inPath, $outPath, $fileType, $diskName);
}
} else {
throw new InvalidArgumentException("The specified input path does not exist or is not a valid URL: {$inPath}");
}
$this->info("\n✅ Processing complete!");
return Command::SUCCESS;
} catch (InvalidArgumentException | RuntimeException | RequestException $e) {
$this->error($e->getMessage());
return Command::FAILURE;
}
}
/**
* Determines if a given path is a URL.
*/
protected function isUrl(string $path): bool
{
return Str::startsWith($path, ['http://', 'https://']);
}
/**
* Gets content from a local file path or a remote URL.
*
* @throws RequestException
*/
protected function getContent(string $path): string
{
if ($this->isUrl($path)) {
$response = Http::get($path);
$response->throw(); // Throws exception for 4xx/5xx errors
return $response->body();
}
return File::get($path);
}
/**
* Puts content to a local file or a specified filesystem disk.
*/
protected function putContent(string $path, string $content, ?string $disk): void
{
if ($disk) {
Storage::disk($disk)->put($path, $content);
} else {
File::ensureDirectoryExists(dirname($path));
File::put($path, $content);
}
}
protected function processSingleFile(string $inputFile, string $outputFile, string $fileType, ?string $disk): void
{
$this->info("Processing single file...");
$this->line(" - Reading from: {$inputFile}");
$content = $this->getContent($inputFile);
$processedContent = $this->formatContent($content, $fileType);
$this->line(" - Writing to: {$outputFile}");
$this->putContent($outputFile, $processedContent, $disk);
}
protected function processDirectory(string $inputDir, string $outputDir, string $fileType, ?string $disk): void
{
$extension = $this->getExtensionForType($fileType);
$files = File::allFiles($inputDir);
$targetFiles = collect($files)->filter(
fn ($file) => Str::endsWith($file->getFilename(), $extension)
);
if ($targetFiles->isEmpty()) {
$this->warn("No '{$extension}' files found in the input directory.");
return;
}
$progressBar = $this->output->createProgressBar($targetFiles->count());
$progressBar->start();
foreach ($targetFiles as $file) {
$relativePath = $file->getRelativePathname();
// Use forward slashes for cross-platform compatibility on storage disks
$outputFilePath = rtrim($outputDir, '/') . '/' . $relativePath;
$content = $file->getContents();
$processedContent = $this->formatContent($content, $fileType);
$this->putContent($outputFilePath, $processedContent, $disk);
$progressBar->advance();
}
$progressBar->finish();
}
protected function formatContent(string $content, string $fileType): string
{
return match ($fileType) {
'php' => $this->formatWithPint($content),
'blade' => $this->formatWithBladeFormatter($content),
'html' => $this->formatWithTidy($content),
'js', 'json', 'ts', 'vue' => $this->formatWithPrettier($content, $fileType),
default => $content,
};
}
protected function formatWithPint(string $content): string
{
$pintPath = base_path('vendor/bin/pint');
if (!File::exists($pintPath)) {
throw new RuntimeException("Laravel Pint not found. Please run 'composer require laravel/pint --dev'.");
}
$tempFile = tempnam(sys_get_temp_dir(), 'pint_') . '.php';
File::put($tempFile, $content);
try {
(new Process([$pintPath, $tempFile]))->mustRun();
return File::get($tempFile);
} finally {
File::delete($tempFile);
}
}
protected function formatWithBladeFormatter(string $content): string
{
$formatterPath = base_path('node_modules/.bin/blade-formatter');
if (!File::exists($formatterPath)) {
throw new RuntimeException("blade-formatter not found. Please run 'npm install --save-dev blade-formatter'.");
}
$process = new Process([$formatterPath, '--stdin', '--indent-size', '4', '--wrap-attributes', 'auto']);
$process->setInput($content);
try {
$process->mustRun();
return $process->getOutput();
} catch (ProcessFailedException $exception) {
$process = $exception->getProcess();
$errorOutput = trim($process->getErrorOutput());
if (empty($errorOutput)) {
$errorOutput = trim($process->getOutput());
}
if (empty($errorOutput)) {
$errorOutput = 'The command exited with status code ' . $process->getExitCode() . ' but provided no output.';
}
throw new RuntimeException("Blade Formatter failed: \n" . $errorOutput);
}
}
protected function formatWithTidy(string $content): string
{
if (empty(shell_exec('command -v tidy'))) {
throw new RuntimeException("The 'Tidy' CLI tool is not installed or not in your system's PATH.");
}
$command = ['tidy', '-q', '--indent', 'auto', '--indent-spaces', '4', '--wrap', '200', '--output-xhtml', 'yes', '--tidy-mark', 'no', '--force-output', 'yes'];
$process = new Process($command);
$process->setInput($content);
try {
if ($process->run() > 1) {
throw new ProcessFailedException($process);
}
return $process->getOutput();
} catch (ProcessFailedException $e) {
throw new RuntimeException("Tidy CLI failed: \n" . $e->getProcess()->getErrorOutput());
}
}
protected function formatWithPrettier(string $content, string $type): string
{
$prettierPath = base_path('node_modules/.bin/prettier');
if (!File::exists($prettierPath)) {
throw new RuntimeException("Prettier not found. Please run 'npm install --save-dev prettier'.");
}
$parser = match ($type) {
'js' => 'babel', 'json' => 'json', 'ts' => 'typescript', 'vue' => 'vue',
default => throw new InvalidArgumentException("No Prettier parser for type '{$type}'."),
};
$process = new Process([$prettierPath, '--parser', $parser]);
$process->setInput($content);
try {
$process->mustRun();
return $process->getOutput();
} catch (ProcessFailedException $e) {
throw new RuntimeException("Prettier failed: \n" . $e->getProcess()->getErrorOutput());
}
}
protected function getValidatedFileType(): string
{
$fileType = $this->option('filetype');
if (!$fileType) {
throw new InvalidArgumentException('The --filetype option is required.');
}
if (!in_array($fileType, self::SUPPORTED_TYPES)) {
throw new InvalidArgumentException("Unsupported file type '{$fileType}'. Supported types are: " . implode(', ', self::SUPPORTED_TYPES));
}
return $fileType;
}
protected function getExtensionForType(string $fileType): string
{
return match ($fileType) {
'blade' => '.blade.php',
'ts' => '.ts',
'vue' => '.vue',
default => '.' . $fileType,
};
}
}