Issue #2982684 by greg.1.anderson, Mile23, Mixologic, webflo, alexpott, yogeshmpawar, pingwin4eg, vijaycs85, larowlan, dww, borisson_, phenaproxima, kim.pepper, bojanz, grasmash, hctom, kmbremner, pingers, Jax, sherakama, derhasi, claudiu.cristea, jhedstrom, Xano, Grimreaper: Add a composer scaffolding plugin to core

merge-requests/1119/head
Lee Rowlands 2019-07-11 07:47:33 +10:00
parent c1734c6185
commit 3bb50b2b3f
No known key found for this signature in database
GPG Key ID: 2B829A3DF9204DC4
96 changed files with 5616 additions and 0 deletions

View File

@ -110,6 +110,7 @@
"drupal/core-proxy-builder": "self.version",
"drupal/core-render": "self.version",
"drupal/core-serialization": "self.version",
"drupal/core-composer-scaffold": "self.version",
"drupal/core-transliteration": "self.version",
"drupal/core-utility": "self.version",
"drupal/core-uuid": "self.version",
@ -201,6 +202,7 @@
"core/lib/Drupal/Component/ProxyBuilder/composer.json",
"core/lib/Drupal/Component/Render/composer.json",
"core/lib/Drupal/Component/Serialization/composer.json",
"core/lib/Drupal/Component/Scaffold/composer.json",
"core/lib/Drupal/Component/Transliteration/composer.json",
"core/lib/Drupal/Component/Utility/composer.json",
"core/lib/Drupal/Component/Uuid/composer.json",

View File

