diff --git a/README.md b/README.md index 0dd53d7..7bec2e6 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,15 @@ php please ssg:generate Your site will be generated into a directory which you can deploy however you like. See [Deployment Examples](#deployment-examples) below for inspiration. +### Multiple Workers + +For improved performance, you may spread the page generation across multiple workers. This requires Spatie's [Fork](https://github.com/spatie/fork) package. Then you may specify how many workers are to be used. You can use as many workers as you have CPU cores. + +``` +composer require spatie/fork +php please ssg:generate --workers=4 +``` + ## Routes diff --git a/composer.json b/composer.json index 39ee57c..acfe01d 100644 --- a/composer.json +++ b/composer.json @@ -29,5 +29,8 @@ "require-dev": { "orchestra/testbench": "^4.0" }, + "suggest": { + "spatie/fork": "Required to generate pages concurrently (^0.0.4)." + }, "minimum-stability": "dev" } diff --git a/src/Commands/StaticSiteGenerate.php b/src/Commands/StaticSiteGenerate.php index bcd0211..3bd1236 100644 --- a/src/Commands/StaticSiteGenerate.php +++ b/src/Commands/StaticSiteGenerate.php @@ -21,7 +21,7 @@ class StaticSiteGenerate extends Command * * @var string */ - protected $signature = 'statamic:ssg:generate'; + protected $signature = 'statamic:ssg:generate {--workers=1}'; /** * The console command description. @@ -51,6 +51,8 @@ public function handle() { Partyline::bind($this); - $this->generator->generate(); + $this->generator + ->workers($this->option('workers')) + ->generate(); } } diff --git a/src/ConcurrentTasks.php b/src/ConcurrentTasks.php new file mode 100644 index 0000000..42ca966 --- /dev/null +++ b/src/ConcurrentTasks.php @@ -0,0 +1,20 @@ +fork = $fork; + } + + public function run(...$closures) + { + return $this->fork->run(...$closures); + } +} diff --git a/src/ConsecutiveTasks.php b/src/ConsecutiveTasks.php new file mode 100644 index 0000000..eeec502 --- /dev/null +++ b/src/ConsecutiveTasks.php @@ -0,0 +1,17 @@ +app = $app; $this->files = $files; $this->router = $router; + $this->tasks = $tasks; $this->extraUrls = collect(); $this->config = $this->initializeConfig(); } @@ -55,6 +59,13 @@ private function initializeConfig() return $config; } + public function workers(int $workers) + { + $this->workers = $workers; + + return $this; + } + public function after($after) { $this->after = $after; @@ -69,6 +80,8 @@ public function addUrls($closure) public function generate() { + $this->checkConcurrencySupport(); + Site::setCurrent(Site::default()->handle()); $this @@ -134,7 +147,7 @@ public function createSymlinks() Partyline::line("Symlink not created. $dest already exists."); } else { $this->files->link($source, $dest); - Partyline::line("$source symlinked to $dest"); + Partyline::line("[✔] $source symlinked to $dest"); } } @@ -152,7 +165,7 @@ public function copyFiles() $this->files->copyDirectory($source, $dest); } - Partyline::line("$source copied to to $dest"); + Partyline::line("[✔] $source copied to $dest"); } } @@ -163,33 +176,28 @@ protected function createContentFiles() $this->app->instance('request', $request); }); - $this->pages()->each(function ($page) use ($request) { - $this->updateCurrentSite($page->site()); + $pages = $this->gatherContent(); - view()->getFinder()->setPaths($this->viewPaths); + Partyline::line("Generating {$pages->count()} content files..."); - $this->count++; + $closures = $this->makeContentGenerationClosures($pages, $request); - $request->setPage($page); + $results = $this->tasks->run(...$closures); - Partyline::comment("Generating {$page->url()}..."); + $this->outputResults($results); - try { - $generated = $page->generate($request); - } catch (NotGeneratedException $e) { - $this->skips++; - Partyline::line($e->consoleMessage()); - return; - } + return $this; + } - if ($generated->hasWarning()) { - $this->warnings++; - } + protected function gatherContent() + { + Partyline::line('Gathering content to be generated...'); - Partyline::line($generated->consoleMessage()); - }); + $pages = $this->pages(); - return $this; + Partyline::line("\x1B[1A\x1B[2K[✔] Gathered content to be generated"); + + return $pages; } protected function pages() @@ -215,6 +223,57 @@ protected function pages() }); } + protected function makeContentGenerationClosures($pages, $request) + { + return $pages->split($this->workers)->map(function ($pages) use ($request) { + return function () use ($pages, $request) { + $count = $skips = $warnings = 0; + $errors = []; + + foreach ($pages as $page) { + $this->updateCurrentSite($page->site()); + + view()->getFinder()->setPaths($this->viewPaths); + + $count++; + + $request->setPage($page); + + Partyline::line("\x1B[1A\x1B[2KGenerating ".$page->url()); + + try { + $generated = $page->generate($request); + } catch (NotGeneratedException $e) { + $skips++; + $errors[] = $e->consoleMessage(); + continue; + } + + if ($generated->hasWarning()) { + $warnings++; + } + } + + return compact('count', 'skips', 'warnings', 'errors'); + }; + })->all(); + } + + protected function outputResults($results) + { + $results = collect($results); + + Partyline::line("\x1B[1A\x1B[2K[✔] Generated {$results->sum('count')} content files"); + + if ($results->sum('skips')) { + $results->reduce(function ($carry, $item) { + return $carry->merge($item['errors']); + }, collect())->each(function ($error) { + Partyline::line($error); + }); + } + } + protected function entries() { return Entry::all() @@ -301,4 +360,13 @@ protected function updateCurrentSite($site) setlocale(LC_TIME, $site->locale()); app()->setLocale($site->shortLocale()); } + + protected function checkConcurrencySupport() + { + if ($this->workers === 1 || class_exists(Fork::class)) { + return; + } + + throw new \RuntimeException('To use multiple workers, you must install PHP 8 and spatie/fork.'); + } } diff --git a/src/NotGeneratedException.php b/src/NotGeneratedException.php index 2673cba..04c0cad 100644 --- a/src/NotGeneratedException.php +++ b/src/NotGeneratedException.php @@ -43,6 +43,6 @@ public function consoleMessage() $message = $this->getMessage(); } - return sprintf('%s %s (%s)', "\x1B[1A\x1B[2K[✘]", $this->getPage()->url(), $message); + return sprintf('%s %s (%s)', "[✘]", $this->getPage()->url(), $message); } } diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 3246954..ef286dd 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -2,6 +2,7 @@ namespace Statamic\StaticSite; +use Spatie\Fork\Fork; use Statamic\StaticSite\Generator; use Illuminate\Support\ServiceProvider as LaravelServiceProvider; @@ -9,8 +10,14 @@ class ServiceProvider extends LaravelServiceProvider { public function register() { + $this->app->bind(Tasks::class, function () { + return class_exists(Fork::class) + ? new ConcurrentTasks(new Fork) + : new ConsecutiveTasks; + }); + $this->app->singleton(Generator::class, function ($app) { - return new Generator($app, $app['files'], $app['router']); + return new Generator($app, $app['files'], $app['router'], $app[Tasks::class]); }); } diff --git a/src/Tasks.php b/src/Tasks.php new file mode 100644 index 0000000..64bc212 --- /dev/null +++ b/src/Tasks.php @@ -0,0 +1,8 @@ +mock(Fork::class); + $fork->shouldReceive('run')->once()->with($one, $two)->andReturn([$one(), $two()]); + + $results = (new ConcurrentTasks($fork))->run($one, $two); + + $this->assertEquals(['one', 'two'], $results); + } +} diff --git a/tests/ConsecutiveTasksTest.php b/tests/ConsecutiveTasksTest.php new file mode 100644 index 0000000..b2313ed --- /dev/null +++ b/tests/ConsecutiveTasksTest.php @@ -0,0 +1,29 @@ +run($one, $two); + + $this->assertEquals(['one', 'two'], $results); + $this->assertEquals(2, $callbacksRan); + } +}