Publisher

The Publisher library provides a means to copy files within a project using robust detection and error checking.

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.:

<?php

$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 extends FileCollection to enact 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:

<?php

$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:

<?php

use CodeIgniter\Publisher\Publisher;

$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):

<?php

// 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:

<?php

use CodeIgniter\CLI\CLI;
use CodeIgniter\Publisher\Publisher;

foreach (Publisher::discover() as $publisher) {
    $result = $publisher->publish();

    if ($result === false) {
        CLI::error(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:

<?php

$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.

Security

In order to prevent modules from injecting malicious code into your projects, Publisher contains a config file that defines which directories and file patterns are allowed as destinations. By default, files may only be published to your project (to prevent access to the rest of the filesystem), and the public/ folder (FCPATH) will only receive files with the following extensions:

  • Web assets: css, scss, js, map

  • Non-executable web files: htm, html, xml, json, webmanifest

  • Fonts: ttf, eot, woff, woff2

  • Images: gif, jpg, jpeg, tif, tiff, png, webp, bmp, ico, svg

If you need to add or adjust the security for your project then alter the $restrictions property of Config\Publisher in app/Config/Publisher.php.

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 Custom Command to run daily that will handle this for you:

<?php

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(true); // `true` to enable overwrites
        } 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:

<?php

$publisher->addUri('https://example.com/feeds/daily_photo.jpg')->copy(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 extending Publisher in your project. So app/Publishers/BootstrapPublisher.php might look like this:

<?php

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 = VENDORPATH . '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.
     */
    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);
    }
}

Note

Directory $destination must be created before executing the command.

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:

<?php

namespace Math\Auth\Commands;

use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\Publisher\Publisher;
use Throwable;

class AuthPublish 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')[0];

        $publisher = new Publisher($source, APPPATH);

        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->getPublished() as $file) {
            // Replace the namespace
            $contents = file_get_contents($file);
            $contents = str_replace('namespace Math\\Auth', 'namespace ' . APP_NAMESPACE, $contents);
            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

Note

Publisher is an extension of FileCollection so has access to all those methods for reading and filtering files.

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<string, Throwable>

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.

addPath(string $path, bool $recursive = true)

Adds all files indicated by the relative path. Path is a reference to actual files or directories relative to $source. If the relative path resolves to a directory then $recursive will include sub-directories.

addPaths(array $paths, 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)

Downloads the contents of a URI using CURLRequest into the scratch workspace then adds the resulting file to the list.

addUris(array $uris)

Downloads the contents of URIs using CURLRequest into the scratch workspace then adds the resulting files 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.

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:

<?php

$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:

<?php

$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();

Modifying Files

replace(string $file, array $replaces): bool

New in version 4.3.0.

Replaces the $file contents. The second parameter $replaces array specifies the search strings as keys and the replacements as values.

<?php

use CodeIgniter\Publisher\Publisher;

$source    = service('autoloader')->getNamespace('CodeIgniter\\Shield')[0];
$publisher = new Publisher($source, APPPATH);

$file = APPPATH . 'Config/Auth.php';

$publisher->replace(
    $file,
    [
        'use CodeIgniter\Config\BaseConfig;' . "\n" => '',
        'class App extends BaseConfig'              => 'class App extends \Some\Package\SomeConfig',
    ]
);

addLineAfter(string $file, string $line, string $after): bool

New in version 4.3.0.

Adds $line after a line with specific string $after.

<?php

use CodeIgniter\Publisher\Publisher;

$source    = service('autoloader')->getNamespace('CodeIgniter\\Shield')[0];
$publisher = new Publisher($source, APPPATH);

$file = APPPATH . 'Config/App.php';

$publisher->addLineAfter(
    $file,
    '    public int $myOwnConfig = 1000;', // Adds this line
    'public bool $CSPEnabled = false;'     // After this line
);

addLineBefore(string $file, string $line, string $after): bool

New in version 4.3.0.

Adds $line before a line with specific string $after.

<?php

use CodeIgniter\Publisher\Publisher;

$source    = service('autoloader')->getNamespace('CodeIgniter\\Shield')[0];
$publisher = new Publisher($source, APPPATH);

$file = APPPATH . 'Config/App.php';

$publisher->addLineBefore(
    $file,
    '    public int $myOwnConfig = 1000;', // Add this line
    'public bool $CSPEnabled = false;'     // Before this line
);