@ -0,0 +1,166 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
/**
* Determine recursively which packages have been allowed to scaffold files.
*
* If the root-level composer.json allows drupal/core, and drupal/core allows
* drupal/assets, then the later package will also implicitly be allowed.
*/
class AllowedPackages implements PostPackageEventListenerInterface {
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* Composer's I/O service.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* Manager of the options in the top-level composer.json's 'extra' section.
*
* @var \Drupal\Component\Scaffold\ManageOptions
*/
protected $manageOptions;
/**
* The list of new packages added by this Composer command.
*
* @var array
*/
protected $newPackages = [];
/**
* AllowedPackages constructor.
*
* @param \Composer\Composer $composer
* The composer object.
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param \Drupal\Component\Scaffold\ManageOptions $manage_options
* Manager of the options in the top-level composer.json's 'extra' section.
*/
public function __construct(Composer $composer, IOInterface $io, ManageOptions $manage_options) {
$this->composer = $composer;
$this->io = $io;
$this->manageOptions = $manage_options;
}
/**
* Gets a list of all packages that are allowed to copy scaffold files.
*
* Configuration for packages specified later will override configuration
* specified by packages listed earlier. In other words, the last listed
* package has the highest priority. The root package will always be returned
* at the end of the list.
*
* @return \Composer\Package\PackageInterface[]
* An array of allowed Composer packages.
*/
public function getAllowedPackages() {
$options = $this->manageOptions->getOptions();
$allowed_packages = $this->recursiveGetAllowedPackages($options->allowedPackages());
// If the root package defines any file mappings, then implicitly add it
// to the list of allowed packages. Add it at the end so that it overrides
// all the preceding packages.
if ($options->hasFileMapping()) {
$root_package = $this->composer->getPackage();
unset($allowed_packages[$root_package->getName()]);
$allowed_packages[$root_package->getName()] = $root_package;
}
// Handle any newly-added packages that are not already allowed.
return $this->evaluateNewPackages($allowed_packages);
}
/**
* {@inheritdoc}
*/
public function event(PackageEvent $event) {
$operation = $event->getOperation();
// Determine the package.
$package = $operation->getJobType() == 'update' ? $operation->getTargetPackage() : $operation->getPackage();
if (ScaffoldOptions::hasOptions($package->getExtra())) {
$this->newPackages[$package->getName()] = $package;
}
}
/**
* Builds a name-to-package mapping from a list of package names.
*
* @param string[] $packages_to_allow
* List of package names to allow.
* @param array $allowed_packages
* Mapping of package names to PackageInterface of packages already
* accumulated.
*
* @return \Composer\Package\PackageInterface[]
* Mapping of package names to PackageInterface in priority order.
*/
protected function recursiveGetAllowedPackages(array $packages_to_allow, array $allowed_packages = []) {
foreach ($packages_to_allow as $name) {
$package = $this->getPackage($name);
if ($package instanceof PackageInterface && !isset($allowed_packages[$name])) {
$allowed_packages[$name] = $package;
$package_options = $this->manageOptions->packageOptions($package);
$allowed_packages = $this->recursiveGetAllowedPackages($package_options->allowedPackages(), $allowed_packages);
}
}
return $allowed_packages;
}
/**
* Evaluates newly-added packages and see if they are already allowed.
*
* For now we will only emit warnings if they are not.
*
* @param array $allowed_packages
* Mapping of package names to PackageInterface of packages already
* accumulated.
*
* @return \Composer\Package\PackageInterface[]
* Mapping of package names to PackageInterface in priority order.
*/
protected function evaluateNewPackages(array $allowed_packages) {
foreach ($this->newPackages as $name => $newPackage) {
if (!array_key_exists($name, $allowed_packages)) {
$this->io->write("Not scaffolding files for <comment>{$name}</comment>, because it is not listed in the element 'extra.composer-scaffold.allowed-packages' in the root-level composer.json file.");
}
else {
$this->io->write("Package <comment>{$name}</comment> has scaffold operations, and is already allowed in the root-level composer.json file.");
}
}
// @todo We could prompt the user and ask if they wish to allow a
// newly-added package. This might be useful if, for example, the user
// might wish to require an installation profile that contains scaffolded
// assets. For more information, see:
// https://www.drupal.org/project/drupal/issues/3064990
return $allowed_packages;
}
/**
* Retrieves a package from the current composer process.
*
* @param string $name
* Name of the package to get from the current composer installation.
*
* @return \Composer\Package\PackageInterface|null
* The Composer package.
*/
protected function getPackage($name) {
return $this->composer->getRepositoryManager()->getLocalRepository()->findPackage($name, '*');
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
/**
* List of all commands provided by this package.
*/
class CommandProvider implements CommandProviderCapability {
/**
* {@inheritdoc}
*/
public function getCommands() {
return [new ComposerScaffoldCommand()];
}
}

View File

@ -0,0 +1,52 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Command\BaseCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* The "composer:scaffold" command class.
*
* Manually run the scaffold operation that normally happens after
* 'composer install'.
*/
class ComposerScaffoldCommand extends BaseCommand {
/**
* {@inheritdoc}
*/
protected function configure() {
$this
->setName('composer:scaffold')
->setDescription('Update the Composer scaffold files.')
->setHelp(
<<<EOT
The <info>composer:scaffold</info> command places the scaffold files in their
respective locations according to the layout stipulated in the composer.json
file.
<info>php composer.phar composer:scaffold</info>
It is usually not necessary to call <info>composer:scaffold</info> manually,
because it is called automatically as needed, e.g. after an <info>install</info>
or <info>update</info> command. Note, though, that only packages explicitly
allowed to scaffold in the top-level composer.json will be processed by this
command.
For more information, see https://www.drupal.org/docs/develop/using-composer/using-drupals-composer-scaffold.
EOT
);
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output) {
$handler = new Handler($this->getComposer(), $this->getIO());
$handler->scaffold();
}
}

View File

@ -0,0 +1,122 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\Operations\ScaffoldResult;
/**
* Generates an 'autoload.php' that includes the autoloader created by Composer.
*/
final class GenerateAutoloadReferenceFile {
/**
* This class provides only static methods.
*/
private function __construct() {
}
/**
* Generates the autoload file at the specified location.
*
* This only writes a bit of PHP that includes the autoload file that
* Composer generated. Drupal does this so that it can guarantee that there
* will always be an `autoload.php` file in a well-known location.
*
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
* @param string $vendor
* The path to the vendor directory.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* The result of the autoload file generation.
*/
public static function generateAutoload(IOInterface $io, $package_name, $web_root, $vendor) {
$autoload_path = static::autoloadPath($package_name, $web_root);
$location = dirname($autoload_path->fullPath());
// Calculate the relative path from the webroot (location of the project
// autoload.php) to the vendor directory.
$fs = new Filesystem();
$relative_vendor_path = $fs->findShortestPath(realpath($location), $vendor);
file_put_contents($autoload_path->fullPath(), static::autoLoadContents($relative_vendor_path));
return new ScaffoldResult($autoload_path, TRUE);
}
/**
* Determines whether or not the autoload file has been committed.
*
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
*
* @return bool
* True if autoload.php file exists and has been committed to the repository
*/
public static function autoloadFileCommitted(IOInterface $io, $package_name, $web_root) {
$autoload_path = static::autoloadPath($package_name, $web_root);
$location = dirname($autoload_path->fullPath());
if (!file_exists($location)) {
return FALSE;
}
return Git::checkTracked($io, $location, $location);
}
/**
* Generates a scaffold file path object for the autoload file.
*
* @param string $package_name
* The name of the package defining the autoload file (the root package).
* @param string $web_root
* The path to the web root.
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* Object wrapping the relative and absolute path to the destination file.
*/
protected static function autoloadPath($package_name, $web_root) {
$rel_path = 'autoload.php';
$dest_rel_path = '[web-root]/' . $rel_path;
$dest_full_path = $web_root . '/' . $rel_path;
return new ScaffoldFilePath('autoload', $package_name, $dest_rel_path, $dest_full_path);
}
/**
* Builds the contents of the autoload file.
*
* @param string $vendor_path
* The relative path to vendor.
*
* @return string
* Return the contents for the autoload.php.
*/
protected static function autoLoadContents($vendor_path) {
$vendor_path = rtrim($vendor_path, '/');
return <<<EOF
<?php
/**
* @file
* Includes the autoloader created by Composer.
*
* This file was generated by composer-scaffold.
*.
* @see composer.json
* @see index.php
* @see core/install.php
* @see core/rebuild.php
* @see core/modules/statistics/statistics.php
*/
return require __DIR__ . '/{$vendor_path}/autoload.php';
EOF;
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
use Composer\Util\ProcessExecutor;
/**
* Provide some Git utility operations
*/
class Git {
/**
* This class provides only static methods.
*/
private function __construct() {
}
/**
* Determines whether the specified scaffold file is already ignored.
*
* @param string $path
* Path to scaffold file to check.
* @param string $dir
* Base directory for git process.
*
* @return bool
* Whether the specified file is already ignored or not (TRUE if ignored).
*/
public static function checkIgnore(IOInterface $io, $path, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git check-ignore ' . $process->escape($path), $output, $dir);
return $exitCode == 0;
}
/**
* Determines whether the specified scaffold file is tracked by git.
*
* @param string $path
* Path to scaffold file to check.
* @param string $dir
* Base directory for git process.
*
* @return bool
* Whether the specified file is already tracked or not (TRUE if tracked).
*/
public static function checkTracked(IOInterface $io, $path, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git ls-files --error-unmatch ' . $process->escape($path), $output, $dir);
return $exitCode == 0;
}
/**
* Checks to see if the project root dir is in a git repository.
*
* @param string $dir
* Base directory for git process.
* @return bool
* True if this is a repository.
*/
public static function isRepository(IOInterface $io, $dir = NULL) {
$process = new ProcessExecutor($io);
$output = '';
$exitCode = $process->execute('git rev-parse --show-toplevel', $output, $dir);
return $exitCode == 0;
}
}

View File

@ -0,0 +1,244 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\EventDispatcher\EventDispatcher;
use Composer\Installer\PackageEvent;
use Composer\IO\IOInterface;
use Composer\Package\PackageInterface;
use Composer\Plugin\CommandEvent;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\Operations\OperationData;
use Drupal\Component\Scaffold\Operations\OperationFactory;
use Drupal\Component\Scaffold\Operations\ScaffoldFileCollection;
/**
* Core class of the plugin.
*
* Contains the primary logic which determines the files to be fetched and
* processed.
*/
class Handler {
/**
* Composer hook called before scaffolding begins.
*/
const PRE_COMPOSER_SCAFFOLD_CMD = 'pre-composer-scaffold-cmd';
/**
* Composer hook called after scaffolding completes.
*/
const POST_COMPOSER_SCAFFOLD_CMD = 'post-composer-scaffold-cmd';
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* Composer's I/O service.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* The scaffold options in the top-level composer.json's 'extra' section.
*
* @var \Drupal\Component\Scaffold\ManageOptions
*/
protected $manageOptions;
/**
* The manager that keeps track of which packages are allowed to scaffold.
*
* @var \Drupal\Component\Scaffold\AllowedPackages
*/
protected $manageAllowedPackages;
/**
* The list of listeners that are notified after a package event.
*
* @var \Drupal\Component\Scaffold\PostPackageEventListenerInterface[]
*/
protected $postPackageListeners = [];
/**
* Handler constructor.
*
* @param \Composer\Composer $composer
* The Composer service.
* @param \Composer\IO\IOInterface $io
* The Composer I/O service.
*/
public function __construct(Composer $composer, IOInterface $io) {
$this->composer = $composer;
$this->io = $io;
$this->manageOptions = new ManageOptions($composer);
$this->manageAllowedPackages = new AllowedPackages($composer, $io, $this->manageOptions);
}
/**
* Registers post-package events before any 'require' event runs.
*
* This method is called by composer prior to doing a 'require' command.
*
* @param \Composer\Plugin\CommandEvent $event
* The Composer Command event.
*/
public function beforeRequire(CommandEvent $event) {
// In order to differentiate between post-package events called after
// 'composer require' vs. the same events called at other times, we will
// only install our handler when a 'require' event is detected.
$this->postPackageListeners[] = $this->manageAllowedPackages;
}
/**
* Posts package command event.
*
* We want to detect packages 'require'd that have scaffold files, but are not
* yet allowed in the top-level composer.json file.
*
* @param \Composer\Installer\PackageEvent $event
* Composer package event sent on install/update/remove.
*/
public function onPostPackageEvent(PackageEvent $event) {
foreach ($this->postPackageListeners as $listener) {
$listener->event($event);
}
}
/**
* Creates scaffold operation objects for all items in the file mappings.
*
* @param \Composer\Package\PackageInterface $package
* The package that relative paths will be relative from.
* @param array $package_file_mappings
* The package file mappings array keyed by destination path and the values
* are operation metadata arrays.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* A list of scaffolding operation objects
*/
protected function createScaffoldOperations(PackageInterface $package, array $package_file_mappings) {
$scaffold_op_factory = new OperationFactory($this->composer);
$scaffold_ops = [];
foreach ($package_file_mappings as $dest_rel_path => $data) {
$operation_data = new OperationData($dest_rel_path, $data);
$scaffold_ops[$dest_rel_path] = $scaffold_op_factory->create($package, $operation_data);
}
return $scaffold_ops;
}
/**
* Copies all scaffold files from source to destination.
*/
public function scaffold() {
// Recursively get the list of allowed packages. Only allowed packages
// may declare scaffold files. Note that the top-level composer.json file
// is implicitly allowed.
$allowed_packages = $this->manageAllowedPackages->getAllowedPackages();
if (empty($allowed_packages)) {
$this->io->write("Nothing scaffolded because no packages are allowed in the top-level composer.json file.");
return;
}
// Call any pre-scaffold scripts that may be defined.
$dispatcher = new EventDispatcher($this->composer, $this->io);
$dispatcher->dispatch(self::PRE_COMPOSER_SCAFFOLD_CMD);
// Fetch the list of file mappings from each allowed package and normalize
// them.
$file_mappings = $this->getFileMappingsFromPackages($allowed_packages);
$location_replacements = $this->manageOptions->getLocationReplacements();
$scaffold_options = $this->manageOptions->getOptions();
// Create a collection of scaffolded files to process. This determines which
// take priority and which are conjoined.
$scaffold_files = new ScaffoldFileCollection($file_mappings, $location_replacements);
// Process the list of scaffolded files.
$scaffold_results = ScaffoldFileCollection::process($scaffold_files, $this->io, $scaffold_options);
// Generate an autoload file in the document root that includes the
// autoload.php file in the vendor directory, wherever that is. Drupal
// requires this in order to easily locate relocated vendor dirs.
$web_root = $this->manageOptions->getOptions()->getLocation('web-root');
if (!GenerateAutoloadReferenceFile::autoloadFileCommitted($this->io, $this->rootPackageName(), $web_root)) {
$scaffold_results[] = GenerateAutoloadReferenceFile::generateAutoload($this->io, $this->rootPackageName(), $web_root, $this->getVendorPath());
}
// Add the managed scaffold files to .gitignore if applicable.
$gitIgnoreManager = new ManageGitIgnore($this->io, getcwd());
$gitIgnoreManager->manageIgnored($scaffold_results, $scaffold_options);
// Call post-scaffold scripts.
$dispatcher->dispatch(self::POST_COMPOSER_SCAFFOLD_CMD);
}
/**
* Gets the path to the 'vendor' directory.
*
* @return string
* The file path of the vendor directory.
*/
protected function getVendorPath() {
$vendor_dir = $this->composer->getConfig()->get('vendor-dir');
$filesystem = new Filesystem();
return $filesystem->normalizePath(realpath($vendor_dir));
}
/**
* Gets a consolidated list of file mappings from all allowed packages.
*
* @param \Composer\Package\Package[] $allowed_packages
* A multidimensional array of file mappings, as returned by
* self::getAllowedPackages().
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* An array of destination paths => scaffold operation objects.
*/
protected function getFileMappingsFromPackages(array $allowed_packages) {
$file_mappings = [];
foreach ($allowed_packages as $package_name => $package) {
$file_mappings[$package_name] = $this->getPackageFileMappings($package);
}
return $file_mappings;
}
/**
* Gets the array of file mappings provided by a given package.
*
* @param \Composer\Package\PackageInterface $package
* The Composer package from which to get the file mappings.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface[]
* An array of destination paths => scaffold operation objects.
*/
protected function getPackageFileMappings(PackageInterface $package) {
$options = $this->manageOptions->packageOptions($package);
if ($options->hasFileMapping()) {
return $this->createScaffoldOperations($package, $options->fileMapping());
}
if (!$options->hasAllowedPackages()) {
$this->io->writeError("The allowed package {$package->getName()} does not provide a file mapping for Composer Scaffold.");
}
return [];
}
/**
* Gets the root package name.
*
* @return string
* The package name of the root project
*/
protected function rootPackageName() {
$root_package = $this->composer->getPackage();
return $root_package->getName();
}
}

View File

@ -0,0 +1,155 @@
<?php
namespace Drupal\Component\Scaffold;
/**
* Injects config values from an associative array into a string.
*/
class Interpolator {
/**
* The character sequence that identifies the start of a token.
*
* @var string
*/
protected $startToken;
/**
* The character sequence that identifies the end of a token.
*
* @var string
*/
protected $endToken;
/**
* The associative array of replacements.
*
* @var array
*/
protected $data = [];
/**
* Interpolator constructor.
*
* @param string $start_token
* The start marker for a token, e.g. '['.
* @param string $end_token
* The end marker for a token, e.g. ']'.
*/
public function __construct($start_token = '\\[', $end_token = '\\]') {
$this->startToken = $start_token;
$this->endToken = $end_token;
}
/**
* Sets the data set to use when interpolating.
*
* @param array $data
* The key:value pairs to use when interpolating.
*
* @return $this
*/
public function setData(array $data) {
$this->data = $data;
return $this;
}
/**
* Adds to the data set to use when interpolating.
*
* @param array $data
* The key:value pairs to use when interpolating.
*
* @return $this
*/
public function addData(array $data) {
$this->data = array_merge($this->data, $data);
return $this;
}
/**
* Replaces tokens in a string with values from an associative array.
*
* Tokens are surrounded by delimiters, e.g. square brackets "[key]". The
* characters that surround the key may be defined when the Interpolator is
* constructed.
*
* Example:
* If the message is 'Hello, [user.name]', then the value of the user.name
* item is fetched from the array, and the token [user.name] is replaced with
* the result.
*
* @param string $message
* Message containing tokens to be replaced.
* @param array $extra
* Data to use for interpolation in addition to whatever was provided to
* self::setData().
* @param string|bool $default
* (optional) The value to substitute for tokens that are not found in the
* data. If FALSE, then missing tokens are not replaced. Defaults to an
* empty string.
*
* @return string
* The message after replacements have been made.
*/
public function interpolate($message, array $extra = [], $default = '') {
$data = $extra + $this->data;
$replacements = $this->replacements($message, $data, $default);
return strtr($message, $replacements);
}
/**
* Finds the tokens that exist in a message and builds a replacement array.
*
* All of the replacements in the data array are looked up given the token
* keys from the provided message. Keys that do not exist in the configuration
* are replaced with the default value.
*
* @param string $message
* String with tokens.
* @param array $data
* Data to use for interpolation.
* @param string $default
* (optional) The value to substitute for tokens that are not found in the
* data. If FALSE, then missing tokens are not replaced. Defaults to an
* empty string.
*
* @return string[]
* An array of replacements to make. Keyed by tokens and the replacements
* are the values.
*/
protected function replacements($message, array $data, $default = '') {
$tokens = $this->findTokens($message);
$replacements = [];
foreach ($tokens as $sourceText => $key) {
$replacement_text = array_key_exists($key, $data) ? $data[$key] : $default;
if ($replacement_text !== FALSE) {
$replacements[$sourceText] = $replacement_text;
}
}
return $replacements;
}
/**
* Finds all of the tokens in the provided message.
*
* @param string $message
* String with tokens.
*
* @return string[]
* map of token to key, e.g. {{key}} => key
*/
protected function findTokens($message) {
$reg_ex = '#' . $this->startToken . '([a-zA-Z0-9._-]+)' . $this->endToken . '#';
if (!preg_match_all($reg_ex, $message, $matches, PREG_SET_ORDER)) {
return [];
}
$tokens = [];
foreach ($matches as $matchSet) {
list($sourceText, $key) = $matchSet;
$tokens[$sourceText] = $key;
}
return $tokens;
}
}

View File

@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

View File

@ -0,0 +1,124 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
/**
* Manage the .gitignore file.
*/
class ManageGitIgnore {
/**
* Composer's I/O service.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* The directory where the project is located.
*
* @var string
*/
protected $dir;
/**
* ManageGitIgnore constructor.
*
* @param string $dir
* The directory where the project is located.
*/
public function __construct(IOInterface $io, $dir) {
$this->io = $io;
$this->dir = $dir;
}
/**
* Manages gitignore files.
*
* @param \Drupal\Component\Scaffold\Operations\ScaffoldResult[] $files
* A list of scaffold results, each of which holds a path and whether
* or not that file is managed.
* @param \Drupal\Component\Scaffold\ScaffoldOptions $options
* Configuration options from the composer.json extras section.
*/
public function manageIgnored(array $files, ScaffoldOptions $options) {
if (!$this->managementOfGitIgnoreEnabled($options)) {
return;
}
// Accumulate entries to add to .gitignore, sorted into buckets based on the
// location of the .gitignore file the entry should be added to.
$add_to_git_ignore = [];
foreach ($files as $scaffoldResult) {
$path = $scaffoldResult->destination()->fullPath();
$is_ignored = Git::checkIgnore($this->io, $path, $this->dir);
if (!$is_ignored) {
$is_tracked = Git::checkTracked($this->io, $path, $this->dir);
if (!$is_tracked && $scaffoldResult->isManaged()) {
$dir = realpath(dirname($path));
$name = basename($path);
$add_to_git_ignore[$dir][] = $name;
}
}
}
// Write out the .gitignore files one at a time.
foreach ($add_to_git_ignore as $dir => $entries) {
$this->addToGitIgnore($dir, $entries);
}
}
/**
* Determines whether we should manage gitignore files.
*
* @param \Drupal\Component\Scaffold\ScaffoldOptions $options
* Configuration options from the composer.json extras section.
*
* @return bool
* Whether or not gitignore files should be managed.
*/
protected function managementOfGitIgnoreEnabled(ScaffoldOptions $options) {
// If the composer.json stipulates whether gitignore is managed or not, then
// follow its recommendation.
if ($options->hasGitIgnore()) {
return $options->gitIgnore();
}
// Do not manage .gitignore if there is no repository here.
if (!Git::isRepository($this->io, $this->dir)) {
return FALSE;
}
// If the composer.json did not specify whether or not .gitignore files
// should be managed, then manage them if the vendor directory is not
// committed.
return !Git::checkTracked($this->io, 'vendor', $this->dir);
}
/**
* Adds a set of entries to the specified .gitignore file.
*
* @param string $dir
* Path to directory where gitignore should be written.
* @param string[] $entries
* Entries to write to .gitignore file.
*/
protected function addToGitIgnore($dir, array $entries) {
sort($entries);
$git_ignore_path = $dir . '/.gitignore';
$contents = '';
// Appending to existing .gitignore files.
if (file_exists($git_ignore_path)) {
$contents = file_get_contents($git_ignore_path);
if (!empty($contents) && substr($contents, -1) != "\n") {
$contents .= "\n";
}
}
$contents .= implode("\n", $entries);
file_put_contents($git_ignore_path, $contents);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\Package\PackageInterface;
use Composer\Util\Filesystem;
/**
* Per-project options from the 'extras' section of the composer.json file.
*
* Projects that describe scaffold files do so via their scaffold options.
* This data is pulled from the 'composer-scaffold' portion of the extras
* section of the project data.
*/
class ManageOptions {
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* ManageOptions constructor.
*
* @param \Composer\Composer $composer
* The Composer service.
*/
public function __construct(Composer $composer) {
$this->composer = $composer;
}
/**
* Gets the root-level scaffold options for this project.
*
* @return \Drupal\Component\Scaffold\ScaffoldOptions
* The scaffold options object.
*/
public function getOptions() {
return $this->packageOptions($this->composer->getPackage());
}
/**
* Gets the scaffold options for the stipulated project.
*
* @param \Composer\Package\PackageInterface $package
* The package to fetch the scaffold options from.
*
* @return \Drupal\Component\Scaffold\ScaffoldOptions
* The scaffold options object.
*/
public function packageOptions(PackageInterface $package) {
return ScaffoldOptions::create($package->getExtra());
}
/**
* Creates an interpolator for the 'locations' element.
*
* The interpolator returned will replace a path string with the tokens
* defined in the 'locations' element.
*
* Note that only the root package may define locations.
*
* @return \Drupal\Component\Scaffold\Interpolator
* Interpolator that will do replacements in a string using tokens in
* 'locations' element.
*/
public function getLocationReplacements() {
return (new Interpolator())->setData($this->ensureLocations());
}
/**
* Ensures that all of the locations defined in the scaffold files exist.
*
* Create them on the filesystem if they do not.
*/
protected function ensureLocations() {
$fs = new Filesystem();
$locations = $this->getOptions()->locations() + ['web_root' => './'];
$locations = array_map(function ($location) use ($fs) {
$fs->ensureDirectoryExists($location);
$location = realpath($location);
return $location;
}, $locations);
return $locations;
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Scaffold operation to add to the beginning and/or end of a scaffold file.
*/
class AppendOp implements OperationInterface, ConjoinableInterface {
/**
* Identifies Append operations.
*/
const ID = 'append';
/**
* Path to the source file to prepend, if any.
*
* @var \Drupal\Component\Scaffold\ScaffoldFilePath
*/
protected $prepend;
/**
* Path to the source file to append, if any.
*
* @var \Drupal\Component\Scaffold\ScaffoldFilePath
*/
protected $append;
/**
* Constructs an AppendOp.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $prepend_path
* The relative path to the prepend file.
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $append_path
* The relative path to the append file.
*/
public function __construct(ScaffoldFilePath $prepend_path = NULL, ScaffoldFilePath $append_path = NULL) {
$this->prepend = $prepend_path;
$this->append = $append_path;
}
/**
* {@inheritdoc}
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
$destination_path = $destination->fullPath();
if (!file_exists($destination_path)) {
throw new \RuntimeException($destination->getInterpolator()->interpolate("Cannot append/prepend because no prior package provided a scaffold file at that [dest-rel-path]."));
}
$interpolator = $destination->getInterpolator();
// Fetch the prepend contents, if provided.
$prepend_contents = '';
if (!empty($this->prepend)) {
$this->prepend->addInterpolationData($interpolator, 'prepend');
$prepend_contents = file_get_contents($this->prepend->fullPath()) . "\n";
$io->write($interpolator->interpolate(" - Prepend to <info>[dest-rel-path]</info> from <info>[prepend-rel-path]</info>"));
}
// Fetch the append contents, if provided.
$append_contents = '';
if (!empty($this->append)) {
$this->append->addInterpolationData($interpolator, 'append');
$append_contents = "\n" . file_get_contents($this->append->fullPath());
$io->write($interpolator->interpolate(" - Append to <info>[dest-rel-path]</info> from <info>[append-rel-path]</info>"));
}
if (!empty(trim($prepend_contents)) || !empty(trim($append_contents))) {
// None of our asset files are very large, so we will load each one into
// memory for processing.
$original_contents = file_get_contents($destination_path);
// Write the appended and prepended contents back to the file.
$altered_contents = $prepend_contents . $original_contents . $append_contents;
file_put_contents($destination_path, $altered_contents);
}
else {
$io->write($interpolator->interpolate(" - Keep <info>[dest-rel-path]</info> unchanged: no content to prepend / append was provided."));
}
return new ScaffoldResult($destination, TRUE);
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
/**
* Marker interface indicating that operation is conjoinable.
*
* A conjoinable operation is one that runs in addition to any previous
* operation defined at the same destination path. Operations that are
* not conjoinable simply replace anything at the same destination path.
*/
interface ConjoinableInterface {
}

View File

@ -0,0 +1,51 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Joins two operations on the same file into a single operation.
*/
class ConjunctionOp implements OperationInterface {
/**
* The first operation.
*
* @var \Drupal\Component\Scaffold\Operations\OperationInterface
*/
protected $firstOperation;
/**
* The second operation.
*
* @var \Drupal\Component\Scaffold\Operations\OperationInterface
*/
protected $secondOperation;
/**
* ConjunctionOp constructor.
*
* @param \Drupal\Component\Scaffold\Operations\OperationInterface $first_operation
* @param \Drupal\Component\Scaffold\Operations\OperationInterface $second_operation
*/
public function __construct(OperationInterface $first_operation, OperationInterface $second_operation) {
$this->firstOperation = $first_operation;
$this->secondOperation = $second_operation;
}
/**
* {@inheritdoc}
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
$destination_path = $destination->fullPath();
// First, scaffold the original file. Disable symlinking, because we
// need a copy of the file if we're going to append / prepend to it.
@unlink($destination_path);
$this->firstOperation->process($destination, $io, $options->overrideSymlink(FALSE));
return $this->secondOperation->process($destination, $io, $options);
}
}

View File

@ -0,0 +1,171 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
/**
* Holds parameter data for operation objects during operation creation only.
*/
class OperationData {
const MODE = 'mode';
const PATH = 'path';
const OVERWRITE = 'overwrite';
const PREPEND = 'prepend';
const APPEND = 'append';
/**
* The parameter data.
*
* @var array
*/
protected $data;
/**
* The destination path
*
* @var string
*/
protected $destination;
/**
* OperationData constructor.
*
* @param mixed $data
* The raw data array to wrap.
*/
public function __construct($destination, $data) {
$this->destination = $destination;
$this->data = $this->normalizeScaffoldMetadata($destination, $data);
}
/**
* Gets the destination path that this operation data is associated with.
*
* @return string
* The destination path for the scaffold result.
*/
public function destination() {
return $this->destination;
}
/**
* Gets operation mode
*
* @return string
* Operation mode.
*/
public function mode() {
return $this->data[self::MODE];
}
/**
* Checks if path exists
*
* @return bool
* Returns true if path exists
*/
public function hasPath() {
return isset($this->data[self::PATH]);
}
/**
* Gets path
*
* @return string
* The path.
*/
public function path() {
return $this->data[self::PATH];
}
/**
* Determines overwrite.
*
* @return bool
* Returns true if overwrite mode was selected.
*/
public function overwrite() {
return isset($this->data[self::OVERWRITE]) ? $this->data[self::OVERWRITE] : TRUE;
}
/**
* Checks if prepend path exists.
*
* @return bool
* Returns true if prepend exists.
*/
public function hasPrepend() {
return isset($this->data[self::PREPEND]);
}
/**
* Gets prepend path.
*
* @return string
* Path to prepend data
*/
public function prepend() {
return $this->data[self::PREPEND];
}
/**
* Checks if append path exists.
*
* @return bool
* Returns true if prepend exists.
*/
public function hasAppend() {
return isset($this->data[self::APPEND]);
}
/**
* Gets append path.
*
* @return string
* Path to append data
*/
public function append() {
return $this->data[self::APPEND];
}
/**
* Normalizes metadata by converting literal values into arrays.
*
* Conversions performed include:
* - Boolean 'false' means "skip".
* - A string means "replace", with the string value becoming the path.
*
* @param string $destination
* The destination path for the scaffold file.
* @param mixed $value
* The metadata for this operation object, which varies by operation type.
*
* @return array
* Normalized scaffold metadata.
*/
protected function normalizeScaffoldMetadata($destination, $value) {
if (is_bool($value)) {
if (!$value) {
return [self::MODE => SkipOp::ID];
}
throw new \RuntimeException("File mapping {$destination} cannot be given the value 'true'.");
}
if (empty($value)) {
throw new \RuntimeException("File mapping {$destination} cannot be empty.");
}
if (is_string($value)) {
$value = [self::PATH => $value];
}
// If there is no 'mode', but there is an 'append' or a 'prepend' path,
// then the mode is 'append' (append + prepend).
if (!isset($value[self::MODE]) && (isset($value[self::APPEND]) || isset($value[self::PREPEND]))) {
$value[self::MODE] = AppendOp::ID;
}
// If there is no 'mode', then the default is 'replace'.
if (!isset($value[self::MODE])) {
$value[self::MODE] = ReplaceOp::ID;
}
return $value;
}
}

View File

@ -0,0 +1,135 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\Composer;
use Composer\Package\PackageInterface;
use Drupal\Component\Scaffold\ScaffoldFilePath;
/**
* Create Scaffold operation objects based on provided metadata.
*/
class OperationFactory {
/**
* The Composer service.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* OperationFactory constructor.
*
* @param \Composer\Composer $composer
* Reference to the 'Composer' object, since the Scaffold Operation Factory
* is also responsible for evaluating relative package paths as it creates
* scaffold operations.
*/
public function __construct(Composer $composer) {
$this->composer = $composer;
}
/**
* Creates a scaffolding operation object as determined by the metadata.
*
* @param \Composer\Package\PackageInterface $package
* The package that relative paths will be relative from.
* @param OperationData $operation_data
* The parameter data for this operation object; varies by operation type.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface
* The scaffolding operation object (skip, replace, etc.)
*
* @throws \RuntimeException
* Exception thrown when parameter data does not identify a known scaffol
* operation.
*/
public function create(PackageInterface $package, OperationData $operation_data) {
switch ($operation_data->mode()) {
case SkipOp::ID:
return new SkipOp();
case ReplaceOp::ID:
return $this->createReplaceOp($package, $operation_data);
case AppendOp::ID:
return $this->createAppendOp($package, $operation_data);
}
throw new \RuntimeException("Unknown scaffold operation mode <comment>{$operation_data->mode()}</comment>.");
}
/**
* Creates a 'replace' scaffold op.
*
* Replace ops may copy or symlink, depending on settings.
*
* @param \Composer\Package\PackageInterface $package
* The package that relative paths will be relative from.
* @param OperationData $operation_data
* The parameter data for this operation object, i.e. the relative 'path'.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface
* A scaffold replace operation object.
*/
protected function createReplaceOp(PackageInterface $package, OperationData $operation_data) {
if (!$operation_data->hasPath()) {
throw new \RuntimeException("'path' component required for 'replace' operations.");
}
$package_name = $package->getName();
$package_path = $this->getPackagePath($package);
$source = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->path());
$op = new ReplaceOp($source, $operation_data->overwrite());
return $op;
}
/**
* Creates an 'append' (or 'prepend') scaffold op.
*
* @param \Composer\Package\PackageInterface $package
* The package that relative paths will be relative from.
* @param OperationData $operation_data
* The parameter data for this operation object, i.e. the relative 'path'.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface
* A scaffold replace operation object.
*/
protected function createAppendOp(PackageInterface $package, OperationData $operation_data) {
$package_name = $package->getName();
$package_path = $this->getPackagePath($package);
$prepend_source_file = NULL;
$append_source_file = NULL;
if ($operation_data->hasPrepend()) {
$prepend_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->prepend());
}
if ($operation_data->hasAppend()) {
$append_source_file = ScaffoldFilePath::sourcePath($package_name, $package_path, $operation_data->destination(), $operation_data->append());
}
$op = new AppendOp($prepend_source_file, $append_source_file);
return $op;
}
/**
* Gets the file path of a package.
*
* Note that if we call getInstallPath on the root package, we get the
* wrong answer (the installation manager thinks our package is in
* vendor). We therefore add special checking for this case.
*
* @param \Composer\Package\PackageInterface $package
* The package.
*
* @return string
* The file path.
*/
protected function getPackagePath(PackageInterface $package) {
if ($package->getName() == $this->composer->getPackage()->getName()) {
// This will respect the --working-dir option if Composer is invoked with
// it. There is no API or method to determine the filesystem path of
// a package's composer.json file.
return getcwd();
}
return $this->composer->getInstallationManager()->getInstallPath($package);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Interface for scaffold operation objects.
*/
interface OperationInterface {
/**
* Process this scaffold operation.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
* Scaffold file's destination path.
* @param \Composer\IO\IOInterface $io
* IOInterface to write to.
* @param \Drupal\Component\Scaffold\ScaffoldOptions $options
* Various options that may alter the behavior of the operation.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* Result of the scaffolding operation.
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options);
}

View File

@ -0,0 +1,119 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Scaffold operation to copy or symlink from source to destination.
*/
class ReplaceOp implements OperationInterface {
/**
* Identifies Replace operations.
*/
const ID = 'replace';
/**
* The relative path to the source file.
*
* @var \Drupal\Component\Scaffold\ScaffoldFilePath
*/
protected $source;
/**
* Whether to overwrite existing files.
*
* @var bool
*/
protected $overwrite;
/**
* Constructs a ReplaceOp.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $sourcePath
* The relative path to the source file.
* @param bool $overwrite
* Whether to allow this scaffold file to overwrite files already at
* the destination. Defaults to TRUE.
*/
public function __construct(ScaffoldFilePath $sourcePath, $overwrite = TRUE) {
$this->source = $sourcePath;
$this->overwrite = $overwrite;
}
/**
* {@inheritdoc}
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
$fs = new Filesystem();
$destination_path = $destination->fullPath();
// Do nothing if overwrite is 'false' and a file already exists at the
// destination.
if ($this->overwrite === FALSE && file_exists($destination_path)) {
$interpolator = $destination->getInterpolator();
$io->write($interpolator->interpolate(" - Skip <info>[dest-rel-path]</info> because it already exists and overwrite is <comment>false</comment>."));
return new ScaffoldResult($destination, FALSE);
}
// Get rid of the destination if it exists, and make sure that
// the directory where it's going to be placed exists.
$fs->remove($destination_path);
$fs->ensureDirectoryExists(dirname($destination_path));
if ($options->symlink()) {
return $this->symlinkScaffold($destination, $io);
}
return $this->copyScaffold($destination, $io);
}
/**
* Copies the scaffold file.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
* Scaffold file to process.
* @param \Composer\IO\IOInterface $io
* IOInterface to writing to.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* The scaffold result.
*/
protected function copyScaffold(ScaffoldFilePath $destination, IOInterface $io) {
$interpolator = $destination->getInterpolator();
$this->source->addInterpolationData($interpolator);
$fs = new Filesystem();
$success = $fs->copy($this->source->fullPath(), $destination->fullPath());
if (!$success) {
throw new \RuntimeException($interpolator->interpolate("Could not copy source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>!"));
}
$io->write($interpolator->interpolate(" - Copy <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
return new ScaffoldResult($destination, $this->overwrite);
}
/**
* Symlinks the scaffold file.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
* Scaffold file to process.
* @param \Composer\IO\IOInterface $io
* IOInterface to writing to.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* The scaffold result.
*/
protected function symlinkScaffold(ScaffoldFilePath $destination, IOInterface $io) {
$interpolator = $destination->getInterpolator();
try {
$fs = new Filesystem();
$fs->relativeSymlink($this->source->fullPath(), $destination->fullPath());
}
catch (\Exception $e) {
throw new \RuntimeException($interpolator->interpolate("Could not symlink source file <info>[src-rel-path]</info> to <info>[dest-rel-path]</info>!"), [], $e);
}
$io->write($interpolator->interpolate(" - Link <info>[dest-rel-path]</info> from <info>[src-rel-path]</info>"));
return new ScaffoldResult($destination, $this->overwrite);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\Interpolator;
use Drupal\Component\Scaffold\ScaffoldFileInfo;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Collection of scaffold files.
*/
class ScaffoldFileCollection implements \IteratorAggregate {
/**
* Nested list of all scaffold files.
*
* The top level array maps from the package name to the collection of
* scaffold files provided by that package. Each collection of scaffold files
* is keyed by destination path.
*
* @var \Drupal\Component\Scaffold\ScaffoldFileInfo[][]
*/
protected $scaffoldFilesByProject = [];
/**
* ScaffoldFileCollection constructor.
*
* @param array $file_mappings
* A multidimensional array of file mappings.
* @param \Drupal\Component\Scaffold\Interpolator $location_replacements
* An object with the location mappings (e.g. [web-root]).
*/
public function __construct(array $file_mappings, Interpolator $location_replacements) {
// Collection of all destination paths to be scaffolded. Used to determine
// when two project scaffold the same file and we have to skip or use a
// ConjunctionOp.
$scaffoldFiles = [];
// Build the list of ScaffoldFileInfo objects by project.
foreach ($file_mappings as $package_name => $package_file_mappings) {
foreach ($package_file_mappings as $destination_rel_path => $op) {
$destination = ScaffoldFilePath::destinationPath($package_name, $destination_rel_path, $location_replacements);
// If there was already a scaffolding operation happening at this path,
// and the new operation is Conjoinable, then use a ConjunctionOp to
// join together both operations. This will cause both operations to
// run, one after the other. At the moment, only AppendOp is
// conjoinable; all other operations simply replace anything at the same
// path.
if (isset($scaffoldFiles[$destination_rel_path])) {
$previous_scaffold_file = $scaffoldFiles[$destination_rel_path];
if ($op instanceof ConjoinableInterface) {
$op = new ConjunctionOp($previous_scaffold_file->op(), $op);
}
// Remove the previous op so we only touch the destination once.
$message = " - Skip <info>[dest-rel-path]</info>: overridden in <comment>{$package_name}</comment>";
$this->scaffoldFilesByProject[$previous_scaffold_file->packageName()][$destination_rel_path] = new ScaffoldFileInfo($destination, new SkipOp($message));
}
$scaffold_file = new ScaffoldFileInfo($destination, $op);
$scaffoldFiles[$destination_rel_path] = $scaffold_file;
$this->scaffoldFilesByProject[$package_name][$destination_rel_path] = $scaffold_file;
}
}
}
/**
* {@inheritdoc}
*/
public function getIterator() {
return new \RecursiveArrayIterator($this->scaffoldFilesByProject, \RecursiveArrayIterator::CHILD_ARRAYS_ONLY);
}
/**
* Processes the iterator created by ScaffoldFileCollection::create().
*
* @param \Drupal\Component\Scaffold\Operations\ScaffoldFileCollection $collection
* The iterator to process.
* @param \Composer\IO\IOInterface $io
* The Composer IO object.
* @param \Drupal\Component\Scaffold\ScaffoldOptions $scaffold_options
* The scaffold options.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult[]
* The results array.
*/
public static function process(ScaffoldFileCollection $collection, IOInterface $io, ScaffoldOptions $scaffold_options) {
$results = [];
foreach ($collection as $project_name => $scaffold_files) {
$io->write("Scaffolding files for <comment>{$project_name}</comment>:");
foreach ($scaffold_files as $scaffold_file) {
$results[$scaffold_file->destination()->relativePath()] = $scaffold_file->process($io, $scaffold_options);
}
}
return $results;
}
}

View File

@ -0,0 +1,59 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Drupal\Component\Scaffold\ScaffoldFilePath;
/**
* Record the result of a scaffold operation.
*/
class ScaffoldResult {
/**
* The path to the scaffold file that was processed.
*
* @var \Drupal\Component\Scaffold\ScaffoldFilePath
*/
protected $destination;
/**
* Indicates if this scaffold file is managed by the scaffold command.
*
* @var bool
*/
protected $managed;
/**
* ScaffoldResult constructor.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
* The path to the scaffold file that was processed.
* @param bool $isManaged
* (optional) Whether this result is managed. Defaults to FALSE.
*/
public function __construct(ScaffoldFilePath $destination, $isManaged = FALSE) {
$this->destination = $destination;
$this->managed = $isManaged;
}
/**
* Determines whether this scaffold file is managed.
*
* @return bool
* TRUE if this scaffold file is managed, FALSE if not.
*/
public function isManaged() {
return $this->managed;
}
/**
* Gets the destination scaffold file that this result refers to.
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* The destination path for the scaffold result.
*/
public function destination() {
return $this->destination;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace Drupal\Component\Scaffold\Operations;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Drupal\Component\Scaffold\ScaffoldOptions;
/**
* Scaffold operation to skip a scaffold file (do nothing).
*/
class SkipOp implements OperationInterface {
/**
* Identifies Skip operations.
*/
const ID = 'skip';
/**
* The message to output while processing.
*
* @var string
*/
protected $message;
/**
* SkipOp constructor.
*
* @param string $message
* (optional) A custom message to output while skipping.
*/
public function __construct($message = " - Skip <info>[dest-rel-path]</info>: disabled") {
$this->message = $message;
}
/**
* {@inheritdoc}
*/
public function process(ScaffoldFilePath $destination, IOInterface $io, ScaffoldOptions $options) {
$interpolator = $destination->getInterpolator();
$io->write($interpolator->interpolate($this->message));
return new ScaffoldResult($destination, FALSE);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Composer;
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\IO\IOInterface;
use Composer\Installer\PackageEvent;
use Composer\Installer\PackageEvents;
use Composer\Plugin\Capability\CommandProvider;
use Composer\Plugin\Capable;
use Composer\Plugin\CommandEvent;
use Composer\Plugin\PluginEvents;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event;
use Composer\Script\ScriptEvents;
use Drupal\Component\Scaffold\CommandProvider as ScaffoldCommandProvider;
/**
* Composer plugin for handling drupal scaffold.
*/
class Plugin implements PluginInterface, EventSubscriberInterface, Capable {
/**
* The Composer Scaffold handler.
*
* @var \Drupal\Component\Scaffold\Handler
*/
protected $handler;
/**
* {@inheritdoc}
*/
public function activate(Composer $composer, IOInterface $io) {
// We use a Handler object to separate the main functionality
// of this plugin from the Composer API. This also avoids some
// debug issues with the plugin being copied on initialisation.
// @see \Composer\Plugin\PluginManager::registerPackage()
$this->handler = new Handler($composer, $io);
}
/**
* {@inheritdoc}
*/
public function getCapabilities() {
return [CommandProvider::class => ScaffoldCommandProvider::class];
}
/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
ScriptEvents::POST_UPDATE_CMD => 'postCmd',
ScriptEvents::POST_INSTALL_CMD => 'postCmd',
PackageEvents::POST_PACKAGE_INSTALL => 'postPackage',
PluginEvents::COMMAND => 'onCommand',
];
}
/**
* Post command event callback.
*
* @param \Composer\Script\Event $event
* The Composer event.
*/
public function postCmd(Event $event) {
$this->handler->scaffold();
}
/**
* Post package event behaviour.
*
* @param \Composer\Installer\PackageEvent $event
* Composer package event sent on install/update/remove.
*/
public function postPackage(PackageEvent $event) {
$this->handler->onPostPackageEvent($event);
}
/**
* Pre command event callback.
*
* @param \Composer\Plugin\CommandEvent $event
* The Composer command event.
*/
public function onCommand(CommandEvent $event) {
if ($event->getCommandName() == 'require') {
$this->handler->beforeRequire($event);
}
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\Installer\PackageEvent;
/**
* Interface for post package event listeners.
*
* @see \Drupal\Component\Scaffold\Handler::onPostPackageEvent
*/
interface PostPackageEventListenerInterface {
/**
* Handles package events during a 'composer require' operation.
*
* @param \Composer\Installer\PackageEvent $event
* Composer package event sent on install/update/remove.
*/
public function event(PackageEvent $event);
}

View File

@ -0,0 +1,487 @@
# composer-scaffold
This project provides a composer plugin for placing scaffold files (like
`index.php`, `update.php`, …) from the `drupal/core` project into their desired
location inside the web root. Only individual files may be scaffolded with this
plugin.
The purpose of scaffolding files is to allow Drupal sites to be fully managed by
Composer, and still allow individual asset files to be placed in arbitrary
locations. The goal of doing this is to enable a properly configured composer
template to produce a file layout that exactly matches the file layout of a
Drupal 8.7.x and earlier tarball distribution. Other file layouts will also be
possible; for example, a project layout very similar to the current
[drupal-composer/drupal-project](https://github.com/drupal-composer/drupal-scaffold)
template will also be provided. When one of these projects is used, the user
should be able to use `composer require` and `composer update` on a Drupal site
immediately after untarring the downloaded archive.
Note that the dependencies of a Drupal site are only able to scaffold files if
explicitly granted that right in the top-level composer.json file. See
[allowed packages](#allowed-packages), below.
## Usage
Composer-scaffold is used by requiring `drupal/core-composer-scaffold` in your
project, and providing configuration settings in the `extra` section of your
project's composer.json file. Additional configuration from the composer.json
file of your project's dependencies is also consulted in order to scaffold the
files a project needs. Additional information may be added to the beginning or
end of scaffold files, as is commonly done to `.htaccess` and `robots.txt`
files. See [altering scaffold files](#altering-scaffold-files) for more
information.
### Allowed Packages
Scaffold files are stored inside of projects that are required from the main
project's composer.json file as usual. The scaffolding operation happens after
`composer install`, and involves copying or symlinking the desired assets to
their destination location. In order to prevent arbitrary dependencies from
copying files via the scaffold mechanism, only those projects that are
specifically permitted by the top-level project will be used to scaffold files.
Example: Permit scaffolding from the project `drupal/core`
```
"name": "my/project",
...
"extra": {
"composer-scaffold": {
"allowed-packages": [
"drupal/core",
],
...
}
}
```
Allowing a package to scaffold files also permits it to delegate permission to
scaffold to any project that it requires itself. This allows a package to
organize its scaffold assets as it sees fit. For example, the project
`drupal/core` may choose to store its assets in a subproject `drupal/assets`.
It is possible for a project to obtain scaffold files from multiple projects.
For example, a Drupal project using a distribution, and installing on a specific
web hosting service provider might take its scaffold files from:
- Drupal core
- Its distribution
- A project provided by the hosting provider
- The project itself
Each project allowed to scaffold by the top-level project will be used in turn,
with projects declared later in the `allowed-packages` list taking precedence
over the projects named before. The top-level composer.json itself is always
implicitly allowed to scaffold files, and its scaffold files have highest
priority.
### Defining Project Locations
The top-level project in turn must define where the web root is located. It does
so via the `locations` mapping, as shown below:
```
"name": "my/project",
...
"extra": {
"composer-scaffold": {
"locations": {
"web-root": "./docroot"
},
...
}
}
```
This makes it possible to configure a project with different file layouts; for
example, either the `drupal/drupal` file layout or the
`drupal-composer/drupal-project` file layout could be used to set up a project.
If a web-root is not explicitly defined, then it will default to `./`.
### Altering Scaffold Files
Sometimes, a project might wish to use a scaffold file provided by a dependency,
but alter it in some way. Two forms of alteration are supported: appending and
patching.
The example below shows a project that appends additional entries onto the end
of the `robots.txt` file provided by `drupal/core`:
```
"name": "my/project",
...
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/robots.txt": {
"append": "assets/my-robots-additions.txt",
}
}
}
}
```
It is also possible to prepend to a scaffold file instead of, or in addition to
appending by including a "prepend" entry that provides the relative path to the
file to prepend to the scaffold file.
The example below demonstrates the use of the `post-composer-scaffold-cmd` hook
to patch the `.htaccess` file using a patch.
```
"name": "my/project",
...
"scripts": {
"post-composer-scaffold-cmd": [
"cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
]
}
```
### Defining Scaffold Files
The placement of scaffold assets is under the control of the project that
provides them, but the location is always relative to some directory defined by
the root project -- usually the web root. For example, the scaffold file
`robots.txt` is copied from its source location, `assets/robots.txt` into the
web root in the snippet below.
```
{
"name": "drupal/assets",
...
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/robots.txt": "assets/robots.txt",
...
}
}
}
}
```
### Excluding Scaffold Files
Sometimes, a project might prefer to entirely replace a scaffold file provided
by a dependency, and receive no further updates for it. This can be done by
setting the value for the scaffold file to exclude to `false`:
```
"name": "my/project",
...
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/robots.txt": false
}
}
}
```
If possible, use the `append` and `prepend` directives as explained in [altering
scaffold files](#altering-scaffold-files), above. Excluding a file means that
your project will not get any bug fixes or other updates to files that are
modified locally.
### Overwrite
By default, scaffold files overwrite whatever content exists at the target
location. Sometimes a project may wish to provide the initial contents for a
file that will not be changed in subsequent updates. This can be done by setting
the `overwrite` flag to `false`, as shown in the example below:
```
{
"name": "service-provider/d8-scaffold-files",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/sites/default/settings.php": {
"mode": "replace",
"path": "assets/sites/default/settings.php",
"overwrite": false
}
}
}
}
}
```
Note that the `overwrite` directive is intended to be used by starter kits,
service providers, and so on. Individual Drupal sites should exclude the file
by setting its value to false instead.
### Autoload File
The scaffold tool automatically creates the required `autoload.php` file at the
Drupal root as part of the scaffolding operation. This file should not be
modified or customized in any way. If it is committed to the repository, though,
then the scaffold tool will stop managing it. If the location of the `vendor`
directory is changed for any reason, and the `autoload.php` file has been
committed to the repository, manually delete it and then run `composer install`
to update it.
## Specifications
Reference section for the configuration directives for the "composer-scaffold"
section of the "extra" section of a `composer.json` file appear below.
### allowed-packages
The `allowed-packages` configuration setting contains an ordered list of package
names that will be used during the scaffolding phase.
```
"allowed-packages": [
"drupal/core",
],
```
### file-mapping
The `file-mapping` configuration setting consists of a map from the destination
path of the file to scaffold to a set of properties that control how the file
should be scaffolded.
The available properties are as follows:
- mode: One of "replace", "append" or "skip".
- path: The path to the source file to write over the destination file.
- prepend: The path to the source file to prepend to the destination file, which
must always be a scaffold file provided by some other project.
- append: Like `prepend`, but appends content rather than prepends.
- overwrite: If `false`, prevents a `replace` from happening if the destination
already exists.
The mode may be inferred from the other properties. If the mode is not
specified, then the following defaults will be supplied:
- replace: Selected if a `path` property is present, or if the entry's value is
a string rather than a property set.
- append: Selected if a `prepend` or `append` property is present.
- skip: Selected if the entry's value is a boolean `false`.
Examples:
```
"file-mapping": {
"[web-root]/sites/default/default.settings.php": {
"mode": "replace",
"path": "assets/sites/default/default.settings.php",
"overwrite": true
},
"[web-root]/sites/default/settings.php": {
"mode": "replace",
"path": "assets/sites/default/settings.php",
"overwrite": false
},
"[web-root]/robots.txt": {
"mode": "append",
"prepend": "assets/robots-prequel.txt",
"append": "assets/robots-append.txt"
},
"[web-root]/.htaccess": {
"mode": "skip",
}
}
```
The short-form of the above example would be:
```
"file-mapping": {
"[web-root]/sites/default/default.settings.php": "assets/sites/default/default.settings.php",
"[web-root]/sites/default/settings.php": {
"path": "assets/sites/default/settings.php",
"overwrite": false
},
"[web-root]/robots.txt": {
"prepend": "assets/robots-prequel.txt",
"append": "assets/robots-append.txt"
},
"[web-root]/.htaccess": false
}
```
Note that there is no distinct "prepend" mode; "append" mode is used to both
append and prepend to scaffold files. The reason for this is that scaffold file
entries are identified in the file-mapping section keyed by their destination
path, and it is not possible for multiple entries to have the same key. If
"prepend" were a separate mode, then it would not be possible to both prepend
and append to the same file.
### gitignore
The `gitignore` configuration setting controls whether or not this plugin will
manage `.gitignore` files for files written during the scaffold operation.
- true: `.gitignore` files will be updated when scaffold files are written.
- false: `.gitignore` files will never be modified.
- Not set: `.gitignore` files will be updated if the target directory is a local
working copy of a git repository, and the `vendor` directory is not committed
in that repository.
### locations
The `locations` configuration setting contains a list of named locations that
may be used in placing scaffold files. The only required location is `web-root`.
Other locations may also be defined if desired.
```
"locations": {
"web-root": "./docroot"
},
```
### symlink
The `symlink` property causes `replace` operations to make a symlink to the
source file rather than copying it. This is useful when doing core development,
as the symlink files themselves should not be edited. Note that `append`
operations override the `symlink` option, to prevent the original scaffold
assets from being altered.
```
"symlink": true,
```
## Managing Scaffold Files
Scaffold files should be treated the same way that the `vendor` directory is
handled. If you need to commit `vendor` (e.g. in order to deploy your site),
then you should also commit your scaffold files. You should not commit your
`vendor` directory or scaffold files unless it is necessary.
If a dependency provides a scaffold file with `overwrite` set to `false`, that
file should be committed to your repository.
By default, `.gitignore` files will be automatically updated if needed when
scaffold files are written. See the `gitignore` setting in the Specifications
section above.
## Examples
Some full-length examples appear below.
Sample composer.json for a project that relies on packages that use composer-scaffold:
```
{
"name": "my/project",
"require": {
"drupal/composer-scaffold": "*",
"composer/installers": "^1.2",
"cweagans/composer-patches": "^1.6.5",
"drupal/core": "^8.8.x-dev",
"service-provider/d8-scaffold-files": "^1"
},
"config": {
"optimize-autoloader": true,
"sort-packages": true
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"drupal/core",
],
"locations": {
"web-root": "./docroot"
},
"symlink": true,
"overwrite": true,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/robots-default.txt"
}
}
}
}
```
Sample composer.json for drupal/core, with assets placed in a different project:
```
{
"name": "drupal/core",
"extra": {
"composer-scaffold": {
"allowed-packages": [
"drupal/assets",
]
}
}
}
```
Sample composer.json for composer-scaffold files in drupal/assets:
```
{
"name": "drupal/assets",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/.csslintrc": "assets/.csslintrc",
"[web-root]/.editorconfig": "assets/.editorconfig",
"[web-root]/.eslintignore": "assets/.eslintignore",
"[web-root]/.eslintrc.json": "assets/.eslintrc.json",
"[web-root]/.gitattributes": "assets/.gitattributes",
"[web-root]/.ht.router.php": "assets/.ht.router.php",
"[web-root]/.htaccess": "assets/.htaccess",
"[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
"[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
"[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
"[web-root]/sites/example.sites.php": "assets/example.sites.php",
"[web-root]/index.php": "assets/index.php",
"[web-root]/robots.txt": "assets/robots.txt",
"[web-root]/update.php": "assets/update.php",
"[web-root]/web.config": "assets/web.config"
}
}
}
}
```
Sample composer.json for a library that implements composer-scaffold:
```
{
"name": "service-provider/d8-scaffold-files",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/sites/default/settings.php": "assets/sites/default/settings.php"
}
}
}
}
```
Append to robots.txt:
```
{
"name": "service-provider/d8-scaffold-files",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/robots.txt": {
"append": "assets/my-robots-additions.txt",
}
}
}
}
}
```
Patch a file after it's copied:
```
"post-composer-scaffold-cmd": [
"cd docroot && patch -p1 <../patches/htaccess-ssl.patch"
]
```
## Related Plugins
### drupal-composer/drupal-scaffold
Previous versions of drupal-scaffold (see community project,
[drupal-composer/drupal-scaffold](https://github.com/drupal-composer/drupal-project))
downloaded each scaffold file directly from its distribution server (e.g.
`https://cgit.drupalcode.org`) to the desired destination directory. This was
necessary, because there was no subtree split of the scaffold files available.
Copying the scaffold assets from projects already downloaded by Composer is more
effective, as downloading and unpacking archive files is more efficient than
downloading each scaffold file individually.
### composer/installers
The [composer/installers](https://github.com/composer/installers) plugin is
similar to this plugin in that it allows dependencies to be installed in
locations other than the `vendor` directory. However, Composer and the
`composer/installers` plugin have a limitation that one project cannot be moved
inside of another project. Therefore, if you use `composer/installers` to place
Drupal modules inside the directory `web/modules/contrib`, then you cannot also
use `composer/installers` to place files such as `index.php` and `robots.txt`
into the `web` directory. The drupal-scaffold plugin was created to work around
this limitation.

View File

@ -0,0 +1,124 @@
<?php
namespace Drupal\Component\Scaffold;
use Composer\IO\IOInterface;
use Drupal\Component\Scaffold\Operations\OperationInterface;
/**
* Data object that keeps track of one scaffold file.
*
* Scaffold files are identified primarily by their destination path. Each
* scaffold file also has an 'operation' object that controls how the scaffold
* file will be placed (e.g. via copy or symlink, or maybe by appending multiple
* files together). The operation may have one or more source files.
*/
class ScaffoldFileInfo {
/**
* The path to the destination.
*
* @var \Drupal\Component\Scaffold\ScaffoldFilePath
*/
protected $destination;
/**
* The operation used to create the destination.
*
* @var \Drupal\Component\Scaffold\Operations\OperationInterface
*/
protected $op;
/**
* Constructs a ScaffoldFileInfo object.
*
* @param \Drupal\Component\Scaffold\ScaffoldFilePath $destination
* The full and relative paths to the destination file and the package
* defining it.
* @param \Drupal\Component\Scaffold\Operations\OperationInterface $op
* Operations object that will handle scaffolding operations.
*/
public function __construct(ScaffoldFilePath $destination, OperationInterface $op) {
$this->destination = $destination;
$this->op = $op;
}
/**
* Gets the Scaffold operation.
*
* @return \Drupal\Component\Scaffold\Operations\OperationInterface
* Operations object that handles scaffolding (copy, make symlink, etc).
*/
public function op() {
return $this->op;
}
/**
* Gets the package name.
*
* @return string
* The name of the package this scaffold file info was collected from.
*/
public function packageName() {
return $this->destination->packageName();
}
/**
* Gets the destination.
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* The scaffold path to the destination file.
*/
public function destination() {
return $this->destination;
}
/**
* Determines if this scaffold file has been overridden by another package.
*
* @param string $providing_package
* The name of the package that provides the scaffold file at this location,
* as returned by self::findProvidingPackage()
*
* @return bool
* Whether this scaffold file if overridden or removed.
*/
public function overridden($providing_package) {
return $this->packageName() !== $providing_package;
}
/**
* Replaces placeholders in a message.
*
* @param string $message
* Message with placeholders to fill in.
* @param array $extra
* Additional data to merge with the interpolator.
* @param mixed $default
* Default value to use for missing placeholders, or FALSE to keep them.
*
* @return string
* Interpolated string with placeholders replaced.
*/
public function interpolate($message, array $extra = [], $default = FALSE) {
$interpolator = $this->destination->getInterpolator();
return $interpolator->interpolate($message, $extra, $default);
}
/**
* Moves a single scaffold file from source to destination.
*
* @param \Composer\IO\IOInterface $io
* The scaffold file to be processed.
* @param \Drupal\Component\Scaffold\ScaffoldOptions $options
* Assorted operational options, e.g. whether the destination should be a
* symlink.
*
* @return \Drupal\Component\Scaffold\Operations\ScaffoldResult
* The scaffold result.
*/
public function process(IOInterface $io, ScaffoldOptions $options) {
return $this->op()->process($this->destination, $io, $options);
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace Drupal\Component\Scaffold;
/**
* Manage the path to a file to scaffold.
*
* Both the relative and full path to the file is maintained so that the shorter
* name may be used in progress and error messages, as needed. The name of the
* package that provided the file path is also recorded for the same reason.
*
* ScaffoldFilePaths may be used to represent destination scaffold files, or the
* source files used to create them. Static factory methods named
* destinationPath and sourcePath, respectively, are provided to create
* ScaffoldFilePath objects.
*/
class ScaffoldFilePath {
/**
* The type of scaffold file this is, 'src' or 'dest'.
*
* @var string
*/
protected $type;
/**
* The name of the package containing the file.
*
* @var string
*/
protected $packageName;
/**
* The relative path to the file.
*
* @var string
*/
protected $relativePath;
/**
* The full path to the file.
*
* @var string
*/
protected $fullPath;
/**
* ScaffoldFilePath constructor.
*
* @param string $path_type
* The type of scaffold file this is, 'src' or 'dest'.
* @param string $package_name
* The name of the package containing the file.
* @param string $rel_path
* The relative path to the file.
* @param string $full_path
* The full path to the file.
*/
public function __construct($path_type, $package_name, $rel_path, $full_path) {
$this->type = $path_type;
$this->packageName = $package_name;
$this->relativePath = $rel_path;
$this->fullPath = $full_path;
}
/**
* Gets the name of the package this source file was pulled from.
*
* @return string
* Name of package.
*/
public function packageName() {
return $this->packageName;
}
/**
* Gets the relative path to the source file (best to use in messages).
*
* @return string
* Relative path to file.
*/
public function relativePath() {
return $this->relativePath;
}
/**
* Gets the full path to the source file.
*
* @return string
* Full path to file.
*/
public function fullPath() {
return $this->fullPath;
}
/**
* Converts the relative source path into an absolute path.
*
* The path returned will be relative to the package installation location.
*
* @param string $package_name
* The name of the package containing the source file. Only used for error
* messages.
* @param string $package_path
* The installation path of the package containing the source file.
* @param string $destination
* Destination location provided as a relative path. Only used for error
* messages.
* @param string $source
* Source location provided as a relative path.
*
* @return self
* Object wrapping the relative and absolute path to the source file.
*/
public static function sourcePath($package_name, $package_path, $destination, $source) {
// Complain if there is no source path.
if (empty($source)) {
throw new \RuntimeException("No scaffold file path given for {$destination} in package {$package_name}.");
}
// Calculate the full path to the source scaffold file.
$source_full_path = $package_path . '/' . $source;
if (!file_exists($source_full_path)) {
throw new \RuntimeException("Scaffold file {$source} not found in package {$package_name}.");
}
if (is_dir($source_full_path)) {
throw new \RuntimeException("Scaffold file {$source} in package {$package_name} is a directory; only files may be scaffolded.");
}
return new self('src', $package_name, $source, $source_full_path);
}
/**
* Converts the relative destination path into an absolute path.
*
* Any placeholders in the destination path, e.g. '[web-root]', will be
* replaced using the provided location replacements interpolator.
*
* @param string $package_name
* The name of the package defining the destination path.
* @param string $destination
* The relative path to the destination file being scaffolded.
* @param \Drupal\Component\Scaffold\Interpolator $location_replacements
* Interpolator that includes the [web-root] and any other available
* placeholder replacements.
*
* @return self
* Object wrapping the relative and absolute path to the destination file.
*/
public static function destinationPath($package_name, $destination, Interpolator $location_replacements) {
$dest_full_path = $location_replacements->interpolate($destination);
return new self('dest', $package_name, $destination, $dest_full_path);
}
/**
* Adds data about the relative and full path to the provided interpolator.
*
* @param \Drupal\Component\Scaffold\Interpolator $interpolator
* Interpolator to add data to.
* @param string $name_prefix
* (optional) Prefix to add before -rel-path and -full-path item names.
* Defaults to path type provided when constructing this object.
*/
public function addInterpolationData(Interpolator $interpolator, $name_prefix = '') {
if (empty($name_prefix)) {
$name_prefix = $this->type;
}
$data = [
'package-name' => $this->packageName(),
"{$name_prefix}-rel-path" => $this->relativePath(),
"{$name_prefix}-full-path" => $this->fullPath(),
];
$interpolator->addData($data);
}
/**
* Interpolate a string using the data from this scaffold file info.
*
* @param string $name_prefix
* (optional) Prefix to add before -rel-path and -full-path item names.
* Defaults to path type provided when constructing this object.
*
* @return \Drupal\Component\Scaffold\Interpolator
* An interpolator for making string replacements.
*/
public function getInterpolator($name_prefix = '') {
$interpolator = new Interpolator();
$this->addInterpolationData($interpolator, $name_prefix);
return $interpolator;
}
}

View File

@ -0,0 +1,200 @@
<?php
namespace Drupal\Component\Scaffold;
/**
* Per-project options from the 'extras' section of the composer.json file.
*
* Projects that describe scaffold files do so via their scaffold options. This
* data is pulled from the 'composer-scaffold' portion of the extras section of
* the project data.
*/
class ScaffoldOptions {
/**
* The raw data from the 'extras' section of the top-level composer.json file.
*
* @var array
*/
protected $options = [];
/**
* ScaffoldOptions constructor.
*
* @param array $options
* The scaffold options taken from the 'composer-scaffold' section.
*/
protected function __construct(array $options) {
$this->options = $options + [
"allowed-packages" => [],
"locations" => [],
"symlink" => FALSE,
"file-mapping" => [],
];
// Define any default locations.
$this->options['locations'] += [
'web-root' => '.',
];
}
/**
* Determines if the provided 'extras' section has scaffold options.
*
* @param array $extras
* The contents of the 'extras' section.
*
* @return bool
* True if scaffold options have been declared
*/
public static function hasOptions(array $extras) {
return array_key_exists('composer-scaffold', $extras);
}
/**
* Creates a scaffold options object.
*
* @param array $extras
* The contents of the 'extras' section.
*
* @return self
* The scaffold options object representing the provided scaffold options
*/
public static function create(array $extras) {
$options = static::hasOptions($extras) ? $extras['composer-scaffold'] : [];
return new self($options);
}
/**
* Creates a new scaffold options object with some values overridden.
*
* @param array $options
* Override values.
*
* @return self
* The scaffold options object representing the provided scaffold options
*/
protected function override(array $options) {
return new self($options + $this->options);
}
/**
* Creates a new scaffold options object with an overridden 'symlink' value.
*
* @param bool $symlink
* Whether symlinking should be enabled or not.
*
* @return self
* The scaffold options object representing the provided scaffold options
*/
public function overrideSymlink($symlink) {
return $this->override(['symlink' => $symlink]);
}
/**
* Determines whether any allowed packages were defined.
*
* @return bool
* Whether there are allowed packages
*/
public function hasAllowedPackages() {
return !empty($this->allowedPackages());
}
/**
* Gets allowed packages from these options.
*
* @return array
* The list of allowed packages
*/
public function allowedPackages() {
return $this->options['allowed-packages'];
}
/**
* Gets the location mapping table, e.g. 'webroot' => './'.
*
* @return array
* A map of name : location values
*/
public function locations() {
return $this->options['locations'];
}
/**
* Determines whether a given named location is defined.
*
* @param string $name
* The location name to search for.
*
* @return bool
* True if the specified named location exist.
*/
protected function hasLocation($name) {
return array_key_exists($name, $this->locations());
}
/**
* Gets a specific named location.
*
* @param string $name
* The name of the location to fetch.
*
* @return string
* The value of the provided named location
*/
public function getLocation($name) {
return $this->hasLocation($name) ? $this->locations()[$name] : FALSE;
}
/**
* Determines if symlink mode is set.
*
* @return bool
* Whether or not 'symlink' mode
*/
public function symlink() {
return $this->options['symlink'];
}
/**
* Determines if there are file mappings.
*
* @return bool
* Whether or not the scaffold options contain any file mappings
*/
public function hasFileMapping() {
return !empty($this->fileMapping());
}
/**
* Returns the actual file mappings.
*
* @return array
* File mappings for just this config type.
*/
public function fileMapping() {
return $this->options['file-mapping'];
}
/**
* Determines if there is defined a value for the 'gitignore' option.
*
* @return bool
* Whether or not there is a 'gitignore' option setting
*/
public function hasGitIgnore() {
return isset($this->options['gitignore']);
}
/**
* Gets the value of the 'gitignore' option.
*
* @return bool
* The 'gitignore' option, or TRUE if undefined.
*/
public function gitIgnore() {
return $this->hasGitIgnore() ? $this->options['gitignore'] : TRUE;
}
}

View File

@ -0,0 +1,18 @@
HOW-TO: Test this Drupal component
In order to test this component, you'll need to get the entire Drupal repo and
run the tests there.
You'll find the tests under core/tests/Drupal/Tests/Component.
You can get the full Drupal repo here:
https://www.drupal.org/project/drupal/git-instructions
You can find more information about running PHPUnit tests with Drupal here:
https://www.drupal.org/node/2116263
Each component in the Drupal\Component namespace has its own annotated test
group. You can use this group to run only the tests for this component. Like
this:
$ ./vendor/bin/phpunit -c core --group Scaffold

View File

@ -0,0 +1,29 @@
{
"name": "drupal/core-composer-scaffold",
"description": "A flexible Composer project scaffold builder.",
"type": "composer-plugin",
"keywords": ["drupal"],
"homepage": "https://www.drupal.org/project/drupal",
"license": "GPL-2.0-or-later",
"require": {
"composer-plugin-api": "^1.0.0",
"php": ">=7.0.8"
},
"autoload": {
"psr-4": {
"Drupal\\Component\\Scaffold\\": ""
}
},
"extra": {
"class": "Drupal\\Component\\Scaffold\\Plugin",
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"config": {
"sort-packages": true
},
"require-dev": {
"composer/composer": "^1.8@stable"
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Drupal\Tests\Component\Scaffold;
/**
* Convenience class for creating fixtures.
*/
trait AssertUtilsTrait {
/**
* Asserts that a given file exists and is/is not a symlink.
*
* @param string $path
* The path to check exists.
* @param bool $is_link
* Checks if the file should be a symlink or not.
* @param string $contents_contains
* Regex to check the file contents.
*/
protected function assertScaffoldedFile($path, $is_link, $contents_contains) {
$this->assertFileExists($path);
$contents = file_get_contents($path);
$this->assertContains($contents_contains, basename($path) . ': ' . $contents);
$this->assertSame($is_link, is_link($path));
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Drupal\Tests\Component\Scaffold;
use Symfony\Component\Process\Process;
/**
* Convenience class for creating fixtures.
*/
trait ExecTrait {
/**
* Runs an arbitrary command.
*
* @param string $cmd
* The command to execute (escaped as required)
* @param string $cwd
* The current working directory to run the command from.
* @param array $env
* Environment variables to define for the subprocess.
*
* @return string
* Standard output from the command
*/
protected function mustExec($cmd, $cwd, array $env = []) {
$process = new Process($cmd, $cwd, $env + ['PATH' => getenv('PATH'), 'HOME' => getenv('HOME')]);
$process->inheritEnvironmentVariables();
$process->setTimeout(300)->setIdleTimeout(300)->run();
$exitCode = $process->getExitCode();
if (0 != $exitCode) {
throw new \RuntimeException("Exit code: {$exitCode}\n\n" . $process->getErrorOutput() . "\n\n" . $process->getOutput());
}
return $process->getOutput();
}
}

View File

@ -0,0 +1,370 @@
<?php
namespace Drupal\Tests\Component\Scaffold;
use Composer\Console\Application;
use Composer\Factory;
use Composer\IO\BufferIO;
use Composer\Util\Filesystem;
use Drupal\Component\Scaffold\Handler;
use Drupal\Component\Scaffold\Interpolator;
use Drupal\Component\Scaffold\Operations\AppendOp;
use Drupal\Component\Scaffold\Operations\ReplaceOp;
use Drupal\Component\Scaffold\ScaffoldFilePath;
use Symfony\Component\Console\Input\StringInput;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* Convenience class for creating fixtures.
*/
class Fixtures {
/**
* Directories to delete when we are done.
*
* @var string[]
*/
protected $tmpDirs = [];
/**
* A Composer IOInterface to write to.
*
* @var \Composer\IO\IOInterface
*/
protected $io;
/**
* The composer object.
*
* @var \Composer\Composer
*/
protected $composer;
/**
* Gets an IO fixture.
*
* @return \Composer\IO\IOInterface
* A Composer IOInterface to write to; output may be retrieved via
* Fixtures::getOutput().
*/
public function io() {
if (!$this->io) {
$this->io = new BufferIO();
}
return $this->io;
}
/**
* Gets the Composer object.
*
* @return \Composer\Composer
* The main Composer object, needed by the scaffold Handler, etc.
*/
public function getComposer() {
if (!$this->composer) {
$this->composer = Factory::create($this->io(), NULL, TRUE);
}
return $this->composer;
}
/**
* Gets the output from the io() fixture.
*
* @return string
* Output captured from tests that write to Fixtures::io().
*/
public function getOutput() {
return $this->io()->getOutput();
}
/**
* Gets the path to Scaffold component.
*
* Used to inject the component into composer.json files.
*
* @return string
* Path to the root of this project.
*/
public function projectRoot() {
return realpath(__DIR__) . '/../../../../../../core/lib/Drupal/Component/Scaffold';
}
/**
* Gets the path to the project fixtures.
*
* @return string
* Path to project fixtures
*/
public function allFixturesDir() {
return realpath(__DIR__ . '/fixtures');
}
/**
* Gets the path to one particular project fixture.
*
* @param string $project_name
* The project name to get the path for.
*
* @return string
* Path to project fixture.
*/
public function projectFixtureDir($project_name) {
$dir = $this->allFixturesDir() . '/' . $project_name;
if (!is_dir($dir)) {
throw new \RuntimeException("Requested fixture project {$project_name} that does not exist.");
}
return $dir;
}
/**
* Gets the path to one particular bin path.
*
* @param string $bin_name
* The bin name to get the path for.
*
* @return string
* Path to project fixture.
*/
public function binFixtureDir($bin_name) {
$dir = $this->allFixturesDir() . '/scripts/' . $bin_name;
if (!is_dir($dir)) {
throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist.");
}
return $dir;
}
/**
* Gets a path to a source scaffold fixture.
*
* Use in place of ScaffoldFilePath::sourcePath().
*
* @param string $project_name
* The name of the project to fetch; $package_name is
* "fixtures/$project_name".
* @param string $source
* The name of the asset; path is "assets/$source".
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* The full and relative path to the desired asset
*
* @see \Drupal\Component\Scaffold\ScaffoldFilePath::sourcePath()
*/
public function sourcePath($project_name, $source) {
$package_name = "fixtures/{$project_name}";
$source_rel_path = "assets/{$source}";
$package_path = $this->projectFixtureDir($project_name);
return ScaffoldFilePath::sourcePath($package_name, $package_path, 'unknown', $source_rel_path);
}
/**
* Gets an Interpolator with 'web-root' and 'package-name' set.
*
* Use in place of ManageOptions::getLocationReplacements().
*
* @return \Drupal\Component\Scaffold\Interpolator
* An interpolator with location replacements, including 'web-root'.
*
* @see \Drupal\Component\Scaffold\ManageOptions::getLocationReplacements()
*/
public function getLocationReplacements() {
$destinationTmpDir = $this->mkTmpDir();
$interpolator = new Interpolator();
$interpolator->setData(['web-root' => $destinationTmpDir, 'package-name' => 'fixtures/tmp-destination']);
return $interpolator;
}
/**
* Creates a ReplaceOp fixture.
*
* @param string $project_name
* The name of the project to fetch; $package_name is
* "fixtures/$project_name".
* @param string $source
* The name of the asset; path is "assets/$source".
*
* @return \Drupal\Component\Scaffold\Operations\ReplaceOp
* A replace operation object.
*/
public function replaceOp($project_name, $source) {
$source_path = $this->sourcePath($project_name, $source);
return new ReplaceOp($source_path, TRUE);
}
/**
* Creates an AppendOp fixture.
*
* @param string $project_name
* The name of the project to fetch; $package_name is
* "fixtures/$project_name".
* @param string $source
* The name of the asset; path is "assets/$source".
*
* @return \Drupal\Component\Scaffold\Operations\AppendOp
* An append operation object.
*/
public function appendOp($project_name, $source) {
$source_path = $this->sourcePath($project_name, $source);
return new AppendOp(NULL, $source_path);
}
/**
* Gets a destination path in a tmp dir.
*
* Use in place of ScaffoldFilePath::destinationPath().
*
* @param string $destination
* Destination path; should be in the form '[web-root]/robots.txt', where
* '[web-root]' is always literally '[web-root]', with any arbitrarily
* desired filename following.
* @param \Drupal\Component\Scaffold\Interpolator $interpolator
* Location replacements. Obtain via Fixtures::getLocationReplacements()
* when creating multiple scaffold destinations.
* @param string $package_name
* (optional) The name of the fixture package that this path came from.
* Taken from interpolator if not provided.
*
* @return \Drupal\Component\Scaffold\ScaffoldFilePath
* A destination scaffold file backed by temporary storage.
*
* @see \Drupal\Component\Scaffold\ScaffoldFilePath::destinationPath()
*/
public function destinationPath($destination, Interpolator $interpolator = NULL, $package_name = NULL) {
$interpolator = $interpolator ?: $this->getLocationReplacements();
$package_name = $package_name ?: $interpolator->interpolate('[package-name]');
return ScaffoldFilePath::destinationPath($package_name, $destination, $interpolator);
}
/**
* Generates a path to a temporary location, but do not create the directory.
*
* @param string $extraSalt
* Extra characters to throw into the md5 to add to name.
*
* @return string
* Path to temporary directory
*/
public function tmpDir($extraSalt = '') {
$tmpDir = sys_get_temp_dir() . '/composer-scaffold-test-' . md5($extraSalt . microtime());
$this->tmpDirs[] = $tmpDir;
return $tmpDir;
}
/**
* Creates a temporary directory.
*
* @param string $extraSalt
* Extra characters to throw into the md5 to add to name.
*
* @return string
* Path to temporary directory
*/
public function mkTmpDir($extraSalt = '') {
$tmpDir = $this->tmpDir($extraSalt);
$filesystem = new Filesystem();
$filesystem->ensureDirectoryExists($tmpDir);
return $tmpDir;
}
/**
* Calls 'tearDown' in any test that copies fixtures to transient locations.
*/
public function tearDown() {
// Remove any temporary directories that were created.
$filesystem = new Filesystem();
foreach ($this->tmpDirs as $dir) {
$filesystem->remove($dir);
}
// Clear out variables from the previous pass.
$this->tmpDirs = [];
$this->io = NULL;
}
/**
* Creates a temporary copy of all of the fixtures projects into a temp dir.
*
* The fixtures remain dirty if they already exist. Individual tests should
* first delete any fixture directory that needs to remain pristine. Since all
* temporary directories are removed in tearDown, this is only an issue when
* a) the FIXTURE_DIR environment variable has been set, or b) tests are
* calling cloneFixtureProjects more than once per test method.
*
* @param string $fixturesDir
* The directory to place fixtures in.
* @param array $replacements
* Key : value mappings for placeholders to replace in composer.json
* templates.
*/
public function cloneFixtureProjects($fixturesDir, array $replacements = []) {
$filesystem = new Filesystem();
// We will replace 'SYMLINK' with the string 'true' in our composer.json
// fixture.
$replacements += ['SYMLINK' => 'true'];
$interpolator = new Interpolator('__', '__');
$interpolator->setData($replacements);
$filesystem->copy($this->allFixturesDir(), $fixturesDir);
$composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl");
foreach ($composer_json_templates as $composer_json_tmpl) {
// Inject replacements into composer.json.
if (file_exists($composer_json_tmpl)) {
$composer_json_contents = file_get_contents($composer_json_tmpl);
$composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE);
file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents);
@unlink($composer_json_tmpl);
}
}
}
/**
* Runs the scaffold operation.
*
* This is equivalent to running `composer composer-scaffold`, but we do the
* equivalent operation by instantiating a Handler object in order to continue
* running in the same process, so that coverage may be calculated for the
* code executed by these tests.
*
* @param string $cwd
* The working directory to run the scaffold command in.
*
* @return string
* Output captured from tests that write to Fixtures::io().
*/
public function runScaffold($cwd) {
chdir($cwd);
$handler = new Handler($this->getComposer(), $this->io());
$handler->scaffold();
return $this->getOutput();
}
/**
* Runs a `composer` command.
*
* @param string $cmd
* The Composer command to execute (escaped as required)
* @param string $cwd
* The current working directory to run the command from.
* @param int $expectedExitCode
* The expected exit code; will throw if a different exit code is returned.
*
* @return string
* Standard output and standard error from the command.
*/
public function runComposer($cmd, $cwd, $expectedExitCode = 0) {
chdir($cwd);
$input = new StringInput($cmd);
$output = new BufferedOutput();
$application = new Application();
$application->setAutoExit(FALSE);
try {
$exitCode = $application->run($input, $output);
if ($exitCode != $expectedExitCode) {
print "Command '{$cmd}' - Expected exit code: {$expectedExitCode}, actual exit code: {$exitCode}\n";
}
}
catch (\Exception $e) {
print "Exception: " . $e->getMessage() . "\n";
}
$output = $output->fetch();
return $output;
}
}

View File

@ -0,0 +1,138 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Functional;
use Composer\Util\Filesystem;
use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
use Drupal\Tests\Component\Scaffold\ExecTrait;
use Drupal\Tests\Component\Scaffold\Fixtures;
use PHPUnit\Framework\TestCase;
/**
* Tests Composer Hooks that run scaffold operations.
*
* The purpose of this test file is to exercise all of the different Composer
* commands that invoke scaffold operations, and ensure that files are
* scaffolded when they should be.
*
* Note that this test file uses `exec` to run Composer for a pure functional
* test. Other functional test files invoke Composer commands directly via the
* Composer Application object, in order to get more accurate test coverage
* information.
*
* @group Scaffold
*/
class ComposerHookTest extends TestCase {
use ExecTrait;
use AssertUtilsTrait;
/**
* The root of this project.
*
* Used to substitute this project's base directory into composer.json files
* so Composer can find it.
*
* @var string
*/
protected $projectRoot;
/**
* Directory to perform the tests in.
*
* @var string
*/
protected $fixturesDir;
/**
* The Symfony FileSystem component.
*
* @var \Symfony\Component\Filesystem\Filesystem
*/
protected $fileSystem;
/**
* The Fixtures object.
*
* @var \Drupal\Tests\Component\Scaffold\Fixtures
*/
protected $fixtures;
/**
* {@inheritdoc}
*/
protected function setUp() {
$this->fileSystem = new Filesystem();
$this->fixtures = new Fixtures();
$this->projectRoot = $this->fixtures->projectRoot();
}
/**
* {@inheritdoc}
*/
protected function tearDown() {
// Remove any temporary directories et. al. that were created.
$this->fixtures->tearDown();
}
/**
* Test to see if scaffold operation runs at the correct times.
*/
public function testComposerHooks() {
$this->fixturesDir = $this->fixtures->tmpDir($this->getName());
$replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->projectRoot];
$this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
$topLevelProjectDir = 'composer-hooks-fixture';
$sut = $this->fixturesDir . '/' . $topLevelProjectDir;
// First test: run composer install. This is the same as composer update
// since there is no lock file. Ensure that scaffold operation ran.
$this->mustExec("composer install --no-ansi", $sut);
$this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'Test version of default.settings.php from drupal/core');
// Run composer required to add in the scaffold-override-fixture. This
// project is "allowed" in our main fixture project, but not required.
// We expect that requiring this library should re-scaffold, resulting
// in a changed default.settings.php file.
$stdout = $this->mustExec("composer require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
$this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture');
// Make sure that the appropriate notice informing us that scaffolding
// is allowed was printed.
$this->assertContains('Package fixtures/scaffold-override-fixture has scaffold operations, and is already allowed in the root-level composer.json file.', $stdout);
// Delete one scaffold file, just for test purposes, then run
// 'composer update' and see if the scaffold file is replaced.
@unlink($sut . '/sites/default/default.settings.php');
$this->assertFileNotExists($sut . '/sites/default/default.settings.php');
$this->mustExec("composer update --no-ansi", $sut);
$this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture');
// Delete the same test scaffold file again, then run
// 'composer composer:scaffold' and see if the scaffold file is
// re-scaffolded.
@unlink($sut . '/sites/default/default.settings.php');
$this->assertFileNotExists($sut . '/sites/default/default.settings.php');
$this->mustExec("composer install --no-ansi", $sut);
$this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture');
// Delete the same test scaffold file yet again, then run
// 'composer install' and see if the scaffold file is re-scaffolded.
@unlink($sut . '/sites/default/default.settings.php');
$this->assertFileNotExists($sut . '/sites/default/default.settings.php');
$this->mustExec("composer composer:scaffold --no-ansi", $sut);
$this->assertScaffoldedFile($sut . '/sites/default/default.settings.php', FALSE, 'scaffolded from the scaffold-override-fixture');
// Run 'composer create-project' to create a new test project called
// 'create-project-test', which is a copy of 'fixtures/drupal-drupal'.
$sut = $this->fixturesDir . '/create-project-test';
$filesystem = new Filesystem();
$filesystem->remove($sut);
$stdout = $this->mustExec("composer create-project --repository=packages.json fixtures/drupal-drupal {$sut}", $this->fixturesDir, ['COMPOSER_MIRROR_PATH_REPOS' => 1]);
$this->assertDirectoryExists($sut);
$this->assertContains('Scaffolding files for fixtures/drupal-drupal', $stdout);
$this->assertScaffoldedFile($sut . '/index.php', FALSE, 'Test version of index.php from drupal/core');
$topLevelProjectDir = 'composer-hooks-nothing-allowed-fixture';
$sut = $this->fixturesDir . '/' . $topLevelProjectDir;
// Run composer install on an empty project.
$this->mustExec("composer install --no-ansi", $sut);
// Require a project that is not allowed to scaffold and confirm that we
// get a warning, and it does not scaffold.
$stdout = $this->mustExec("composer require --no-ansi --no-interaction fixtures/scaffold-override-fixture:dev-master", $sut);
$this->assertFileNotExists($sut . '/sites/default/default.settings.php');
$this->assertContains("Not scaffolding files for fixtures/scaffold-override-fixture, because it is not listed in the element 'extra.composer-scaffold.allowed-packages' in the root-level composer.json file.", $stdout);
}
}

View File

@ -0,0 +1,190 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Functional;
use Composer\Util\Filesystem;
use Drupal\Tests\Component\Scaffold\Fixtures;
use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
use Drupal\Tests\Component\Scaffold\ExecTrait;
use PHPUnit\Framework\TestCase;
/**
* Tests to see whether .gitignore files are correctly managed.
*
* The purpose of this test file is to run a scaffold operation and
* confirm that the files that were scaffolded are added to the
* repository's .gitignore file.
*
* @group Scaffold
*/
class ManageGitIgnoreTest extends TestCase {
use ExecTrait;
use AssertUtilsTrait;
/**
* The root of this project.
*
* Used to substitute this project's base directory into composer.json files
* so Composer can find it.
*
* @var string
*/
protected $projectRoot;
/**
* Directory to perform the tests in.
*
* @var string
*/
protected $fixturesDir;
/**
* The Symfony FileSystem component.
*
* @var \Symfony\Component\Filesystem\Filesystem
*/
protected $fileSystem;
/**
* The Fixtures object.
*
* @var \Drupal\Tests\Component\Scaffold\Fixtures
*/
protected $fixtures;
/**
* {@inheritdoc}
*/
protected function setUp() {
$this->fileSystem = new Filesystem();
$this->fixtures = new Fixtures();
$this->projectRoot = $this->fixtures->projectRoot();
}
/**
* {@inheritdoc}
*/
protected function tearDown() {
// Remove any temporary directories et. al. that were created.
$this->fixtures->tearDown();
}
/**
* Creates a system-under-test and initialize a git repository for it.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
*
* @return string
* The path to the fixture directory.
*/
protected function createSutWithGit($fixture_name) {
$this->fixturesDir = $this->fixtures->tmpDir($this->getName());
$sut = $this->fixturesDir . '/' . $fixture_name;
$replacements = ['SYMLINK' => 'false', 'PROJECT_ROOT' => $this->projectRoot];
$this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
// .gitignore files will not be managed unless there is a git repository.
$this->mustExec('git init', $sut);
// Add some user info so git does not complain.
$this->mustExec('git config user.email "test@example.com"', $sut);
$this->mustExec('git config user.name "Test User"', $sut);
$this->mustExec('git add .', $sut);
$this->mustExec('git commit -m "Initial commit."', $sut);
// Run composer install, but suppress scaffolding.
$this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
return $sut;
}
/**
* Tests scaffold command correctly manages the .gitignore file.
*/
public function testManageGitIgnore() {
// Note that the drupal-composer-drupal-project fixture does not
// have any configuration settings related to .gitignore management.
$sut = $this->createSutWithGit('drupal-composer-drupal-project');
$this->assertFileNotExists($sut . '/docroot/index.php');
$this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
// Run the scaffold command.
$this->fixtures->runScaffold($sut);
$this->assertFileExists($sut . '/docroot/index.php');
$expected = <<<EOT
build
.csslintrc
.editorconfig
.eslintignore
.eslintrc.json
.gitattributes
.ht.router.php
autoload.php
index.php
robots.txt
update.php
web.config
EOT;
// At this point we should have a .gitignore file, because although we did
// not explicitly ask for .gitignore tracking, the vendor directory is not
// tracked, so the default in that instance is to manage .gitignore files.
$this->assertScaffoldedFile($sut . '/docroot/.gitignore', FALSE, $expected);
$this->assertScaffoldedFile($sut . '/docroot/sites/.gitignore', FALSE, 'example.settings.local.php');
$this->assertScaffoldedFile($sut . '/docroot/sites/default/.gitignore', FALSE, 'default.services.yml');
$expected = <<<EOT
M docroot/.gitignore
?? docroot/sites/.gitignore
?? docroot/sites/default/.gitignore
EOT;
// Check to see whether there are any untracked files. We expect that
// only the .gitignore files themselves should be untracked.
$stdout = $this->mustExec('git status --porcelain', $sut);
$this->assertEquals(trim($expected), trim($stdout));
}
/**
* Tests scaffold command does not manage the .gitignore file when disabled.
*/
public function testUnmanagedGitIgnoreWhenDisabled() {
// Note that the drupal-drupal fixture has a configuration setting
// `"gitignore": false,` which disables .gitignore file handling.
$sut = $this->createSutWithGit('drupal-drupal');
$this->assertFileNotExists($sut . '/docroot/index.php');
// Run the scaffold command.
$this->fixtures->runScaffold($sut);
$this->assertFileExists($sut . '/index.php');
$this->assertFileNotExists($sut . '/.gitignore');
$this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
}
/**
* Tests scaffold command disables .gitignore management when git not present.
*
* The scaffold operation should still succeed if there is no 'git'
* executable.
*/
public function testUnmanagedGitIgnoreWhenGitNotAvailable() {
// Note that the drupal-composer-drupal-project fixture does not have any
// configuration settings related to .gitignore management.
$sut = $this->createSutWithGit('drupal-composer-drupal-project');
$this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
$this->assertFileNotExists($sut . '/docroot/index.php');
$this->assertFileNotExists($sut . '/docroot/sites/.gitignore');
// Confirm that 'git' is available (n.b. if it were not, createSutWithGit()
// would fail).
exec('git --help', $output, $status);
$this->assertEquals(0, $status);
// Modify our $PATH so that it begins with a path that contains an
// executable script named 'git' that always exits with 127, as if git were
// not found. Note that we run our tests using process isolation, so we do
// not need to restore the PATH when we are done.
$unavailableGitPath = $this->fixtures->binFixtureDir('disable-git-bin');
chmod($unavailableGitPath . '/git', 0755);
putenv('PATH=' . $unavailableGitPath . ':' . getenv('PATH'));
// Confirm that 'git' is no longer available.
exec('git --help', $output, $status);
$this->assertEquals(127, $status);
// Run the scaffold command.
exec('composer composer:scaffold', $output, $status);
$this->assertFileExists($sut . '/docroot/index.php');
$this->assertFileNotExists($sut . '/docroot/sites/default/.gitignore');
}
}

View File

@ -0,0 +1,380 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Functional;
use Composer\Util\Filesystem;
use Drupal\Tests\Component\Scaffold\AssertUtilsTrait;
use Drupal\Tests\Component\Scaffold\Fixtures;
use Drupal\Tests\Component\Scaffold\ScaffoldTestResult;
use PHPUnit\Framework\TestCase;
/**
* Tests Composer Scaffold.
*
* The purpose of this test file is to exercise all of the different kinds of
* scaffold operations: copy, symlinks, skips and so on.
*
* @group Scaffold
*/
class ScaffoldTest extends TestCase {
use AssertUtilsTrait;
/**
* The root of this project.
*
* Used to substitute this project's base directory into composer.json files
* so Composer can find it.
*
* @var string
*/
protected $projectRoot;
/**
* Directory to perform the tests in.
*
* @var string
*/
protected $fixturesDir;
/**
* The Symfony FileSystem component.
*
* @var \Symfony\Component\Filesystem\Filesystem
*/
protected $fileSystem;
/**
* The Fixtures object.
*
* @var \Drupal\Tests\Component\Scaffold\Fixtures
*/
protected $fixtures;
/**
* {@inheritdoc}
*/
protected function setUp() {
$this->fileSystem = new Filesystem();
$this->fixtures = new Fixtures();
$this->projectRoot = $this->fixtures->projectRoot();
// The directory used for creating composer projects to test can be
// configured using the SCAFFOLD_FIXTURE_DIR environment variable. Otherwise
// a directory will be created in the system's temporary directory.
$this->fixturesDir = getenv('SCAFFOLD_FIXTURE_DIR');
if (!$this->fixturesDir) {
$this->fixturesDir = $this->fixtures->tmpDir($this->getName());
}
}
/**
* {@inheritdoc}
*/
protected function tearDown() {
// Remove any temporary directories et. al. that were created.
$this->fixtures->tearDown();
}
/**
* Creates the System-Under-Test.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param array $replacements
* Key : value mappings for placeholders to replace in composer.json
* templates.
*
* @return string
* The path to the created System-Under-Test.
*/
protected function createSut($fixture_name, array $replacements = []) {
$sut = $this->fixturesDir . '/' . $fixture_name;
// Erase just our sut, to ensure it is clean. Recopy all of the fixtures.
$this->fileSystem->remove($sut);
$replacements += ['PROJECT_ROOT' => $this->projectRoot];
$this->fixtures->cloneFixtureProjects($this->fixturesDir, $replacements);
return $sut;
}
/**
* Creates the system-under-test and runs a scaffold operation on it.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param bool $is_link
* Whether to use symlinks for 'replace' operations.
* @param bool $relocated_docroot
* Whether the named fixture has a relocated document root.
*/
public function scaffoldSut($fixture_name, $is_link = FALSE, $relocated_docroot = TRUE) {
$sut = $this->createSut($fixture_name, ['SYMLINK' => $is_link ? 'true' : 'false']);
// Run composer install to get the dependencies we need to test.
$this->fixtures->runComposer("install --no-ansi --no-scripts", $sut);
// Test composer:scaffold.
$scaffoldOutput = $this->fixtures->runScaffold($sut);
// Calculate the docroot directory and assert that our fixture layout
// matches what was stipulated in $relocated_docroot. Fail fast if
// the caller provided the wrong value.
$docroot = $sut;
if ($relocated_docroot) {
$docroot .= '/docroot';
$this->assertFileExists($docroot);
}
else {
$this->assertFileNotExists($sut . '/docroot');
}
return new ScaffoldTestResult($docroot, $scaffoldOutput);
}
/**
* Data provider for testScaffoldWithExpectedException.
*/
public function scaffoldExpectedExceptionTestValues() {
return [
[
'drupal-drupal-missing-scaffold-file',
'Scaffold file assets/missing-robots-default.txt not found in package fixtures/drupal-drupal-missing-scaffold-file.',
TRUE,
],
[
'project-with-empty-scaffold-path',
'No scaffold file path given for [web-root]/my-error in package fixtures/project-with-empty-scaffold-path',
FALSE,
],
[
'project-with-illegal-dir-scaffold',
'Scaffold file assets in package fixtures/project-with-illegal-dir-scaffold is a directory; only files may be scaffolded',
FALSE,
],
];
}
/**
* Tests that scaffold files throw when they have bad values.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param string $expected_exception_message
* The expected exception message.
* @param bool $is_link
* Whether or not symlinking should be used.
*
* @dataProvider scaffoldExpectedExceptionTestValues
*/
public function testScaffoldWithExpectedException($fixture_name, $expected_exception_message, $is_link) {
// Test scaffold. Expect an error.
$this->expectException(\Exception::class);
$this->expectExceptionMessage($expected_exception_message);
$this->scaffoldSut($fixture_name, $is_link);
}
/**
* Try to scaffold a project that does not scaffold anything.
*/
public function testEmptyProject() {
$fixture_name = 'empty-fixture';
$result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
$this->assertContains('Nothing scaffolded because no packages are allowed in the top-level composer.json file', $result->scaffoldOutput());
}
/**
* Try to scaffold a project that allows a project with no scaffold files.
*/
public function testProjectThatScaffoldsEmptyProject() {
$fixture_name = 'project-allowing-empty-fixture';
$is_link = FALSE;
$result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
$this->assertContains('The allowed package fixtures/empty-fixture does not provide a file mapping for Composer Scaffold', $result->scaffoldOutput());
$this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), FALSE);
}
public function scaffoldOverridingSettingsExcludingHtaccessValues() {
return [
[
'drupal-composer-drupal-project',
TRUE,
TRUE,
],
[
'drupal-drupal',
FALSE,
FALSE,
],
];
}
/**
* Asserts that the drupal/assets scaffold files correct for sut.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param bool $is_link
* Whether to use symlinks for 'replace' operations.
* @param bool $relocated_docroot
* Whether the named fixture has a relocated document root.
*
* @dataProvider scaffoldOverridingSettingsExcludingHtaccessValues
*/
public function testScaffoldOverridingSettingsExcludingHtaccess($fixture_name, $is_link, $relocated_docroot) {
$result = $this->scaffoldSut($fixture_name, $is_link, $relocated_docroot);
$this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), $is_link);
$this->assertDefaultSettingsFromScaffoldOverride($result->docroot(), $is_link);
$this->assertHtaccessExcluded($result->docroot());
}
/**
* Asserts that the appropriate file was replaced.
*
* Check the drupal/drupal-based project to confirm that the expected file was
* replaced, and that files that were not supposed to be replaced remain
* unchanged.
*/
public function testDrupalDrupalFileWasReplaced() {
$fixture_name = 'drupal-drupal-test-overwrite';
$result = $this->scaffoldSut($fixture_name, FALSE, FALSE);
$this->assertScaffoldedFile($result->docroot() . '/replace-me.txt', FALSE, 'from assets that replaces file');
$this->assertScaffoldedFile($result->docroot() . '/keep-me.txt', FALSE, 'File in drupal-drupal-test-overwrite that is not replaced');
$this->assertScaffoldedFile($result->docroot() . '/make-me.txt', FALSE, 'from assets that replaces file');
$this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), FALSE);
$this->assertScaffoldedFile($result->docroot() . '/robots.txt', FALSE, $fixture_name);
}
/**
* Test values for testDrupalDrupalFileWasAppended.
*/
public function scaffoldAppendTestValues() {
return array_merge(
$this->scaffoldAppendTestValuesToPermute(FALSE),
$this->scaffoldAppendTestValuesToPermute(TRUE)
);
}
/**
* Test values to run both with $is_link FALSE and $is_link TRUE.
*
* @param bool $is_link
* Whether or not symlinking should be used.
*/
protected function scaffoldAppendTestValuesToPermute($is_link) {
return [
[
'drupal-drupal-test-append',
$is_link,
'# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
# This content is prepended to the top of the existing robots.txt fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# Test version of robots.txt from drupal/core.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
',
],
[
'drupal-drupal-append-to-append',
$is_link,
'# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
# This content is prepended to the top of the existing robots.txt fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# Test version of robots.txt from drupal/core.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in profile-with-append composer.json fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.',
],
];
}
/**
* Tests a fixture where the robots.txt file is prepended / appended to.
*
* @param string $fixture_name
* The name of the fixture to use from
* core/tests/Drupal/Tests/Component/Scaffold/fixtures.
* @param bool $is_link
* Whether or not symlinking should be used.
* @param string $robots_txt_contents
* Regular expression matching expectations for robots.txt.
*
* @dataProvider scaffoldAppendTestValues
*/
public function testDrupalDrupalFileWasAppended($fixture_name, $is_link, $robots_txt_contents) {
$result = $this->scaffoldSut($fixture_name, $is_link, FALSE);
$this->assertScaffoldedFile($result->docroot() . '/robots.txt', FALSE, $robots_txt_contents);
$this->assertCommonDrupalAssetsWereScaffolded($result->docroot(), $is_link);
}
/**
* Asserts that the default settings file was overridden by the test.
*
* @param string $docroot
* The path to the System-under-Test's docroot.
* @param bool $is_link
* Whether or not symlinking is used.
*/
protected function assertDefaultSettingsFromScaffoldOverride($docroot, $is_link) {
$this->assertScaffoldedFile($docroot . '/sites/default/default.settings.php', $is_link, 'scaffolded from the scaffold-override-fixture');
}
/**
* Asserts that the .htaccess file was excluded by the test.
*
* @param string $docroot
* The path to the System-under-Test's docroot.
*/
protected function assertHtaccessExcluded($docroot) {
// Ensure that the .htaccess.txt file was not written, as our
// top-level composer.json excludes it from the files to scaffold.
$this->assertFileNotExists($docroot . '/.htaccess');
}
/**
* Asserts that the scaffold files from drupal/assets are placed as expected.
*
* This tests that all assets from drupal/assets were scaffolded, save
* for .htaccess, robots.txt and default.settings.php, which are scaffolded
* in different ways in different tests.
*
* @param string $docroot
* The path to the System-under-Test's docroot.
* @param bool $is_link
* Whether or not symlinking is used.
*/
protected function assertCommonDrupalAssetsWereScaffolded($docroot, $is_link) {
// Ensure that the autoload.php file was written.
$this->assertFileExists($docroot . '/autoload.php');
// Assert other scaffold files are written in the correct locations.
$this->assertScaffoldedFile($docroot . '/.csslintrc', $is_link, 'Test version of .csslintrc from drupal/core.');
$this->assertScaffoldedFile($docroot . '/.editorconfig', $is_link, 'Test version of .editorconfig from drupal/core.');
$this->assertScaffoldedFile($docroot . '/.eslintignore', $is_link, 'Test version of .eslintignore from drupal/core.');
$this->assertScaffoldedFile($docroot . '/.eslintrc.json', $is_link, 'Test version of .eslintrc.json from drupal/core.');
$this->assertScaffoldedFile($docroot . '/.gitattributes', $is_link, 'Test version of .gitattributes from drupal/core.');
$this->assertScaffoldedFile($docroot . '/.ht.router.php', $is_link, 'Test version of .ht.router.php from drupal/core.');
$this->assertScaffoldedFile($docroot . '/sites/default/default.services.yml', $is_link, 'Test version of default.services.yml from drupal/core.');
$this->assertScaffoldedFile($docroot . '/sites/example.settings.local.php', $is_link, 'Test version of example.settings.local.php from drupal/core.');
$this->assertScaffoldedFile($docroot . '/sites/example.sites.php', $is_link, 'Test version of example.sites.php from drupal/core.');
$this->assertScaffoldedFile($docroot . '/index.php', $is_link, 'Test version of index.php from drupal/core.');
$this->assertScaffoldedFile($docroot . '/update.php', $is_link, 'Test version of update.php from drupal/core.');
$this->assertScaffoldedFile($docroot . '/web.config', $is_link, 'Test version of web.config from drupal/core.');
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Integration;
use Drupal\Component\Scaffold\Operations\AppendOp;
use Drupal\Component\Scaffold\ScaffoldOptions;
use Drupal\Tests\Component\Scaffold\Fixtures;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\Component\Scaffold\Operations\AppendOp
*
* @group Scaffold
*/
class AppendOpTest extends TestCase {
/**
* @covers ::process
*/
public function testProcess() {
$fixtures = new Fixtures();
$destination = $fixtures->destinationPath('[web-root]/robots.txt');
$options = ScaffoldOptions::create([]);
// Assert that there is no target file before we run our test.
$this->assertFileNotExists($destination->fullPath());
// Create a file.
file_put_contents($destination->fullPath(), "# This is a test\n");
$prepend = $fixtures->sourcePath('drupal-drupal-test-append', 'prepend-to-robots.txt');
$append = $fixtures->sourcePath('drupal-drupal-test-append', 'append-to-robots.txt');
$sut = new AppendOp($prepend, $append);
// Test the system under test.
$sut->process($destination, $fixtures->io(), $options);
// Assert that the target file was created.
$this->assertFileExists($destination->fullPath());
// Assert the target contained the contents from the correct scaffold files.
$contents = trim(file_get_contents($destination->fullPath()));
$expected = <<<EOT
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
# This content is prepended to the top of the existing robots.txt fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This is a test
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
EOT;
$this->assertEquals(trim($expected), $contents);
// Confirm that expected output was written to our io fixture.
$output = $fixtures->getOutput();
$this->assertContains('Prepend to [web-root]/robots.txt from assets/prepend-to-robots.txt', $output);
$this->assertContains('Append to [web-root]/robots.txt from assets/append-to-robots.txt', $output);
}
}

View File

@ -0,0 +1,40 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Integration;
use Drupal\Component\Scaffold\Operations\ReplaceOp;
use Drupal\Component\Scaffold\ScaffoldOptions;
use Drupal\Tests\Component\Scaffold\Fixtures;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\Component\Scaffold\Operations\ReplaceOp
*
* @group Scaffold
*/
class ReplaceOpTest extends TestCase {
/**
* @covers ::process
*/
public function testProcess() {
$fixtures = new Fixtures();
$destination = $fixtures->destinationPath('[web-root]/robots.txt');
$source = $fixtures->sourcePath('drupal-assets-fixture', 'robots.txt');
$options = ScaffoldOptions::create([]);
$sut = new ReplaceOp($source, TRUE);
// Assert that there is no target file before we run our test.
$this->assertFileNotExists($destination->fullPath());
// Test the system under test.
$sut->process($destination, $fixtures->io(), $options);
// Assert that the target file was created.
$this->assertFileExists($destination->fullPath());
// Assert the target contained the contents from the correct scaffold file.
$contents = trim(file_get_contents($destination->fullPath()));
$this->assertEquals('# Test version of robots.txt from drupal/core.', $contents);
// Confirm that expected output was written to our io fixture.
$output = $fixtures->getOutput();
$this->assertContains('Copy [web-root]/robots.txt from assets/robots.txt', $output);
}
}

View File

@ -0,0 +1,69 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Integration;
use PHPUnit\Framework\TestCase;
use Drupal\Tests\Component\Scaffold\Fixtures;
use Drupal\Component\Scaffold\Operations\ConjunctionOp;
use Drupal\Component\Scaffold\Operations\SkipOp;
use Drupal\Component\Scaffold\Operations\ScaffoldFileCollection;
/**
* @coversDefaultClass \Drupal\Component\Scaffold\Operations\ScaffoldFileCollection
*
* @group Scaffold
*/
class ScaffoldFileCollectionTest extends TestCase {
/**
* @covers ::__construct
*/
public function testCreate() {
$fixtures = new Fixtures();
$locationReplacements = $fixtures->getLocationReplacements();
$scaffold_file_fixtures = [
'fixtures/drupal-assets-fixture' => [
'[web-root]/index.php' => $fixtures->replaceOp('drupal-assets-fixture', 'index.php'),
'[web-root]/.htaccess' => $fixtures->replaceOp('drupal-assets-fixture', '.htaccess'),
'[web-root]/robots.txt' => $fixtures->replaceOp('drupal-assets-fixture', 'robots.txt'),
'[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-assets-fixture', 'default.services.yml'),
],
'fixtures/drupal-profile' => [
'[web-root]/sites/default/default.services.yml' => $fixtures->replaceOp('drupal-profile', 'profile.default.services.yml'),
],
'fixtures/drupal-drupal' => [
'[web-root]/.htaccess' => new SkipOp(),
'[web-root]/robots.txt' => $fixtures->appendOp('drupal-drupal-test-append', 'append-to-robots.txt'),
],
];
$sut = new ScaffoldFileCollection($scaffold_file_fixtures, $locationReplacements);
$resolved_file_mappings = iterator_to_array($sut);
// Confirm that the keys of the output are the same as the keys of the
// input.
$this->assertEquals(array_keys($scaffold_file_fixtures), array_keys($resolved_file_mappings));
// '[web-root]/robots.txt' is now a SkipOp, as it is now part of a
// conjunction operation.
$this->assertEquals([
'[web-root]/index.php',
'[web-root]/.htaccess',
'[web-root]/robots.txt',
'[web-root]/sites/default/default.services.yml',
], array_keys($resolved_file_mappings['fixtures/drupal-assets-fixture']));
$this->assertInstanceOf(SkipOp::class, $resolved_file_mappings['fixtures/drupal-assets-fixture']['[web-root]/robots.txt']->op());
$this->assertEquals([
'[web-root]/sites/default/default.services.yml',
], array_keys($resolved_file_mappings['fixtures/drupal-profile']));
$this->assertEquals([
'[web-root]/.htaccess',
'[web-root]/robots.txt',
], array_keys($resolved_file_mappings['fixtures/drupal-drupal']));
// Test that .htaccess is skipped.
$this->assertInstanceOf(SkipOp::class, $resolved_file_mappings['fixtures/drupal-assets-fixture']['[web-root]/.htaccess']->op());
// Test that the expected conjunction operation exists.
$this->assertInstanceOf(ConjunctionOp::class, $resolved_file_mappings['fixtures/drupal-drupal']['[web-root]/robots.txt']->op());
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace Drupal\Tests\Component\Scaffold\Integration;
use Drupal\Component\Scaffold\Operations\SkipOp;
use Drupal\Component\Scaffold\ScaffoldOptions;
use Drupal\Tests\Component\Scaffold\Fixtures;
use PHPUnit\Framework\TestCase;
/**
* @coversDefaultClass \Drupal\Component\Scaffold\Operations\SkipOp
*
* @group Scaffold
*/
class SkipOpTest extends TestCase {
/**
* @covers ::process
*/
public function testProcess() {
$fixtures = new Fixtures();
$destination = $fixtures->destinationPath('[web-root]/robots.txt');
$options = ScaffoldOptions::create([]);
$sut = new SkipOp();
// Assert that there is no target file before we run our test.
$this->assertFileNotExists($destination->fullPath());
// Test the system under test.
$sut->process($destination, $fixtures->io(), $options);
// Assert that the target file was not created.
$this->assertFileNotExists($destination->fullPath());
// Confirm that expected output was written to our io fixture.
$output = $fixtures->getOutput();
$this->assertContains('Skip [web-root]/robots.txt: disabled', $output);
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Drupal\Tests\Component\Scaffold;
/**
* Holds result of a scaffold test.
*/
class ScaffoldTestResult {
protected $docroot;
protected $scaffoldOutput;
/**
* Holds the location of the scaffold fixture and the stdout from the test.
*
* @param string $docroot
* @param string $scaffoldOutput
*/
public function __construct($docroot, $scaffoldOutput) {
$this->docroot = $docroot;
$this->scaffoldOutput = $scaffoldOutput;
}
/**
* Returns the location of the docroot from the scaffold test.
*
* @return string
*/
public function docroot() {
return $this->docroot;
}
/**
* Returns the standard output from the scaffold test.
*
* @return string
*/
public function scaffoldOutput() {
return $this->scaffoldOutput;
}
}

View File

@ -0,0 +1,38 @@
# Fixtures README
These fixtures are automatically copied to a temporary directory during test
runs. After the test run, the fixtures are automatically deleted.
Set the SCAFFOLD_FIXTURE_DIR environment variable to place the fixtures in a
specific location rather than a temporary directory. If this is done, then the
fixtures will not be deleted after the test run. This is useful for ad-hoc
testing.
Example:
$ SCAFFOLD_FIXTURE_DIR=$HOME/tmp/scaffold-fixtures composer unit
$ cd $HOME/tmp/scaffold-fixtures
$ cd drupal-drupal
$ composer composer:scaffold
Scaffolding files for fixtures/drupal-assets-fixture:
- Link [web-root]/.csslintrc from assets/.csslintrc
- Link [web-root]/.editorconfig from assets/.editorconfig
- Link [web-root]/.eslintignore from assets/.eslintignore
- Link [web-root]/.eslintrc.json from assets/.eslintrc.json
- Link [web-root]/.gitattributes from assets/.gitattributes
- Link [web-root]/.ht.router.php from assets/.ht.router.php
- Skip [web-root]/.htaccess: overridden in my/project
- Link [web-root]/sites/default/default.services.yml from assets/default.services.yml
- Skip [web-root]/sites/default/default.settings.php: overridden in fixtures/scaffold-override-fixture
- Link [web-root]/sites/example.settings.local.php from assets/example.settings.local.php
- Link [web-root]/sites/example.sites.php from assets/example.sites.php
- Link [web-root]/index.php from assets/index.php
- Skip [web-root]/robots.txt: overridden in my/project
- Link [web-root]/update.php from assets/update.php
- Link [web-root]/web.config from assets/web.config
Scaffolding files for fixtures/scaffold-override-fixture:
- Link [web-root]/sites/default/default.settings.php from assets/override-settings.php
Scaffolding files for my/project:
- Skip [web-root]/.htaccess: disabled
- Link [web-root]/robots.txt from assets/robots-default.txt

View File

@ -0,0 +1 @@
# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.

View File

@ -0,0 +1,67 @@
{
"name": "fixtures/drupal-drupal",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/scaffold-override-fixture"
],
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/robots-default.txt"
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1 @@
# robots.txt fixture scaffolded from "file-mappings" in composer-hooks-fixture composer.json fixture.

View File

@ -0,0 +1,63 @@
{
"name": "fixtures/drupal-drupal",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*"
},
"extra": {
"composer-scaffold": {
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/robots-default.txt"
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1 @@
# Test version of .csslintrc from drupal/core.

View File

@ -0,0 +1 @@
# Test version of .editorconfig from drupal/core.

View File

@ -0,0 +1 @@
# Test version of .eslintignore from drupal/core.

View File

@ -0,0 +1 @@
// Test version of .eslintrc.json from drupal/core.

View File

@ -0,0 +1 @@
# Test version of .gitattributes from drupal/core.

View File

@ -0,0 +1,2 @@
<?php
// Test version of .ht.router.php from drupal/core.

View File

@ -0,0 +1 @@
# Test version of .htaccess from drupal/core.

View File

@ -0,0 +1,4 @@
# Test version of default.services.yml from drupal/core.
# Add a dummy key until YamlPecl can validate an empty YAML file:
# https://www.drupal.org/project/drupal/issues/3003300
foo: bar

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Test version of default.settings.php from drupal/core.
*/

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Test version of example.settings.local.php from drupal/core.
*/

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Test version of example.sites.php from drupal/core.
*/

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Test version of index.php from drupal/core.
*/

View File

@ -0,0 +1 @@
# Test version of robots.txt from drupal/core.

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* Test version of update.php from drupal/core.
*/

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Test version of web.config from drupal/core. -->

View File

@ -0,0 +1,24 @@
{
"name": "fixtures/drupal-assets-fixture",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/.csslintrc": "assets/.csslintrc",
"[web-root]/.editorconfig": "assets/.editorconfig",
"[web-root]/.eslintignore": "assets/.eslintignore",
"[web-root]/.eslintrc.json": "assets/.eslintrc.json",
"[web-root]/.gitattributes": "assets/.gitattributes",
"[web-root]/.ht.router.php": "assets/.ht.router.php",
"[web-root]/.htaccess": "assets/.htaccess",
"[web-root]/sites/default/default.services.yml": "assets/default.services.yml",
"[web-root]/sites/default/default.settings.php": "assets/default.settings.php",
"[web-root]/sites/example.settings.local.php": "assets/example.settings.local.php",
"[web-root]/sites/example.sites.php": "assets/example.sites.php",
"[web-root]/index.php": "assets/index.php",
"[web-root]/robots.txt": "assets/robots.txt",
"[web-root]/update.php": "assets/update.php",
"[web-root]/web.config": "assets/web.config"
}
}
}
}

View File

@ -0,0 +1,2 @@
composer.lock
vendor

View File

@ -0,0 +1 @@
# robots.txt fixture scaffolded from "file-mappings" in drupal-composer-drupal-project composer.json fixture.

View File

@ -0,0 +1,68 @@
{
"name": "fixtures/drupal-composer-drupal-project",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*",
"fixtures/scaffold-override-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/scaffold-override-fixture"
],
"locations": {
"web-root": "./docroot"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/robots-default.txt"
}
},
"installer-paths": {
"docroot/core": ["type:drupal-core"],
"docroot/modules/contrib/{$name}": ["type:drupal-module"],
"docroot/modules/custom/{$name}": ["type:drupal-custom-module"],
"docroot/profiles/contrib/{$name}": ["type:drupal-profile"],
"docroot/profiles/custom/{$name}": ["type:drupal-custom-profile"],
"docroot/themes/contrib/{$name}": ["type:drupal-theme"],
"docroot/themes/custom/{$name}": ["type:drupal-custom-theme"],
"docroot/libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "fixtures/drupal-core-fixture",
"require": {
"fixtures/drupal-assets-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-assets-fixture"
]
}
}
}

View File

@ -0,0 +1,3 @@
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.

View File

@ -0,0 +1,3 @@
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-append-to-append composer.json fixture.
# This content is prepended to the top of the existing robots.txt fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::

View File

@ -0,0 +1,71 @@
{
"name": "fixtures/drupal-drupal-test-append",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"profile-with-append": {
"type": "path",
"url": "../profile-with-append",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/profile-with-append": "*",
"fixtures/drupal-core-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/profile-with-append"
],
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": {
"prepend": "assets/prepend-to-robots.txt",
"append": "assets/append-to-robots.txt"
}
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1,68 @@
{
"name": "fixtures/drupal-drupal-missing-scaffold-file",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*",
"fixtures/scaffold-override-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/scaffold-override-fixture"
],
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/missing-robots-default.txt"
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1,3 @@
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.

View File

@ -0,0 +1,3 @@
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-append composer.json fixture.
# This content is prepended to the top of the existing robots.txt fixture.
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::

View File

@ -0,0 +1,62 @@
{
"name": "fixtures/drupal-drupal-test-append",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture"
],
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": {
"prepend": "assets/prepend-to-robots.txt",
"append": "assets/append-to-robots.txt"
}
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1 @@
# File from assets that replaces file in web root.

View File

@ -0,0 +1 @@
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal-test-overwrite composer.json fixture.

View File

@ -0,0 +1,80 @@
{
"name": "fixtures/drupal-drupal-test-overwrite",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*",
"fixtures/scaffold-override-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/scaffold-override-fixture"
],
"locations": {
"web-root": "./"
},
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": "assets/robots-default.txt",
"make-me.txt": {
"path": "assets/replacement.txt",
"overwrite": false
},
"keep-me.txt": {
"path": "assets/replacement.txt",
"overwrite": false
},
"replace-me.txt": {
"path": "assets/replacement.txt",
"overwrite": true
}
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1 @@
# File in drupal-drupal-test-overwrite that is not replaced by a scaffold file.

View File

@ -0,0 +1 @@
# File in drupal-drupal-test-overwrite that is replaced by a scaffold file.

View File

@ -0,0 +1 @@
# robots.txt fixture scaffolded from "file-mappings" in drupal-drupal composer.json fixture.

View File

@ -0,0 +1,71 @@
{
"name": "fixtures/drupal-drupal",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*",
"fixtures/scaffold-override-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/scaffold-override-fixture"
],
"gitignore": false,
"overwrite": true,
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false,
"[web-root]/robots.txt": {
"mode": "replace",
"path": "assets/robots-default.txt",
"overwrite": true
}
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1,4 @@
# default.services.yml fixture scaffolded from "file-mappings" in drupal-project composer.json fixture.
# Add a dummy key until YamlPecl can validate an empty YAML file:
# https://www.drupal.org/project/drupal/issues/3003300
foo: bar

View File

@ -0,0 +1,10 @@
{
"name": "fixtures/drupal-profile",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/.htaccess": false
}
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "fixtures/empty-fixture-allowing-core",
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture"
],
"locations": {
"web-root": "./"
}
}
}
}

View File

@ -0,0 +1,3 @@
{
"name": "fixtures/empty-fixture"
}

View File

@ -0,0 +1,14 @@
{
"packages": {
"fixtures/drupal-drupal": {
"dev-master": {
"name": "fixtures/drupal-drupal",
"version": "1.0.0",
"dist": {
"url": "./drupal-drupal",
"type": "path"
}
}
}
}
}

View File

@ -0,0 +1,3 @@
# ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
# This content is appended to the bottom of the existing robots.txt fixture.
# robots.txt fixture scaffolded from "file-mappings" in profile-with-append composer.json fixture.

View File

@ -0,0 +1,12 @@
{
"name": "fixtures/profile-with-append",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/robots.txt": {
"append": "assets/append-to-robots.txt"
}
}
}
}
}

View File

@ -0,0 +1,77 @@
{
"name": "fixtures/project-allowing-empty-fixture",
"type": "project",
"minimum-stability": "dev",
"prefer-stable": true,
"repositories": {
"composer-scaffold": {
"type": "path",
"url": "__PROJECT_ROOT__",
"options": {
"symlink": true
}
},
"drupal-core-fixture": {
"type": "path",
"url": "../drupal-core-fixture",
"options": {
"symlink": true
}
},
"drupal-assets-fixture": {
"type": "path",
"url": "../drupal-assets-fixture",
"options": {
"symlink": true
}
},
"empty-fixture": {
"type": "path",
"url": "../empty-fixture",
"options": {
"symlink": true
}
},
"scaffold-override-fixture": {
"type": "path",
"url": "../scaffold-override-fixture",
"options": {
"symlink": true
}
}
},
"require": {
"drupal/core-composer-scaffold": "*",
"fixtures/drupal-core-fixture": "*",
"fixtures/empty-fixture": "*",
"fixtures/scaffold-override-fixture": "*"
},
"extra": {
"composer-scaffold": {
"allowed-packages": [
"fixtures/drupal-core-fixture",
"fixtures/empty-fixture",
"fixtures/scaffold-override-fixture"
],
"locations": {
"web-root": "./"
},
"gitignore": false,
"symlink": __SYMLINK__,
"file-mapping": {
"[web-root]/.htaccess": false
}
},
"installer-paths": {
"core": ["type:drupal-core"],
"modules/contrib/{$name}": ["type:drupal-module"],
"modules/custom/{$name}": ["type:drupal-custom-module"],
"profiles/contrib/{$name}": ["type:drupal-profile"],
"profiles/custom/{$name}": ["type:drupal-custom-profile"],
"themes/contrib/{$name}": ["type:drupal-theme"],
"themes/custom/{$name}": ["type:drupal-custom-theme"],
"libraries/{$name}": ["type:drupal-library"],
"drush/Commands/contrib/{$name}": ["type:drupal-drush"]
}
}
}

View File

@ -0,0 +1,15 @@
{
"name": "fixtures/project-with-empty-scaffold-path",
"extra": {
"composer-scaffold": {
"locations": {
"web-root": "./"
},
"file-mapping": {
"[web-root]/my-error": {
"path": ""
}
}
}
}
}

View File

@ -0,0 +1,15 @@
{
"name": "fixtures/project-with-illegal-dir-scaffold",
"extra": {
"composer-scaffold": {
"locations": {
"web-root": "./"
},
"file-mapping": {
"[web-root]/assets": {
"path": "assets"
}
}
}
}
}

View File

@ -0,0 +1,6 @@
<?php
/**
* @file
* A settings.php fixture file scaffolded from the scaffold-override-fixture.
*/

View File

@ -0,0 +1,10 @@
{
"name": "fixtures/scaffold-override-fixture",
"extra": {
"composer-scaffold": {
"file-mapping": {
"[web-root]/sites/default/default.settings.php": "assets/override-settings.php"
}
}
}
}

View File

@ -0,0 +1,2 @@
#!/bin/bash
exit 127