From 89d8a897c4e9dece51d5cadd924dfe1b7673f5b5 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Thu, 9 May 2024 21:52:08 +0200 Subject: [PATCH] Improved validation --- app/Console/Commands/Bundle/Validate.php | 237 ++++++++++++++++++++--- 1 file changed, 213 insertions(+), 24 deletions(-) diff --git a/app/Console/Commands/Bundle/Validate.php b/app/Console/Commands/Bundle/Validate.php index 411137a..af89676 100644 --- a/app/Console/Commands/Bundle/Validate.php +++ b/app/Console/Commands/Bundle/Validate.php @@ -3,9 +3,12 @@ namespace App\Console\Commands\Bundle; use App\Classes\Bundle; +use App\Services\HtmlValidator; use Illuminate\Console\Command; use Illuminate\Contracts\Filesystem\Filesystem; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Vite; use Illuminate\Support\Str; use JsonSchema\Validator; @@ -22,14 +25,23 @@ class Validate extends Command protected Filesystem $sourceDisk; - protected array $invalid = []; + protected array $invalidJson = []; + + protected array $invalidHtml = []; + + protected array $invalidCss = []; + + protected $bundles; public function __construct() { $this->signature = 'bundle:validate - { --r|recursive : Also render sub-bundles } + { --no-css : Do not validate CSS } + { --no-html : Do not validate HTML } + { --no-json : Do not validate JSON } + { --r|recursive : Also validate sub-bundles } { --source-disk= : Use specified content disk - Defaults to ' . env('CONTENT_DISK') . ' } - { path? : Path to a specific bundle to render - Default to / } + { path? : Path to a specific bundle to validate - Default to / } '; parent::__construct(); @@ -41,8 +53,19 @@ public function __construct() public function handle() { $this->selectDisk() - ->validate() + ->validateCss() + ->selectBundles() + ->validateJson() + ->validateHtml() ->showReport(); + + if ( + !empty($this->invalidCss) + || !empty($this->invalidJson) + || !empty($this->invalidHtml) + ) { + exit(1); + } } /** @@ -54,9 +77,9 @@ private function selectDisk(): self $this->sourceDisk = Storage::disk($sourceDisk); - $this->comment( + $this->line( sprintf( - 'Using `%s` as source disk', + 'Using %s as source disk', $sourceDisk ) ); @@ -67,16 +90,16 @@ private function selectDisk(): self /** * Collect a list of bundles to validate */ - private function getBundles() + private function selectBundles(): self { $path = $this->argument('path') ?? '/'; - $comment = sprintf('Validating %s', $path); + $comment = sprintf('Validating %s', $path); if ($this->option('recursive')) { - $comment .= ' and all sub-bundles'; + $comment .= ' and all sub-bundles'; } - $this->comment($comment); + $this->line($comment); $this->output->write('Collecting bundles... '); if ($this->option('recursive')) { @@ -85,16 +108,18 @@ private function getBundles() $bundles = [new Bundle($path, $this->sourceDisk)]; } + $this->bundles = $bundles; + $this->info('OK'); - return $bundles; + return $this; } /** * Return an associative array containing available bundles metadata files * as keys and json-decoded validation schemas as values */ - private function getValidators() + private function getJsonValidators() { return array_map(function ($path) { return json_decode(file_get_contents($path)); @@ -104,15 +129,21 @@ private function getValidators() /** * Perform json validation on selected bundles */ - private function validate(): self + private function validateJson(): self { - $bundles = $this->getBundles(); - $validators = $this->getValidators(); + if ($this->option('no-json')) { + return $this; + } + + $this->line('Validating JSON...'); + + $bundles = $this->bundles; + $validators = $this->getJsonValidators(); progress( label: 'Validating... ', steps: $bundles, - callback: fn (Bundle $bundle, $progress) => $this->handleBundle($bundle, $progress, $validators) + callback: fn (Bundle $bundle, $progress) => $this->handleBundleJsonValidation($bundle, $progress, $validators) ); return $this; @@ -121,7 +152,7 @@ private function validate(): self /** * Handle specific bundle */ - private function handleBundle(Bundle $bundle, $progress, $validators) + private function handleBundleJsonValidation(Bundle $bundle, $progress, $validators) { $progress->label(sprintf('Validating %s ...', $bundle->getPath())); @@ -141,7 +172,7 @@ private function handleBundle(Bundle $bundle, $progress, $validators) $validator->validate($data, $schema); if (!$validator->isValid()) { - $this->invalid[$bundle->getPath()] = [ + $this->invalidJson[$bundle->getPath()] = [ 'metadata' => $metadataFilename, 'errors' => $validator->getErrors(), ]; @@ -149,20 +180,109 @@ private function handleBundle(Bundle $bundle, $progress, $validators) } } + private function validateCss(): self + { + if ($this->option('no-css')) { + return $this; + } + + $this->line('Validating CSS...'); + + $file = public_path(Vite::asset('resources/css/app.css')); + $cacheKey = sprintf('css_validation_%s', Str::slug($file)); + + $result = Cache::remember($cacheKey, now()->addMonth(), function () use ($file) { + $content = file_get_contents($file); + + return HtmlValidator::validateCss($content); + }); + + $errors = collect($result['messages'])->where('type', '=', 'error'); + + if ($errors->count() > 0) { + $this->invalidCss = $result; + + Cache::forever('css_is_valid', false); + } else { + Cache::forever('css_is_valid', true); + } + + return $this; + } + + /** + * Perform json validation on selected bundles + */ + private function validateHtml(): self + { + if ($this->option('no-html')) { + return $this; + } + + $this->line('Validating HTML...'); + + $bundles = $this->bundles; + + progress( + label: 'Validating... ', + steps: $bundles, + callback: fn (Bundle $bundle, $progress) => $this->handleBundleHtmlValidation($bundle, $progress) + ); + + return $this; + } + + private function handleBundleHtmlValidation(Bundle $bundle, $progress) + { + $progress->label(sprintf('Validating %s ...', $bundle->getPath())); + + $rendered = $bundle->render(); + + foreach ($rendered as $path => $content) { + $result = HtmlValidator::validateHtml($content); + + $errors = collect($result['messages'])->where('type', '=', 'error'); + + $cacheKey = sprintf('html_is_valid_%s', Str::slug($path)); + + if ($errors->count() > 0) { + $this->invalidHtml[$path] = $result; + + Cache::forever($cacheKey, false); + } else { + Cache::forever($cacheKey, true); + } + } + } + /** * Show a report in case of need */ private function showReport() { - if (empty($this->invalid)) { - $this->info('All files are valid'); - } else { - $count = count($this->invalid); + $this->showCssReport(); + $this->showJsonReport(); + $this->showHtmlReport(); + } - $this->error(sprintf('%d invalid %s reported', $count, Str::plural('file', $count))); + /** + * Show a report in case of need + */ + private function showJsonReport() + { + if ($this->option('no-json')) { + return $this; + } + + if (empty($this->invalidJson)) { + $this->info('Checked JSON files are valid'); + } else { + $count = count($this->invalidJson); + + $this->error(sprintf('%d invalid JSON %s reported', $count, Str::plural('file', $count))); $this->newLine(2); - foreach ($this->invalid as $bundlePath => $data) { + foreach ($this->invalidJson as $bundlePath => $data) { $this->line($bundlePath); $this->line(sprintf(' > In %s:', $data['metadata'])); @@ -172,4 +292,73 @@ private function showReport() } } } + + private function showCssReport() + { + if ($this->option('no-css')) { + return $this; + } + + if (empty($this->invalidCss)) { + $this->info('Checked CSS files are valid'); + } else { + $count = count($this->invalidCss); + + $this->error(sprintf('%d CSS %s reported', $count, Str::plural('error', $count))); + $this->newLine(); + + $data = $this->invalidCss; + + foreach ($data['messages'] as $message) { + $type = $message['type']; + $subType = $message['subType'] ?? $type; + + $decoration = match ($subType) { + 'error' => 'error', + 'info' => 'comment', + 'warning' => 'comment' + }; + + $this->newLine(); + $this->line(sprintf(' > <%1$s>%2$s: %3$s', $decoration, $subType, $message['message'])); + $this->line(' > > ' . $message['extract']); + } + } + } + + private function showHtmlReport() + { + if ($this->option('no-html')) { + return $this; + } + + if (empty($this->invalidHtml)) { + $this->info('Checked HTML files are valid'); + } else { + $count = count($this->invalidHtml); + + $this->error(sprintf('%d invalid HTML %s reported', $count, Str::plural('file', $count))); + $this->newLine(2); + + foreach ($this->invalidHtml as $path => $data) { + $this->newLine(2); + $this->comment($path); + + foreach ($data['messages'] as $message) { + $type = $message['type']; + $subType = $message['subType'] ?? $type; + + $decoration = match ($subType) { + 'error' => 'error', + 'info' => 'comment', + 'warning' => 'comment' + }; + + $this->newLine(); + $this->line(sprintf(' > <%1$s>%2$s: %3$s', $decoration, $subType, $message['message'])); + $this->line(' > > ' . $message['extract']); + } + } + } + } }