Commit c2c73418 authored by samuel.mortenson's avatar samuel.mortenson Committed by Samuel Mortenson

Issue #3006451 by samuel.mortenson: Create a user interface for Tome

parent 111c9432
......@@ -61,12 +61,14 @@ trait ProcessTrait {
* The command to run.
* @param string $cwd
* (Optional) The working directory to use.
* @param int|float|null $timeout
* The timeout in seconds or null to disable.
*
* @return bool
* Whether or not the process executed successfully.
*/
protected function runCommand($command, $cwd = NULL) {
$process = new Process($command, $cwd);
protected function runCommand($command, $cwd = NULL, $timeout = 60) {
$process = new Process($command, $cwd, NULL, NULL, $timeout);
$process->run();
$successful = $process->isSuccessful();
$errors = [];
......
......@@ -2,8 +2,11 @@
namespace Drupal\tome_static\Commands;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\tome_base\CommandBase;
use Drupal\tome_static\StaticGeneratorInterface;
use Drupal\tome_static\StaticUITrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
......@@ -14,6 +17,9 @@ use Symfony\Component\Process\Process;
*/
class StaticCommand extends CommandBase {
use StaticUITrait;
use StringTranslationTrait;
/**
* The default number of processes to invoke.
*
......@@ -21,6 +27,11 @@ class StaticCommand extends CommandBase {
*/
const PROCESS_COUNT = 5;
/**
* The default number of paths to export per process.
*/
const PATH_COUNT = 5;
/**
* The static service.
*
......@@ -28,15 +39,25 @@ class StaticCommand extends CommandBase {
*/
protected $static;
/**
* The state system.
*
* @var \\Drupal\Core\State\StateInterface
*/
protected $state;
/**
* Constructs a StaticCommand instance.
*
* @param \Drupal\tome_static\StaticGeneratorInterface $static
* The static service.
* @param \Drupal\Core\State\StateInterface $state
* The state system.
*/
public function __construct(StaticGeneratorInterface $static) {
public function __construct(StaticGeneratorInterface $static, StateInterface $state) {
parent::__construct();
$this->static = $static;
$this->state = $state;
}
/**
......@@ -45,11 +66,13 @@ class StaticCommand extends CommandBase {
protected function configure() {
$this->setName('tome:static')
->setDescription('Exports all pages on your site to static HTML.')
->addOption('process-count', NULL, InputOption::VALUE_OPTIONAL, 'Limits the number of pages that will process at the same time.', static::PROCESS_COUNT)
->addOption('process-count', NULL, InputOption::VALUE_OPTIONAL, 'Limits the number of processes to run concurrently.', static::PROCESS_COUNT)
->addOption('path-count', NULL, InputOption::VALUE_OPTIONAL, 'The number of paths to export per process.', static::PATH_COUNT)
->addOption('run-server', NULL, InputOption::VALUE_NONE, 'If a local HTTP server should be started after the export.')
->addOption('port', NULL, InputOption::VALUE_OPTIONAL, 'The port to run the server on.', 8889)
->addOption('ignore-warnings', NULL, InputOption::VALUE_NONE, 'If configuration warnings should be shown.')
->addOption('path-pattern', NULL, InputOption::VALUE_OPTIONAL, 'If you only want to export a specific paths based on pattern.', '');
->addOption('path-pattern', NULL, InputOption::VALUE_OPTIONAL, 'If you only want to export a specific paths based on pattern.', '')
->addOption('yes', 'y', InputOption::VALUE_NONE, 'Assume "yes" as answer to all prompts,');
}
/**
......@@ -58,7 +81,18 @@ class StaticCommand extends CommandBase {
protected function execute(InputInterface $input, OutputInterface $output) {
$options = $input->getOptions();
$warnings = $this->getWarnings($options);
if ($this->state->get(StaticGeneratorInterface::STATE_KEY, FALSE)) {
if (!$options['yes'] && !$this->io()->confirm('Another user may be running a static build, proceed only if the last build failed unexpectedly. Ignore and continue build?', FALSE)) {
return 0;
}
}
$warnings = $this->getWarnings();
if (empty($options['uri']) || $options['uri'] === 'http://default') {
$warnings[] = 'No "--uri" option provided. This could lead to invalid absolute URLs. To resolve, pass the "--uri" option.';
}
if (!$options['ignore-warnings'] && $warnings) {
$warnings[] = 'To suppress these messages, pass the --ignore-warnings option.';
$this->io->comment($warnings);
......@@ -79,7 +113,7 @@ class StaticCommand extends CommandBase {
}
$this->io->writeln('Generating static HTML...');
$this->exportPaths($paths, [], $options['process-count'], TRUE, $options['uri']);
$this->exportPaths($paths, [], $options['process-count'], $options['path-count'], TRUE, $options['uri']);
$this->io->success('Exported static HTML and related assets.');
$this->static->cleanupStaticDirectory();
......@@ -87,7 +121,7 @@ class StaticCommand extends CommandBase {
if ($options['run-server']) {
$url = '127.0.0.1:' . $options['port'];
$this->startBrowser('http://' . $url . base_path(), 2);
$this->runCommand('php -S ' . escapeshellarg($url), $this->static->getStaticDirectory());
$this->runCommand('php -S ' . escapeshellarg($url), $this->static->getStaticDirectory(), NULL);
}
}
......@@ -100,12 +134,14 @@ class StaticCommand extends CommandBase {
* An array of paths that have already been processed.
* @param int $process_count
* The number of processes to invoke.
* @param int $path_count
* The number of paths to export per process.
* @param bool $show_progress
* Whether or not a progress bar should be shown.
* @param string $uri
* The URI of the site, probably passed by -l or --uri.
*/
protected function exportPaths(array $paths, array $old_paths, $process_count, $show_progress, $uri) {
protected function exportPaths(array $paths, array $old_paths, $process_count, $path_count, $show_progress, $uri) {
$paths = $this->static->exportPaths($paths);
if (empty($paths)) {
......@@ -118,16 +154,17 @@ class StaticCommand extends CommandBase {
}
$commands = [];
foreach ($paths as $path) {
$command = $this->executable . ' tome:static-export-path ' . escapeshellarg($path) . ' --return-json --process-count=' . escapeshellarg($process_count) . ' --uri=' . escapeshellarg($uri);
$chunks = array_chunk($paths, $path_count);
foreach ($chunks as $chunk) {
$command = $this->executable . ' tome:static-export-path ' . implode(',', $chunk) . ' --return-json --process-count=' . escapeshellarg($process_count) . ' --uri=' . escapeshellarg($uri);
$commands[] = $command;
}
$show_progress && $this->io->progressStart(count($paths));
$invoke_paths = [];
$collected_errors = $this->runCommands($commands, $process_count, function (Process $process) use ($show_progress, &$invoke_paths) {
$show_progress && $this->io->progressAdvance();
$collected_errors = $this->runCommands($commands, $process_count, function (Process $process) use ($show_progress, &$invoke_paths, $path_count) {
$show_progress && $this->io->progressAdvance($path_count);
$output = $process->getOutput();
if (!empty($output) && $json = json_decode($output, TRUE)) {
$invoke_paths = array_merge($invoke_paths, $json);
......@@ -143,51 +180,8 @@ class StaticCommand extends CommandBase {
}
if (count($invoke_paths)) {
$this->io->writeln('Processing related assets and paths...');
$this->exportPaths($invoke_paths, $old_paths, $process_count, $show_progress, $uri);
}
}
/**
* Collects warnings to help users correct issues in rendered HTML.
*
* @param array $options
* An array of options passed to the tome:static command.
*
* @return array
* An array of warning messages to display to the user.
*/
protected function getWarnings(array $options) {
$warnings = [];
if (empty($options['uri']) || $options['uri'] === 'http://default') {
$warnings[] = 'No "--uri" option provided. This could lead to invalid absolute URLs. To resolve, pass the "--uri" option.';
}
$performance_config = \Drupal::config('system.performance');
if (!$performance_config->get('css.preprocess') || !$performance_config->get('js.preprocess')) {
if (!$performance_config->get('css.preprocess') && !$performance_config->get('js.preprocess')) {
$message = 'CSS and JS preprocessing is disabled.';
}
elseif (!$performance_config->get('css.preprocess')) {
$message = 'CSS preprocessing is disabled.';
}
else {
$message = 'JS preprocessing is disabled.';
}
$warnings[] = $message . ' This could lead to performance issues. To resolve, visit /admin/config/development/performance.';
}
$twig_config = \Drupal::getContainer()->getParameter('twig.config');
if ($twig_config['debug'] || !$twig_config['cache']) {
if ($twig_config['debug'] && !$twig_config['cache']) {
$message = 'Twig debugging is enabled and caching is disabled.';
}
elseif ($twig_config['debug']) {
$message = 'Twig debugging is enabled.';
}
else {
$message = 'Twig caching is disabled.';
}
$warnings[] = $message . ' This could lead to performance issues. To resolve, edit the "twig.config" parameter in the "sites/*/services.yml" file.';
$this->exportPaths($invoke_paths, $old_paths, $process_count, $path_count, $show_progress, $uri);
}
return $warnings;
}
/**
......
......@@ -2,6 +2,10 @@
namespace Drupal\tome_static\Commands;
use Drupal\Core\State\StateInterface;
use Drupal\tome_static\RequestPreparer;
use Drupal\tome_static\StaticGeneratorInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
......@@ -11,14 +15,37 @@ use Symfony\Component\Console\Output\OutputInterface;
*/
class StaticExportPathCommand extends StaticCommand {
/**
* The request preparer.
*
* @var \Drupal\tome_static\RequestPreparer
*/
protected $requestPreparer;
/**
* Constructs a StaticCommand instance.
*
* @param \Drupal\tome_static\StaticGeneratorInterface $static
* The static service.
* @param \Drupal\Core\State\StateInterface $state
* The state system.
* @param \Drupal\tome_static\RequestPreparer $request_preparer
* The request preparer.
*/
public function __construct(StaticGeneratorInterface $static, StateInterface $state, RequestPreparer $request_preparer) {
parent::__construct($static, $state);
$this->requestPreparer = $request_preparer;
}
/**
* {@inheritdoc}
*/
protected function configure() {
$this->setName('tome:static-export-path')
->setDescription('Exports static HTML for a specific path.')
->addArgument('path')
->addOption('process-count', NULL, InputOption::VALUE_OPTIONAL, 'Limits the number of pages that will process at the same time.', static::PROCESS_COUNT)
->addArgument('chunk', InputArgument::REQUIRED, 'A comma separated list of paths.')
->addOption('process-count', NULL, InputOption::VALUE_OPTIONAL, 'Limits the number of processes to run concurrently.', static::PROCESS_COUNT)
->addOption('path-count', NULL, InputOption::VALUE_OPTIONAL, 'The number of paths to export per process.', static::PATH_COUNT)
->addOption('return-json', NULL, InputOption::VALUE_NONE, 'Whether or not paths that need invoking should be returned as JSON.');
}
......@@ -26,14 +53,24 @@ class StaticExportPathCommand extends StaticCommand {
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
$path = $input->getArgument('path');
$chunk = $input->getArgument('chunk');
$paths = explode(',', $chunk);
$invoke_paths = [];
foreach ($paths as $path) {
$this->requestPreparer->prepareForRequest();
try {
$invoke_paths = array_merge($this->static->requestPath($path), $invoke_paths);
}
catch (\Exception $e) {
$this->io->getErrorStyle()->error($this->formatPathException($path, $e));
}
}
$options = $input->getOptions();
$invoke_paths = $this->static->requestPath($path);
if ($options['return-json']) {
$this->io->write(json_encode($invoke_paths, JSON_PRETTY_PRINT));
}
else {
$this->exportPaths($invoke_paths, [$path], $options['process-count'], FALSE, $options['uri']);
$this->exportPaths($invoke_paths, $paths, $options['process-count'], $options['path-count'], FALSE, $options['uri']);
}
}
......
<?php
namespace Drupal\tome_static\Controller;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Archiver\ArchiveTar;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Url;
use Drupal\tome_static\StaticGeneratorInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
/**
* Contains routes related to Tome Static.
*/
class StaticDownloadController extends ControllerBase {
/**
* The static generator.
*
* @var \Drupal\tome_static\StaticGeneratorInterface
*/
protected $static;
/**
* StaticGeneratorForm constructor.
*
* @param \Drupal\tome_static\StaticGeneratorInterface $static
* The static generator.
*/
public function __construct(StaticGeneratorInterface $static) {
$this->static = $static;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('tome_static.generator')
);
}
/**
* Presents a user interface to download a static build.
*/
public function build() {
$build = [];
$download_url = Url::fromRoute('tome_static.download');
if ($download_url->access()) {
$build['description'] = [
'#type' => 'markup',
'#markup' => '<p>' . $this->t('Download the latest static build of this site as a gzipped tar file.') . '</p>',
];
$build['download'] = [
'#type' => 'link',
'#attributes' => [
'class' => ['button'],
],
'#title' => $this->t('Download'),
'#url' => $download_url,
];
}
else {
$build['description'] = [
'#type' => 'markup',
'#markup' => '<p>' . $this->t('No static build available for download. <a href=":generate">Click here to generate one.</a>', [
':generate' => Url::fromRoute('tome_static.generate')->toString(),
]) . '</p>',
];
}
return $build;
}
/**
* Downloads a tarball of the static build.
*/
public function download() {
$path = file_directory_temp() . '/tome_static_export.tar.gz';
$static_directory = $this->static->getStaticDirectory();
file_unmanaged_delete($path);
$archiver = new ArchiveTar($path, 'gz');
$archiver->addModify([$static_directory], '', $static_directory);
$response = new BinaryFileResponse($path, 200, [], FALSE);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_ATTACHMENT,
basename($path)
);
return $response;
}
/**
* Custom access callback to determine if there's anything to download.
*
* @return \Drupal\Core\Access\AccessResult
* The access result.
*/
public function downloadAccess() {
return AccessResult::allowedIf(file_exists($this->static->getStaticDirectory()) && (new \FilesystemIterator($this->static->getStaticDirectory()))->valid());
}
}
<?php
namespace Drupal\tome_static\Form;
use Drupal\Component\Utility\UrlHelper;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\Url;
use Drupal\tome_static\RequestPreparer;
use Drupal\tome_static\StaticGeneratorInterface;
use Drupal\tome_static\StaticUITrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Contains a form for initializing a static build.
*/
class StaticGeneratorForm extends FormBase {
use StaticUITrait;
/**
* The static generator.
*
* @var \Drupal\tome_static\StaticGeneratorInterface
*/
protected $static;
/**
* The state system.
*
* @var \Drupal\tome_static\StaticGeneratorInterface
*/
protected $state;
/**
* The request preparer.
*
* @var \Drupal\tome_static\RequestPreparer
*/
protected $requestPreparer;
/**
* StaticGeneratorForm constructor.
*
* @param \Drupal\tome_static\StaticGeneratorInterface $static
* The static generator.
* @param \Drupal\Core\State\StateInterface $state
* The state system.
* @param \Drupal\tome_static\RequestPreparer $request_preparer
* The request preparer.
*/
public function __construct(StaticGeneratorInterface $static, StateInterface $state, RequestPreparer $request_preparer) {
$this->static = $static;
$this->state = $state;
$this->requestPreparer = $request_preparer;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('tome_static.generator'),
$container->get('state'),
$container->get('tome_static.request_preparer')
);
}
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'tome_static_generator_form';
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['description'] = [
'#markup' => '<p>' . $this->t('Submitting this form will initiate a build of all uncached static pages site using Tome. Existing files in the static export directory (@dir) will be overridden.', [
'@dir' => $this->static->getStaticDirectory(),
]) . '</p>',
];
$form['base_url'] = [
'#type' => 'textfield',
'#title' => $this->t('Base URL'),
'#default_value' => 'http://127.0.0.1',
'#required' => TRUE,
'#size' => 30,
'#description' => $this->t('The absolute URL used for generating static pages. This should match the domain on the site where the static site will be deployed.'),
];
$warnings = $this->getWarnings();
if ($this->state->get(StaticGeneratorInterface::STATE_KEY, FALSE)) {
$warnings[] = $this->t('Another user may be running a static build, proceed only if the last build failed unexpectedly.');
}
if (!empty($warnings)) {
$form['warnings'] = [
'#type' => 'container',
'title' => [
'#markup' => '<strong>' . $this->t('Build warnings') . '</strong>',
],
'list' => [
'#theme' => 'item_list',
'#items' => [],
],
];
foreach ($warnings as $warning) {
$form['warnings']['list']['#items'][] = [
'#markup' => $warning,
];
}
}
$form['actions'] = [
'#type' => 'actions',
];
$form['actions']['submit'] = [
'#type' => 'submit',
'#value' => $this->t('Submit'),
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateForm(array &$form, FormStateInterface $form_state) {
if (!UrlHelper::isValid($form_state->getValue('base_url'), TRUE)) {
$form_state->setError($form['base_url'], $this->t('The provided URL is not valid.'));
}
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$this->state->set(StaticGeneratorInterface::STATE_KEY, TRUE);
$base_url = $form_state->getValue('base_url');
$this->static->prepareStaticDirectory();
$original_server = $this->getRequest()->server->all();
$server = $this->setBaseUrl($original_server, $base_url);
$this->getRequest()->server->replace($server);
$paths = $this->static->getPaths();
$this->getRequest()->server->replace($original_server);
$this->setBatch($paths, $base_url);
}
/**
* Exports all remaining paths at the end of a previous batch.
*
* @param string $base_url
* The base URL.
* @param array $context
* The batch context.
*/
public function batchInvokePaths($base_url, array &$context) {
if (!empty($context['results']['invoke_paths'])) {
$context['results']['old_paths'] = isset($context['results']['old_paths']) ? $context['results']['old_paths'] : [];
$context['results']['invoke_paths'] = array_diff($context['results']['invoke_paths'], $context['results']['old_paths']);
$context['results']['old_paths'] = array_merge($context['results']['invoke_paths'], $context['results']['old_paths']);
$invoke_paths = $this->static->exportPaths($context['results']['invoke_paths']);
if (!empty($invoke_paths)) {
$this->setBatch($invoke_paths, $base_url);
}
}
}
/**
* Exports a path using Tome.
*
* @param string $path
* The path to export.
* @param string $base_url
* The base URL.
* @param array $context
* The batch context.
*/
public function exportPath($path, $base_url, array &$context) {
$original_server = $this->getRequest()->server->all();
$server = $this->setBaseUrl($original_server, $base_url);
$this->getRequest()->server->replace($server);
$this->requestPreparer->prepareForRequest();
try {
$invoke_paths = $this->static->requestPath($path);
}
catch (\Exception $e) {
$context['results']['errors'][] = $this->formatPathException($path, $e);
$invoke_paths = [];
}
$this->getRequest()->server->replace($original_server);
$context['results']['invoke_paths'] = isset($context['results']['invoke_paths']) ? $context['results']['invoke_paths'] : [];
$context['results']['invoke_paths'] = array_merge($context['results']['invoke_paths'], $invoke_paths);
}
/**
* Batch finished callback after all paths and assets have been exported.
*
* @param bool $success