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);
+ }
+}