diff --git a/.gitignore b/.gitignore
index 8dcd98b..9bbe0e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
+# libraries lock file
+/installed-libraries.json
# composer artifacts
/vendor/
diff --git a/README.md b/README.md
index 3681808..2c8ee61 100644
--- a/README.md
+++ b/README.md
@@ -14,19 +14,68 @@ external dependencies for a Drupal site in a single place: the `composer.json` f
```
1. Add libraries to your `composer.json` file via the `drupal-libraries` property
-within `extra`. A library is specified using its name as the key and a URL to its
-distribution ZIP file as the value:
+within `extra`. A library is specified using its name as the key and a URL to
+its distribution ZIP/RAR/TAR/TGZ/TAR.GZ/TAR.BZ2 file as the value.
+It'll attempt to guess the library type from the URL.
+If the file is not a ZIP file, then the URL must end with the file extension:
```json
{
"extra": {
"drupal-libraries": {
+ "chosen": "https://github.com/harvesthq/chosen/releases/download/v1.8.2/chosen_v1.8.2.zip",
"flexslider": "https://github.com/woocommerce/FlexSlider/archive/2.6.4.zip",
- "chosen": "https://github.com/harvesthq/chosen/releases/download/v1.8.2/chosen_v1.8.2.zip"
+ "moment": "https://registry.npmjs.org/moment/-/moment-2.25.0.tgz"
}
}
}
```
+
+ Or alternatively if you want to specify a more detailed definition:
+
+ ```json
+ {
+ "extra": {
+ "drupal-libraries": {
+ "chosen": {
+ "url": "https://github.com/harvesthq/chosen/releases/download/v1.8.2/chosen_v1.8.2.zip"
+ },
+ "flexslider": {
+ "url": "https://github.com/woocommerce/FlexSlider/archive/2.6.4.zip",
+ "version": "2.6.4",
+ "type": "zip",
+ "ignore": ["bower_components", "demo", "node_modules"]
+ },
+ "moment": {
+ "url": "https://registry.npmjs.org/moment/-/moment-2.25.0.tgz",
+ "shasum": "e961ab9a5848a1cf2c52b1af4e6c82a8401e7fe9"
+ },
+ "select2": {
+ "url": "https://github.com/select2/select2/archive/4.0.13.zip",
+ "ignore": [
+ ".*",
+ "*.{md}",
+ "Gruntfile.js",
+ "{docs,src,tests}"
+ ]
+ },
+ "custom-tar-asset": {
+ "url": "https://assets.custom-url.com/unconventional/url/path",
+ "type": "tar",
+ "ignore": [".*", "*.{txt,md}"]
+ }
+ }
+ }
+ }
+ ```
+
+ Where the configuration options are as follows:
+ - `url`: The library URL (mandatory).
+ - `version`: The version of the library (defaults to `1.0.0`).
+ - `type`: The type of library archive, one of (zip, tar, rar, gzip), support depends on your composer version (defaults to `zip`).
+ - `ignore`: Array of folders/file globs to remove from the library (defaults to `[]`). See [PSA-2011-002](https://www.drupal.org/node/1189632).
+ - `shasum`: The SHA1 hash of the asset (optional).
+
_See below for how to find the ZIP URL for a GitHub repo._
1. Ensure composer packages of type `drupal-library` are configured to install to the
@@ -52,6 +101,45 @@ the `installer-paths` property (within `extra`) of your `composer.json`:
`composer install` or `composer update`. (To upgrade a library, simply swap out its URL
in your `composer.json` file and run `composer install` again.)
+## Installing libraries declared by other packages.
+
+Set the `drupal-libraries-dependencies` extra property to `true` to fetch libraries
+declared by all the packages in your project. Only the first declaration of the
+library is ever used, so if you find that multiple packages require different
+versions of the same library, you can declare the correct version in the
+project's `composer.json`.
+
+```json
+{
+ "extra": {
+ "drupal-libraries-dependencies": true
+ }
+}
+```
+
+Alternatively you can restrict it to specific packages (recommended) by setting
+it to an array of packages which you'd like to pull in libraries for.
+
+```json
+{
+ "extra": {
+ "drupal-libraries-dependencies": [
+ "drupal/project1",
+ "drupal/project2"
+ ]
+ }
+}
+```
+
+## How to reference an npm package.
+
+For example, if you're interested in downloading version 2.25.0 of the
+[`moment`](https://www.npmjs.com/package/moment) npm package.
+
+- Run `npm view moment@2.25.0`.
+- Use the `.tarball` value as the library `url`.
+- Use the `.shasum` value as the library `shasum`.
+
## How to Reference a GitHub Repo as a ZIP File
If you are directed to download a library from its GitHub repo, follow these instructions
@@ -90,10 +178,6 @@ exact same version of the library.
## Notes
-- Only ZIP files are supported at this time.
-- This plugin is meant to be used with a root package only (i.e. a Drupal site repo)
-and will not find libraries listed in the `composer.json` files of dependencies
-(e.g. contrib modules).
- This plugin is essentially a shortcut for explicitly declaring the composer package
information for each library zip you need to include in your project, e.g.:
```json
@@ -134,8 +218,6 @@ no way to install these packages directly into the proper /libraries/ folder wit
As well, modules will often list their external library requirements as links to ZIP
distribution files or GitHub repos, making it easier to reference and pull in these
dependencies in that manner with this plugin.
-- This plugin is intended only as a short-term solution for the broader issue of
-external library dependency management among modules and themes.
[composer]: https://getcomposer.org/
[npm]: https://www.npmjs.com/
diff --git a/src/Plugin.php b/src/Plugin.php
index ffa9228..bf827e3 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -4,24 +4,66 @@
use Composer\Composer;
use Composer\IO\IOInterface;
+use Composer\Json\JsonFile;
+use Composer\Package\CompletePackage;
use Composer\Plugin\PluginInterface;
use Composer\EventDispatcher\EventSubscriberInterface;
+use Composer\Script\Event;
use Composer\Script\ScriptEvents;
-use Composer\EventDispatcher\Event;
use Composer\Package\Package;
+use Composer\Util\Filesystem;
+use Symfony\Component\Finder\Finder;
+use Symfony\Component\Finder\Glob;
class Plugin implements PluginInterface, EventSubscriberInterface {
- // property which contains drupal libraries in composer.json extra
- const EXTRA_PROP = 'drupal-libraries';
- // composer package type for libraries
- const TYPE = 'drupal-library';
+ /**
+ * The installed-libraries.json lock file schema version.
+ *
+ * @var string
+ */
+ const SCHEMA_VERSION = '1.0';
+
+ /**
+ * The composer package name.
+ */
+ const PACKAGE_NAME = 'balbuf/drupal-libraries-installer';
+
+ /**
+ * @var Composer $composer
+ */
+ protected $composer;
+
+ /**
+ * @var IOInterface $io
+ */
+ protected $io;
+
+ /**
+ * @var \Composer\Downloader\DownloadManager
+ */
+ protected $downloadManager;
+
+ /**
+ * @var \Composer\Installer\InstallationManager
+ */
+ protected $installationManager;
+
+ /**
+ * @var \Composer\Util\Filesystem
+ */
+ protected $fileSystem;
/**
* Called when the composer plugin is activated.
*/
public function activate(Composer $composer, IOInterface $io) {
- // no activation steps required
+ $this->composer = $composer;
+
+ $this->io = $io;
+ $this->fileSystem = new Filesystem();
+ $this->downloadManager = $composer->getDownloadManager();
+ $this->installationManager = $composer->getInstallationManager();
}
/**
@@ -36,35 +78,325 @@ public static function getSubscribedEvents() {
/**
* Upon running composer install or update, install the drupal libraries.
- * @param Event $event install/update event
+ *
+ * @param Event $event install/update event
+ *
+ * @throws \Exception
*/
public function install(Event $event) {
- // get composer object
$composer = $event->getComposer();
- // get root package extra prop
- $extra = $composer->getPackage()->getExtra();
- // do we have any libraries listed?
- if (empty($extra[static::EXTRA_PROP]) || !is_array($extra[static::EXTRA_PROP])) {
- return;
+ $installed_json_file = new JsonFile($this->getInstalledJsonPath(), NULL, $this->io);
+
+ $installed = NULL;
+ if ($installed_json_file->exists()) {
+ $installed = $installed_json_file->read();
}
- // get some services
- $downloadManager = $composer->getDownloadManager();
- $installationManager = $composer->getInstallationManager();
-
- // install each library
- foreach ($extra[static::EXTRA_PROP] as $library => $url) {
- // create a virtual package for this library
- // we don't ask for a version number, so just use "1.0.0" so the package is considered stable
- $package = new Package(static::TYPE . '/' . $library, '1.0.0', $url);
- // all URLs are assumed to be zips (for now)
- $package->setDistType('zip');
- $package->setDistUrl($url);
- $package->setType(static::TYPE);
- // let composer download and unpack the library for us!
- $downloadManager->download($package, $installationManager->getInstallPath($package));
+ // Reset if the schema doesn't match the current version.
+ if (!isset($installed['schema-version']) || $installed['schema-version'] !== static::SCHEMA_VERSION) {
+ $installed = [
+ 'schema-version' => static::SCHEMA_VERSION,
+ 'installed' => [],
+ ];
}
+
+ $applied_drupal_libraries = $installed['installed'];
+
+ // Process the root package first.
+ $root_package = $composer->getPackage();
+ $processed_drupal_libraries = $this->processPackage([], $applied_drupal_libraries, $root_package);
+
+ // Process libraries declared in dependencies.
+ if (!empty($root_package->getExtra()['drupal-libraries-dependencies'])) {
+ $allowed_dependencies = $root_package->getExtra()['drupal-libraries-dependencies'];
+ $local_repo = $composer->getRepositoryManager()->getLocalRepository();
+ foreach ($local_repo->getCanonicalPackages() as $package) {
+ if (
+ $allowed_dependencies === TRUE ||
+ (is_array($allowed_dependencies) && in_array($package->getName(), $allowed_dependencies, TRUE))
+ ) {
+ if (!empty($package->getExtra()['drupal-libraries'])) {
+ $processed_drupal_libraries += $this->processPackage(
+ $processed_drupal_libraries,
+ $applied_drupal_libraries,
+ $package
+ );
+ }
+ }
+ }
+ }
+
+ // Remove unused libraries from disk before attempting to download new ones.
+ // Avoids the edge-case where the removed folder happens to be the same as the one where the new one is being
+ // installed to.
+ $removed_libraries = array_diff_key($applied_drupal_libraries, $processed_drupal_libraries);
+ if ($removed_libraries) {
+ $this->removeUnusedLibraries($removed_libraries);
+ }
+
+ // Attempt to download the libraries.
+ $this->downloadLibraries($processed_drupal_libraries, $applied_drupal_libraries);
+
+ // Write the lock file to disk.
+ if ($this->io->isDebug()) {
+ $this->io->write(static::PACKAGE_NAME . ':');
+ $this->io->write(
+ sprintf(' - Writing to %s', $this->fileSystem->normalizePath($installed_json_file->getPath()))
+ );
+ }
+
+ $installed['installed'] = $processed_drupal_libraries;
+ $installed_json_file->write($installed);
+ }
+
+ /**
+ * Drupal library processor.
+ *
+ * Inspired by https://github.com/civicrm/composer-downloads-plugin
+ *
+ * @param array $processed_drupal_libraries
+ * @param array $drupal_libraries
+ * Applied drupal libraries.
+ * @param \Composer\Package\PackageInterface $package
+ *
+ * @return array
+ * The processed packages.
+ */
+ protected function processPackage($processed_drupal_libraries, $drupal_libraries, $package) {
+ $extra = $package->getExtra();
+
+ if (empty($extra['drupal-libraries']) || !is_array($extra['drupal-libraries'])) {
+ return $processed_drupal_libraries;
+ }
+
+ // Install each library
+ foreach ($extra['drupal-libraries'] as $library => $library_definition) {
+ $ignore_patterns = [];
+ $sha1checksum = NULL;
+ if (is_string($library_definition)) {
+ // Simple format.
+ $url = $library_definition;
+ list($version, $distribution_type) = $this->guessDefaultsFromUrl($url);
+ }
+ else {
+ if (empty($library_definition['url'])) {
+ throw new \LogicException("The drupal-library '$library' does not contain a valid URL.");
+ }
+ $url = $library_definition['url'];
+ list($version, $distribution_type) = $this->guessDefaultsFromUrl($url);
+ $version = $library_definition['version'] ?? $version;
+ $distribution_type = $library_definition['type'] ?? $distribution_type;
+ $ignore_patterns = $library_definition['ignore'] ?? $ignore_patterns;
+ $sha1checksum = $library_definition['shasum'] ?? $sha1checksum;
+ }
+
+ if (isset($processed_drupal_libraries[$library])) {
+ // Only the first declaration of the library is ever used. This ensures that the root package always
+ // acts as the source of truth over what version of a library is installed.
+ $old_definition = $processed_drupal_libraries[$library];
+ if ($this->io->isDebug()) {
+ $this->io->write(
+ sprintf(
+ 'Library %s already declared by %s, (%s also attempts to declare one). Skipping...',
+ $library . ' [' . $old_definition['url'] . ']',
+ $old_definition['package'],
+ "$library [$url]"
+ )
+ );
+ }
+ }
+ else {
+ // Track installed libraries in the package info in installed-libraries.json
+ $applied_library = [
+ 'version' => $version,
+ 'url' => $url,
+ 'type' => $distribution_type,
+ 'ignore' => $ignore_patterns,
+ 'package' => $package->getName(),
+ ];
+ if (isset($sha1checksum)) {
+ $applied_library['shasum'] = $sha1checksum;
+ }
+
+ $processed_drupal_libraries[$library] = $applied_library;
+ }
+ }
+
+ return $processed_drupal_libraries;
+ }
+
+ /**
+ * Remove old unused libraries from disk.
+ *
+ * @param array $old_libraries
+ */
+ protected function removeUnusedLibraries($old_libraries) {
+ foreach ($old_libraries as $library_name => $library_definition) {
+ $library_package = $this->getLibraryPackage($library_name, $library_definition);
+
+ $this->downloadManager->remove(
+ $library_package,
+ $this->installationManager->getInstallPath($library_package)
+ );
+ }
+ }
+
+ /**
+ * Download library assets if required.
+ *
+ * @param array $processed_libraries
+ * The processed libraries.
+ */
+ protected function downloadLibraries($processed_libraries, $applied_drupal_libraries) {
+ foreach ($processed_libraries as $library_name => $processed_library) {
+ $library_package = $this->getLibraryPackage($library_name, $processed_library);
+
+ $ignore_patterns = $processed_library['ignore'];
+ $install_path = $this->installationManager->getInstallPath($library_package);
+ if (
+ (
+ !isset($applied_drupal_libraries[$library_name]) ||
+ $applied_drupal_libraries[$library_name] !== $processed_library
+ ) ||
+ !file_exists($install_path)
+ ) {
+ // Download if the package:
+ // - wasn't in the lock file.
+ // - doesn't match what is in the lock file.
+ // - doesn't exist on disk.
+ $this->downloadPackage($library_package, $install_path, $ignore_patterns);
+ }
+ }
+ }
+
+ /**
+ * Downloads a library package to disk.
+ *
+ * @param \Composer\Package\Package $library_package
+ * The library package.
+ * @param string $install_path
+ * The package install path.
+ * @param array $ignore_patterns
+ * File patterns to ignore.
+ */
+ protected function downloadPackage(Package $library_package, $install_path, $ignore_patterns) {
+ // Let composer download and unpack the library for us!
+ $this->downloadManager->download($library_package, $install_path);
+
+ // Delete files/folders according to the ignore pattern(s).
+ if ($ignore_patterns) {
+ $finder = new Finder();
+
+ $patterns = [];
+ foreach ($ignore_patterns as $ignore_pattern) {
+ $patterns[$ignore_pattern] = Glob::toRegex($ignore_pattern);
+ }
+
+ $finder
+ ->in($install_path)
+ ->ignoreDotFiles(FALSE)
+ ->ignoreVCS(FALSE)
+ ->ignoreUnreadableDirs()
+ // Custom filter pattern for matching files and folders.
+ ->filter(
+ function ($file) use ($patterns) {
+ /** @var \SplFileInfo $file */
+ $file_pathname = $file->getRelativePathname();
+ if ('\\' === \DIRECTORY_SEPARATOR) {
+ // Normalize the path name.
+ $file_pathname = str_replace('\\', '/', $file_pathname);
+ }
+ foreach ($patterns as $pattern) {
+ if (preg_match($pattern, $file_pathname)) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+ }
+ );
+
+ foreach ($finder as $file) {
+ $file_pathname = $this->fileSystem->normalizePath($file->getPathname());
+ $this->io->writeError(" - Removing $file_pathname");
+ $this->fileSystem->remove($file_pathname);
+ }
+ }
+ }
+
+ /**
+ * Get a drupal-library package object from its definition.
+ *
+ * @param $library_name
+ * @param $library_definition
+ *
+ * @return \Composer\Package\Package
+ */
+ protected function getLibraryPackage($library_name, $library_definition) {
+ $library_package_name = 'drupal-library/' . $library_name;
+ $library_package = new Package(
+ $library_package_name, $library_definition['version'], $library_definition['version']
+ );
+ $library_package->setDistType($library_definition['type']);
+ $library_package->setDistUrl($library_definition['url']);
+ $library_package->setInstallationSource('dist');
+ if (isset($library_definition['shasum'])) {
+ $library_package->setDistSha1Checksum($library_definition['shasum']);
+ }
+ $library_package->setType('drupal-library');
+
+ return $library_package;
+ }
+
+ /**
+ * Guess the default version and distribution type from the URL.
+ *
+ * @param string $url
+ * The URL to process.
+ *
+ * @return array
+ * The version and distribution type.
+ */
+ protected function guessDefaultsFromUrl($url) {
+ // Default to version 1.0.0 so it's considered stable.
+ $version = '1.0.0';
+ // Default to zips.
+ $distribution_type = 'zip';
+ // Attempt to guess the version number and type from the URL.
+ $match = [];
+ if (preg_match('/(v?[\d.]{2,})\.(zip|rar|tgz|tar(?:\.(gz|bz2))?)$/', $url, $match)) {
+ $version = $match[1];
+ $distribution_type = explode('.', $match[2])[0];
+ if ($distribution_type === 'tgz') {
+ $distribution_type = 'tar';
+ }
+ }
+
+ return [$version, $distribution_type];
+ }
+
+ /**
+ * Get the current installed-libraries json file path.
+ *
+ * @return string
+ */
+ protected function getInstalledJsonPath() {
+ // Alternative approach.
+ // $installed_json_file = __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'installed-libraries.json';
+
+ /** @var \Composer\Package\CompletePackage $installer_library_package */
+ $installer_library_package = $this->composer->getRepositoryManager()->getLocalRepository()->findPackage(
+ static::PACKAGE_NAME,
+ '*'
+ );
+
+ if (!$installer_library_package || !$installer_library_package instanceof CompletePackage) {
+ // This should never happen.
+ throw new \LogicException('Could not resolve the %s package!', static::PACKAGE_NAME);
+ }
+
+ return $this->installationManager->getInstallPath($installer_library_package) . '/installed-libraries.json';
}
}