diff --git a/system/Publisher/Publisher.php b/system/Publisher/Publisher.php index 640dd779756e..b9e5cca24e90 100644 --- a/system/Publisher/Publisher.php +++ b/system/Publisher/Publisher.php @@ -15,6 +15,7 @@ use CodeIgniter\Files\File; use CodeIgniter\HTTP\URI; use CodeIgniter\Publisher\Exceptions\PublisherException; +use RuntimeException; use Throwable; /** @@ -64,6 +65,13 @@ class Publisher */ private $errors = []; + /** + * List of file published curing the last write operation. + * + * @var string[] + */ + private $published = []; + /** * Base path to use for the source. * @@ -85,7 +93,7 @@ class Publisher * * @return self[] */ - public static function discover(string $directory = 'Publishers'): array + final public static function discover(string $directory = 'Publishers'): array { if (isset(self::$discovered[$directory])) { @@ -301,7 +309,7 @@ public function __destruct() } /** - * Reads files in the sources and copies them out to their destinations. + * Reads files from the sources and copies them out to their destinations. * This method should be reimplemented by child classes intended for * discovery. * @@ -309,17 +317,42 @@ public function __destruct() */ public function publish(): bool { + if ($this->source === ROOTPATH && $this->destination === FCPATH) + { + throw new RuntimeException('Child classes of Publisher should provide their own source and destination or publish method.'); + } + return $this->addPath('/')->merge(true); } //-------------------------------------------------------------------- + /** + * Returns the source directory. + * + * @return string + */ + final public function getSource(): string + { + return $this->source; + } + + /** + * Returns the destination directory. + * + * @return string + */ + final public function getDestination(): string + { + return $this->destination; + } + /** * Returns the temporary workspace, creating it if necessary. * * @return string */ - protected function getScratch(): string + final public function getScratch(): string { if (is_null($this->scratch)) { @@ -335,17 +368,27 @@ protected function getScratch(): string * * @return array */ - public function getErrors(): array + final public function getErrors(): array { return $this->errors; } + /** + * Returns the files published by the last write operation. + * + * @return string[] + */ + final public function getPublished(): array + { + return $this->published; + } + /** * Optimizes and returns the current file list. * * @return string[] */ - public function getFiles(): array + final public function getFiles(): array { $this->files = array_unique($this->files, SORT_STRING); sort($this->files, SORT_STRING); @@ -353,6 +396,8 @@ public function getFiles(): array return $this->files; } + //-------------------------------------------------------------------- + /** * Sets the file list directly, files are still subject to verification. * This works as a "reset" method with []. @@ -361,15 +406,13 @@ public function getFiles(): array * * @return $this */ - public function setFiles(array $files) + final public function setFiles(array $files) { $this->files = []; return $this->addFiles($files); } - //-------------------------------------------------------------------- - /** * Verifies and adds files to the list. * @@ -377,7 +420,7 @@ public function setFiles(array $files) * * @return $this */ - public function addFiles(array $files) + final public function addFiles(array $files) { foreach ($files as $file) { @@ -394,7 +437,7 @@ public function addFiles(array $files) * * @return $this */ - public function addFile(string $file) + final public function addFile(string $file) { $this->files[] = self::resolveFile($file); @@ -408,7 +451,7 @@ public function addFile(string $file) * * @return $this */ - public function removeFiles(array $files) + final public function removeFiles(array $files) { $this->files = array_diff($this->files, $files); @@ -422,7 +465,7 @@ public function removeFiles(array $files) * * @return $this */ - public function removeFile(string $file) + final public function removeFile(string $file) { return $this->removeFiles([$file]); } @@ -438,7 +481,7 @@ public function removeFile(string $file) * * @return $this */ - public function addDirectories(array $directories, bool $recursive = false) + final public function addDirectories(array $directories, bool $recursive = false) { foreach ($directories as $directory) { @@ -456,7 +499,7 @@ public function addDirectories(array $directories, bool $recursive = false) * * @return $this */ - public function addDirectory(string $directory, bool $recursive = false) + final public function addDirectory(string $directory, bool $recursive = false) { $directory = self::resolveDirectory($directory); @@ -486,7 +529,7 @@ public function addDirectory(string $directory, bool $recursive = false) * * @return $this */ - public function addPaths(array $paths, bool $recursive = true) + final public function addPaths(array $paths, bool $recursive = true) { foreach ($paths as $path) { @@ -504,7 +547,7 @@ public function addPaths(array $paths, bool $recursive = true) * * @return $this */ - public function addPath(string $path, bool $recursive = true) + final public function addPath(string $path, bool $recursive = true) { $full = $this->source . $path; @@ -530,7 +573,7 @@ public function addPath(string $path, bool $recursive = true) * * @return $this */ - public function addUris(array $uris) + final public function addUris(array $uris) { foreach ($uris as $uri) { @@ -547,7 +590,7 @@ public function addUris(array $uris) * * @return $this */ - public function addUri(string $uri) + final public function addUri(string $uri) { // Figure out a good filename (using URI strips queries and fragments) $file = $this->getScratch() . basename((new URI($uri))->getPath()); @@ -569,7 +612,7 @@ public function addUri(string $uri) * * @return $this */ - public function removePattern(string $pattern, string $scope = null) + final public function removePattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -592,7 +635,7 @@ public function removePattern(string $pattern, string $scope = null) * * @return $this */ - public function retainPattern(string $pattern, string $scope = null) + final public function retainPattern(string $pattern, string $scope = null) { if ($pattern === '') { @@ -613,7 +656,7 @@ public function retainPattern(string $pattern, string $scope = null) * * @return $this */ - public function wipe() + final public function wipe() { self::wipeDirectory($this->destination); @@ -629,9 +672,9 @@ public function wipe() * * @return boolean Whether all files were copied successfully */ - public function copy(bool $replace = true): bool + final public function copy(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; foreach ($this->getFiles() as $file) { @@ -640,6 +683,7 @@ public function copy(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { @@ -658,9 +702,9 @@ public function copy(bool $replace = true): bool * * @return boolean Whether all files were copied successfully */ - public function merge(bool $replace = true): bool + final public function merge(bool $replace = true): bool { - $this->errors = []; + $this->errors = $this->published = []; // Get the file from source for special handling $sourced = self::filterFiles($this->getFiles(), $this->source); @@ -678,6 +722,7 @@ public function merge(bool $replace = true): bool try { self::safeCopyFile($file, $to, $replace); + $this->published[] = $to; } catch (Throwable $e) { diff --git a/tests/_support/Publishers/TestPublisher.php b/tests/_support/Publishers/TestPublisher.php index fdb3547478d1..9192c3899b83 100644 --- a/tests/_support/Publishers/TestPublisher.php +++ b/tests/_support/Publishers/TestPublisher.php @@ -4,13 +4,62 @@ use CodeIgniter\Publisher\Publisher; -class TestPublisher extends Publisher +final class TestPublisher extends Publisher { /** - * Runs the defined Operations. + * Fakes an error on the given file. + * + * @return $this + */ + public static function setError(string $file) + { + self::$error = $file; + } + + /** + * A file to cause an error + * + * @var string + */ + private static $error = ''; + + /** + * Base path to use for the source. + * + * @var string + */ + protected $source = SUPPORTPATH . 'Files'; + + /** + * Base path to use for the destination. + * + * @var string + */ + protected $destination = WRITEPATH; + + /** + * Fakes a publish event so no files are actually copied. */ public function publish(): bool { - $this->downloadFromUrls($urls)->mergeToDirectory(FCPATH . 'assets'); + $this->errors = $this->published = []; + + $this->addPath(''); + + // Copy each sourced file to its relative destination + foreach ($this->getFiles() as $file) + { + if ($file === self::$error) + { + $this->errors[$file] = new RuntimeException('Have an error, dear.'); + } + else + { + // Resolve the destination path + $this->published[] = $this->destination . substr($file, strlen($this->source)); + } + } + + return $this->errors === []; } } diff --git a/tests/system/Publisher/PublisherOutputTest.php b/tests/system/Publisher/PublisherOutputTest.php index 5183d188d6b9..0f416440b803 100644 --- a/tests/system/Publisher/PublisherOutputTest.php +++ b/tests/system/Publisher/PublisherOutputTest.php @@ -112,6 +112,7 @@ public function testCopyIgnoresSame() $result = $publisher->copy(true); $this->assertTrue($result); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyIgnoresCollision() @@ -121,10 +122,10 @@ public function testCopyIgnoresCollision() mkdir($this->root->url() . '/banana.php'); $result = $publisher->addFile($this->file)->copy(false); - $errors = $publisher->getErrors(); $this->assertTrue($result); - $this->assertSame([], $errors); + $this->assertSame([], $publisher->getErrors()); + $this->assertSame([$this->root->url() . '/banana.php'], $publisher->getPublished()); } public function testCopyCollides() @@ -140,6 +141,7 @@ public function testCopyCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->file], array_keys($errors)); + $this->assertSame([], $publisher->getPublished()); $this->assertSame($expected, $errors[$this->file]->getMessage()); } @@ -148,6 +150,12 @@ public function testCopyCollides() public function testMerge() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $this->assertFileDoesNotExist($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryDoesNotExist($this->root->url() . '/baker'); @@ -157,23 +165,36 @@ public function testMerge() $this->assertTrue($result); $this->assertFileExists($this->root->url() . '/able/fig_3.php'); $this->assertDirectoryExists($this->root->url() . '/baker'); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeReplace() { $this->assertFalse(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); + $expected = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/fig_3.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; $result = $publisher->addPath('/')->merge(true); $this->assertTrue($result); $this->assertTrue(same_file($this->directory . 'apple.php', $this->root->url() . '/able/apple.php')); + $this->assertSame($expected, $publisher->getPublished()); } public function testMergeCollides() { $publisher = new Publisher(SUPPORTPATH . 'Files', $this->root->url()); $expected = lang('Publisher.collision', ['dir', $this->directory . 'fig_3.php', $this->root->url() . '/able/fig_3.php']); + $published = [ + $this->root->url() . '/able/apple.php', + $this->root->url() . '/able/prune_ripe.php', + $this->root->url() . '/baker/banana.php', + ]; mkdir($this->root->url() . '/able/fig_3.php'); @@ -183,6 +204,7 @@ public function testMergeCollides() $this->assertFalse($result); $this->assertCount(1, $errors); $this->assertSame([$this->directory . 'fig_3.php'], array_keys($errors)); + $this->assertSame($published, $publisher->getPublished()); $this->assertSame($expected, $errors[$this->directory . 'fig_3.php']->getMessage()); } diff --git a/tests/system/Publisher/PublisherSupportTest.php b/tests/system/Publisher/PublisherSupportTest.php index 5592c02fc4ca..f17be3460432 100644 --- a/tests/system/Publisher/PublisherSupportTest.php +++ b/tests/system/Publisher/PublisherSupportTest.php @@ -126,13 +126,26 @@ public function testResolveFileDirectory() //-------------------------------------------------------------------- + public function testGetSource() + { + $publisher = new Publisher(ROOTPATH); + + $this->assertSame(ROOTPATH, $publisher->getSource()); + } + + public function testGetDestination() + { + $publisher = new Publisher(ROOTPATH, SUPPORTPATH); + + $this->assertSame(SUPPORTPATH, $publisher->getDestination()); + } + public function testGetScratch() { $publisher = new Publisher(); $this->assertNull($this->getPrivateProperty($publisher, 'scratch')); - $method = $this->getPrivateMethodInvoker($publisher, 'getScratch'); - $scratch = $method(); + $scratch = $publisher->getScratch(); $this->assertIsString($scratch); $this->assertDirectoryExists($scratch); diff --git a/user_guide_src/source/libraries/publisher.rst b/user_guide_src/source/libraries/publisher.rst new file mode 100644 index 000000000000..ef97e0b52f95 --- /dev/null +++ b/user_guide_src/source/libraries/publisher.rst @@ -0,0 +1,437 @@ +######### +Publisher +######### + +The Publisher library provides a means to copy files within a project using robust detection and error checking. + +.. contents:: + :local: + :depth: 2 + +******************* +Loading the Library +******************* + +Because Publisher instances are specific to their source and destination this library is not available +through ``Services`` but should be instantiated or extended directly. E.g. + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +***************** +Concept and Usage +***************** + +``Publisher`` solves a handful of common problems when working within a backend framework: + +* How do I maintain project assets with version dependencies? +* How do I manage uploads and other "dynamic" files that need to be web accessible? +* How can I update my project when the framework or modules change? +* How can components inject new content into existing projects? + +At its most basic, publishing amounts to copying a file or files into a project. ``Publisher`` uses fluent-style +command chaining to read, filter, and process input files, then copies or merges them into the target destination. +You may use ``Publisher`` on demand in your Controllers or other components, or you may stage publications by extending +the class and leveraging its discovery with ``spark publish``. + +On Demand +========= + +Access ``Publisher`` directly by instantiating a new instance of the class:: + + $publisher = new \CodeIgniter\Publisher\Publisher(); + +By default the source and destination will be set to ``ROOTPATH`` and ``FCPATH`` respectively, giving ``Publisher`` +easy access to take any file from your project and make it web-accessible. Alternatively you may pass a new source +or source and destination into the constructor:: + + $vendorPublisher = new Publisher(ROOTPATH . 'vendor'); + $filterPublisher = new Publisher('/path/to/module/Filters', APPPATH . 'Filters'); + +Once the source and destination are set you may start adding relative input files:: + + $frameworkPublisher = new Publisher(ROOTPATH . 'vendor/codeigniter4/codeigniter4'); + + // All "path" commands are relative to $source + $frameworkPublisher->addPath('app/Config/Cookie.php'); + + // You may also add from outside the source, but the files will not be merged into subdirectories + $frameworkPublisher->addFiles([ + '/opt/mail/susan', + '/opt/mail/ubuntu', + ]); + $frameworkPublisher->addDirectory(SUPPORTPATH . 'Images'); + +Once all the files are staged use one of the output commands (**copy()** or **merge()**) to process the staged files +to their destination(s):: + + // Place all files into $destination + $frameworkPublisher->copy(); + + // Place all files into $destination, overwriting existing files + $frameworkPublisher->copy(true); + + // Place files into their relative $destination directories, overwriting and saving the boolean result + $result = $frameworkPublisher->merge(true); + +See the Library Reference for a full description of available methods. + +Automation and Discovery +======================== + +You may have regular publication tasks embedded as part of your application deployment or upkeep. ``Publisher`` leverages +the powerful ``Autoloader`` to locate any child classes primed for publication:: + + use CodeIgniter\CLI\CLI; + use CodeIgniter\Publisher\Publisher; + + foreach (Publisher::discover() as $publisher) + { + $result = $publisher->publish(); + + if ($result === false) + { + CLI::write(get_class($publisher) . ' failed to publish!', 'red'); + } + } + +By default ``discover()`` will search for the "Publishers" directory across all namespaces, but you may specify a +different directory and it will return any child classes found:: + + $memePublishers = Publisher::discover('CatGIFs'); + +Most of the time you will not need to handle your own discovery, just use the provided "publish" command:: + + > php spark publish + +By default on your class extension ``publish()`` will add all files from your ``$source`` and merge them +out to your destination, overwriting on collision. + +******** +Examples +******** + +Here are a handful of example use cases and their implementations to help you get started publishing. + +File Sync Example +================= + +You want to display a "photo of the day" image on your homepage. You have a feed for daily photos but you +need to get the actual file into a browsable location in your project at **public/images/daily_photo.jpg**. +You can set up :doc:`Custom Command ` to run daily that will handle this for you:: + + namespace App\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class DailyPhoto extends BaseCommand + { + protected $group = 'Publication'; + protected $name = 'publish:daily'; + protected $description = 'Publishes the latest daily photo to the homepage.'; + + public function run(array $params) + { + $publisher = new Publisher('/path/to/photos/', FCPATH . 'assets/images'); + + try + { + $publisher->addPath('daily_photo.jpg')->copy($replace = true); + } + catch (Throwable $e) + { + $this->showError($e); + } + } + } + +Now running ``spark publish:daily`` will keep your homepage's image up-to-date. What if the photo is +coming from an external API? You can use ``addUri()`` in place of ``addPath()`` to download the remote +resource and publish it out instead:: + + $publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy($replace = true); + +Asset Dependencies Example +========================== + +You want to integrate the frontend library "Bootstrap" into your project, but the frequent updates makes it a hassle +to keep up with. You can create a publication definition in your project to sync frontend assets by adding extending +``Publisher`` in your project. So **app/Publishers/BootstrapPublisher.php** might look like this:: + + namespace App\Publishers; + + use CodeIgniter\Publisher\Publisher; + + class BootstrapPublisher extends Publisher + { + /** + * Tell Publisher where to get the files. + * Since we will use Composer to download + * them we point to the "vendor" directory. + * + * @var string + */ + protected $source = 'vendor/twbs/bootstrap/'; + + /** + * FCPATH is always the default destination, + * but we may want them to go in a sub-folder + * to keep things organized. + * + * @var string + */ + protected $destination = FCPATH . 'bootstrap'; + + /** + * Use the "publish" method to indicate that this + * class is ready to be discovered and automated. + * + * @return boolean + */ + public function publish(): bool + { + return $this + // Add all the files relative to $source + ->addPath('dist') + + // Indicate we only want the minimized versions + ->retainPattern('*.min.*) + + // Merge-and-replace to retain the original directory structure + ->merge(true); + } + +Now add the dependency via Composer and call ``spark publish`` to run the publication:: + + > composer require twbs/bootstrap + > php spark publish + +... and you'll end up with something like this: + + public/.htaccess + public/favicon.ico + public/index.php + public/robots.txt + public/ + bootstrap/ + css/ + bootstrap.min.css + bootstrap-utilities.min.css.map + bootstrap-grid.min.css + bootstrap.rtl.min.css + bootstrap.min.css.map + bootstrap-reboot.min.css + bootstrap-utilities.min.css + bootstrap-reboot.rtl.min.css + bootstrap-grid.min.css.map + js/ + bootstrap.esm.min.js + bootstrap.bundle.min.js.map + bootstrap.bundle.min.js + bootstrap.min.js + bootstrap.esm.min.js.map + bootstrap.min.js.map + +Module Deployment Example +========================= + +You want to allow developers using your popular authentication module the ability to expand on the default behavior +of your Migration, Controller, and Model. You can create your own module "publish" command to inject these components +into an application for use:: + + namespace Math\Auth\Commands; + + use CodeIgniter\CLI\BaseCommand; + use CodeIgniter\Publisher\Publisher; + use Throwable; + + class Publish extends BaseCommand + { + protected $group = 'Auth'; + protected $name = 'auth:publish'; + protected $description = 'Publish Auth components into the current application.'; + + public function run(array $params) + { + // Use the Autoloader to figure out the module path + $source = service('autoloader')->getNamespace('Math\\Auth'); + + $publisher = new Publisher($source, APPATH); + + try + { + // Add only the desired components + $publisher->addPaths([ + 'Controllers', + 'Database/Migrations', + 'Models', + ])->merge(false); // Be careful not to overwrite anything + } + catch (Throwable $e) + { + $this->showError($e); + return; + } + + // If publication succeeded then update namespaces + foreach ($publisher->getFiles as $original) + { + // Get the location of the new file + $file = str_replace($source, APPPATH, $original); + + // Replace the namespace + $contents = file_get_contents($file); + $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, ); + file_put_contents($file, $contents); + } + } + } + +Now when your module users run ``php spark auth:publish`` they will have the following added to their project:: + + app/Controllers/AuthController.php + app/Database/Migrations/2017-11-20-223112_create_auth_tables.php.php + app/Models/LoginModel.php + app/Models/UserModel.php + +***************** +Library Reference +***************** + +Support Methods +=============== + +**[static] discover(string $directory = 'Publishers'): Publisher[]** + +Discovers and returns all Publishers in the specified namespace directory. For example, if both +**app/Publishers/FrameworkPublisher.php** and **myModule/src/Publishers/AssetPublisher.php** exist and are +extensions of ``Publisher`` then ``Publisher::discover()`` would return an instance of each. + +**publish(): bool** + +Processes the full input-process-output chain. By default this is the equivalent of calling ``addPath($source)`` +and ``merge(true)`` but child classes will typically provide their own implementation. ``publish()`` is called +on all discovered Publishers when running ``spark publish``. +Returns success or failure. + +**getScratch(): string** + +Returns the temporary workspace, creating it if necessary. Some operations use intermediate storage to stage +files and changes, and this provides the path to a transient, writable directory that you may use as well. + +**getErrors(): array** + +Returns any errors from the last write operation. The array keys are the files that caused the error, and the +values are the Throwable that was caught. Use ``getMessage()`` on the Throwable to get the error message. + +**getFiles(): string[]** + +Returns an array of all the loaded input files. + +Inputting Files +=============== + +**setFiles(array $files)** + +Sets the list of input files to the provided string array of file paths. + +**addFile(string $file)** +**addFiles(array $files)** + +Adds the file or files to the current list of input files. Files are absolute paths to actual files. + +**removeFile(string $file)** +**removeFiles(array $files)** + +Removes the file or files from the current list of input files. + +**addDirectory(string $directory, bool $recursive = false)** +**addDirectories(array $directories, bool $recursive = false)** + +Adds all files from the directory or directories, optionally recursing into sub-directories. Directories are +absolute paths to actual directories. + +**addPath(string $path, bool $recursive = true)** +**addPaths(array $path, bool $recursive = true)** + +Adds all files indicated by the relative paths. Paths are references to actual files or directories relative +to ``$source``. If the relative path resolves to a directory then ``$recursive`` will include sub-directories. + +**addUri(string $uri)** +**addUris(array $uris)** + +Downloads the contents of a URI using ``CURLRequest`` into the scratch workspace then adds the resulting +file to the list. + +.. note:: The CURL request made is a simple ``GET`` and uses the response body for the file contents. Some + remote files may need a custom request to be handled properly. + +Filtering Files +=============== + +**removePattern(string $pattern, string $scope = null)** +**retainPattern(string $pattern, string $scope = null)** + +Filters the current file list through the pattern (and optional scope), removing or retaining matched +files. ``$pattern`` may be a complete regex (like ``'#[A-Za-z]+\.php#'``) or a pseudo-regex similar +to ``glob()`` (like ``*.css``). +If a ``$scope`` is provided then only files in or under that directory will be considered (i.e. files +outside of ``$scope`` are always retained). When no scope is provided then all files are subject. + +Examples:: + + $publisher = new Publisher(APPPATH . 'Config'); + $publisher->addPath('/', true); // Adds all Config files and directories + + $publisher->removePattern('*tion.php'); // Would remove Encryption.php, Validation.php, and boot/production.php + $publisher->removePattern('*tion.php', APPPATH . 'Config/boot'); // Would only remove boot/production.php + + $publisher->retainPattern('#A.+php$#'); // Would keep only Autoload.php + $publisher->retainPattern('#d.+php$#', APPPATH . 'Config/boot'); // Would keep everything but boot/production.php and boot/testing.php + +Outputting Files +================ + +**wipe()** + +Removes all files, directories, and sub-directories from ``$destination``. + +.. important:: Use wisely. + +**copy(bool $replace = true): bool** + +Copies all files into the ``$destination``. This does not recreate the directory structure, so every file +from the current list will end up in the same destination directory. Using ``$replace`` will cause files +to overwrite when there is already an existing file. Returns success or failure, use ``getPublished()`` +and ``getErrors()`` to troubleshoot failures. +Be mindful of duplicate basename collisions, for example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // This is bad! Only one file will remain at /home/destination/lead.png + $publisher->copy(true); + +**merge(bool $replace = true): bool** + +Copies all files into the ``$destination`` in appropriate relative sub-directories. Any files that +match ``$source`` will be placed into their equivalent directories in ``$destination``, effectively +creating a "mirror" or "rsync" operation. Using ``$replace`` will cause files +to overwrite when there is already an existing file; since directories are merged this will not +affect other files in the destination. Returns success or failure, use ``getPublished()`` and +``getErrors()`` to troubleshoot failures. + +Example:: + + $publisher = new Publisher('/home/source', '/home/destination'); + $publisher->addPaths([ + 'pencil/lead.png', + 'metal/lead.png', + ]); + + // Results in "/home/destination/pencil/lead.png" and "/home/destination/metal/lead.png" + $publisher->merge();