| #!/usr/bin/php
<?php
/**
 * Components manager.
 *
 * @copyright 2015 Fernando Val
 * @author    Fernando Val <[email protected] >
 *
 * @version   5.0.3
 *
 * This is script is not a Composer plugin.
 *
 * This post install/update script for Composer is not a packager version number.
 * It is a helper program to copy (and minify) component files from the download
 * destination directories to final folders in the web server accessible tree.
 * Than you can use your favorite package manager like Composer, NPM, Yarn, etc.
 *
 * The "components.json" file will be loaded if it exists. Then the list of
 * components listed inside "components" entry.
 *
 * The following attributes will be used:
 *
 * "source" - (only in components.json) The source folder where to find the files;
 * "target" - Destination folder to the files;
 * "ignore-subdirs" - If true all files will be sabed in same folder;
 * "minify" - "on" or "off" to minify or not the Javascript and CSS files;
 * "files" - The array of files or the file to be copied. Wildcards accepted.
 *
 * If there is no "files" defined for every component, their bower.json file is
 * used by this script to decide which files will be copied.
 *
 * NOTE: To minify CSS and JS files, is recommended the use of the Minify class
 * by Matthias Mullie.
 * https://github.com/matthiasmullie/minify
 */
define('DS', DIRECTORY_SEPARATOR);
define('LF', "\n");
define('CS_GREEN', "\033[32m");
define('CS_RED', "\033[31m");
define('CS_RESET', "\033[0m");
define('TAB', '    ');
define('LOCK_FILE', __DIR__ . DS . 'components.lock');
define('BOWER_FILE', 'bower.json');
/**
 * Gets the component source path.
 *
 * Also checks if the destination is defined.
 *
 * @param array $data
 *
 * @return string
 */
function checkComponentPath(array $data): string
{
    if (!isset($data['source'])) {
        fatalError(TAB . 'Component source path undefined.');
    }
    // Component sub directory
    $path = __DIR__ . DS . implode(DS, explode('/', $data['source']));
    // Check component's source path
    if (!is_dir($path)) {
        echo TAB, CS_RED, 'Component\'s "', $path, '" does not exists.', CS_RESET, LF;
        return '';
    }
    // Check compnent's configuration
    if (!isset($data['target'])) {
        echo TAB, CS_RED, 'Target directory not defined.', CS_RESET, LF;
        return '';
    }
    return $path;
}
/**
 * Copies all files from a directory.
 *
 * @param string $path
 * @param string $dest
 * @param string $minify
 *
 * @return array
 */
function copyDir(string $path, string $dest, string $minify): array
{
    $installed = [];
    $objects = scandir($path);
    foreach ($objects as $file) {
        if ($file == '.' || $file == '..') {
            continue;
        }
        $installed = array_merge(
            $installed,
            recursiveCopy($path . DS . $file, $dest . DS . $file, $minify)
        );
    }
    return $installed;
}
/**
 * Copy a file.
 *
 * @param string $path
 * @param string $dest
 * @param string $minify
 *
 * @return void
 */
function copyFile(string $path, string $dest, string $minify): void
{
    // The source is a file
    $dir = dirname($dest);
    grantDestination($dir);
    // Copy only if source is new or newer
    if (is_file($dest) && filemtime($path) < filemtime($dest)) {
        return;
    } elseif (!realCopy($path, $dest, $minify)) {
        echo TAB, CS_RED, '[ERROR] Copying (', $path, ') to (', $dest, ')', CS_RESET, LF;
    }
}
/**
 * Removes an empty directory.
 *
 * @param array $dir
 *
 * @return void
 */
function delDir(array $dir): void
{
    $type = $dir['type'] ?? '';
    $path = $dir['path'] ?? '';
    if ($type !== 'd' || !$path) {
        return;
    } elseif (is_dir($path) && count(getDir($path)) === 0 && !rmdir($path)) {
        echo TAB, CS_RED, 'Fail to delete "', $path, '" file.', CS_RESET, LF;
    }
}
/**
 * Deletes a file.
 *
 * @param array $file
 *
 * @return void
 */
function delFile(array $file): void
{
    $type = $file['type'] ?? '';
    $path = $file['path'] ?? '';
    if ($type !== 'f' || !$path) {
        return;
    } elseif (is_file($path) && !unlink($path)) {
        echo TAB, CS_RED, 'Fail to delete "', $path, '" file.', CS_RESET, LF;
    }
    $dir = dirname($path);
    $files = glob($dir . DS . '{.[!.],}*', GLOB_BRACE);
    if (is_array($files) && !count($files)) {
        delDir([
            'type' => 'd',
            'path' => $dir,
        ]);
    }
}
/**
 * Verifies all installed components that is no more listed inside Json.
 *
 * @param array $components
 *
 * @return void
 */
function delRemovedComponents(array $components): void
{
    $installed = loadLockFile();
    // Verify if any component was removed
    foreach (array_reverse($installed) as $name => $files) {
        if (isset($components[$name])) {
            continue;
        }
        echo '  - Deleting ', CS_GREEN, $name, CS_RESET, ' files', LF;
        foreach (array_reverse($files) as $file) {
            delDir($file);
            delFile($file);
        }
    }
}
/**
 * Terminates the program with an error message.
 *
 * @param string $error
 *
 * @return void
 *
 * @SuppressWarnings(PHPMD.ExitExpression)
 */
function fatalError(string $error): void
{
    echo CS_RED, $error, CS_RESET, LF;
    exit(1);
}
/**
 * Gets the list of files from Bower Json.
 *
 * @param string $path
 *
 * @return array
 */
function getBowerMain(string $path): array
{
    if (!file_exists($path . DS . BOWER_FILE)) {
        return ['*'];
    }
    $bower = loadJson($path . DS . BOWER_FILE);
    if (!isset($bower['main'])) {
        echo TAB, CS_RED, 'Main section does not exists in "' . $path . DS . BOWER_FILE . '" file.', CS_RESET, LF;
        return [];
    }
    return is_array($bower['main']) ? $bower['main'] : [$bower['main']];
}
/**
 * Gets the list of files of the component.
 *
 * @param array  $data
 * @param string $path
 *
 * @return array
 */
function getComponentFiles(array $data, string $path): array
{
    if (isset($data['files'])) {
        return is_array($data['files']) ? $data['files'] : [$data['files']];
    }
    return getBowerMain($path);
}
/**
 * Returns an array with directory content without . and .. special dirs.
 *
 * @param string $path
 *
 * @return array
 */
function getDir(string $path): array
{
    return array_filter(
        scandir($path),
        fn ($file) => $file !== '.' && $file !== '..'
    );
}
/**
 * Gets the destination folder for the component.
 *
 * Creates the folder if does not exists.
 *
 * @param string $component
 * @param array  $data
 *
 * @return string
 */
function getDestinantion(string $component, array $data)
{
    if (!is_string($data['target'])) {
        fatalError(TAB . 'No destination defined for "' . $component . '" component.');
    }
    $destination = __DIR__ . DS . implode(DS, explode('/', $data['target']));
    if (!is_dir($destination) && !mkdir($destination, 0775, true)) {
        fatalError(TAB . 'Can\'t create "' . $destination . '" directory.');
    }
    return $destination;
}
/**
 * Grants the existance of the directory.
 *
 * @param string $dir
 *
 * @return void
 */
function grantDestination(string $dir): void
{
    if (is_dir($dir)) {
        return;
    } elseif (!mkdir($dir, 0775, true)) {
        echo TAB, CS_RED, 'Can\'t create "', $dir, '" directory.', CS_RESET, LF;
    }
}
/**
 * Loads the components.json file and returns an array with components list.
 *
 * @return array
 */
function loadComponentsJson(): array
{
    $jsonpath = __DIR__ . DS . 'components.json';
    if (!file_exists($jsonpath)) {
        return [];
    }
    $json = loadJson($jsonpath);
    if (!is_array($json['components'] ?? null)) {
        fatalError('Syntax error in components.json');
    }
    $components = [];
    foreach ($json['components'] as $name => $data) {
        $components[$name] = $data;
    }
    return $components;
}
/**
 * Parses the Json file.
 *
 * @param string $json
 *
 * @return array
 */
function loadJson(string $filepath): array
{
    $jsonstr = file_get_contents($filepath);
    if (!$jsonstr) {
        fatalError('Can\'t open ' . $filepath . ' file.');
    }
    $parsed = json_decode($jsonstr, true);
    $error = json_last_error();
    if ($error === JSON_ERROR_NONE) {
        return $parsed;
    }
    $jsonErrors = [
        JSON_ERROR_DEPTH => 'The maximum stack depth has been exceeded',
        JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
        JSON_ERROR_CTRL_CHAR => 'Control character error, possibly incorrectly encoded',
        JSON_ERROR_SYNTAX => 'Syntax error',
        JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded',
        JSON_ERROR_RECURSION => 'One or more recursive references in the value to be encoded',
        JSON_ERROR_INF_OR_NAN => 'One or more NAN or INF values in the value to be encoded',
        JSON_ERROR_UNSUPPORTED_TYPE => 'A value of a type that cannot be encoded was given',
    ];
    fatalError($jsonErrors[$error] ?? 'Unknown error occurred');
}
/**
 * Loads the components.lock file.
 *
 * @return array
 */
function loadLockFile(): array
{
    if (!file_exists(LOCK_FILE)) {
        return [];
    }
    $lock = file_get_contents(LOCK_FILE);
    if (!$lock) {
        fatalError('Can\'t open ' . LOCK_FILE . ' file.');
    }
    return unserialize($lock);
}
/**
 * Minify a file using Mattias Mullie's component.
 *
 * @param string $buffer
 * @param string $ext
 *
 * @return string
 */
function matthiasMullie(string $buffer, string $ext): string
{
    if ($ext !== 'css' && $ext !== 'js') {
        echo TAB, CS_RED, '[WARNING] Invalid minify method: ', $ext, CS_RESET, LF;
        return $buffer;
    }
    $minifier = 'css'
        ? new MatthiasMullie\Minify\CSS($buffer)
        : new MatthiasMullie\Minify\JS($buffer);
    return $minifier->minify();
}
/**
 * Minify the file if turned on.
 *
 * @param string $buffer
 * @param string $minify
 *
 * @return string
 */
function minifyFile(string $buffer, string $minify): string
{
    if ($minify == 'off') {
        return $buffer;
    }
    if (class_exists('MatthiasMullie\Minify\Minify')) {
        return matthiasMullie($buffer, $minify);
    }
    // Matthias Mullie's Minify class not found. I Will try by myself but this is not the best way.
    switch ($minify) {
        case 'css':
            $buffer = preg_replace('!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $buffer);
            $buffer = str_replace(["\r\n", "\r", "\n", "\t", '  ', TAB, '    '], '', $buffer);
            $buffer = preg_replace(['(( )+{)', '({( )+)'], '{', $buffer);
            $buffer = preg_replace(['(( )+})', '(}( )+)', '(;( )*})'], '}', $buffer);
            $buffer = preg_replace(['(;( )+)', '(( )+;)'], ';', $buffer);
            break;
        case 'js':
            $buffer = preg_replace("/((?:\/\*(?:[^*]|(?:\*+[^*\/]))*\*+\/)|(?:\/\/.*))/", '', $buffer);
            $buffer = str_replace(["\r\n", "\r", "\t", "\n", '  ', TAB, '    '], '', $buffer);
            $buffer = preg_replace(['(( )+\))', '(\)( )+)'], ')', $buffer);
            break;
        default:
            echo TAB, CS_RED, '[WARNING] Invalid minify method: ', $minify, CS_RESET, LF;
    }
    return $buffer;
}
/**
 * Copy file minifyint if necessary.
 *
 * @param string $source
 * @param string $destiny
 * @param string $minify
 *
 * @return bool
 */
function realCopy(string $source, string $destiny, string $minify = 'auto')
{
    if ($minify == 'auto' || $minify == 'on') {
        $minify = (substr($source, -4) == '.css' ? 'css' : (substr($source, -3) == '.js' ? 'js' : 'off'));
    }
    $buffer = file_get_contents($source);
    if ($buffer == false) {
        echo TAB, CS_RED, '[ERROR] Failed to open ', $source, CS_RESET, LF;
        return false;
    }
    $buffer = minifyFile($buffer, $minify);
    $return = file_put_contents($destiny, minifyFile($buffer, $minify));
    if ($return !== false) {
        chmod($destiny, 0664);
    }
    return $return;
}
/**
 * Copy files or directory recursively.
 *
 * @param string $path
 * @param string $dest
 * @param string $minify
 * @param string $component
 *
 * @return array
 */
function recursiveCopy(string $path, string $dest, string $minify): array
{
    $installed = [];
    if (is_dir($path)) {
        // The source is a directory
        return array_merge(
            $installed,
            copyDir($path, $dest, $minify)
        );
    } elseif (is_file($path)) {
        copyFile($path, $dest, $minify);
        $installed[] = [
            'path' => $dest,
            'type' => 'f',
        ];
        return $installed;
    }
    /*
     * Oh! Is a wildcard path.
     */
    $dest = dirname($dest);
    foreach (glob($path) as $filename) {
        $installed = array_merge(
            $installed,
            recursiveCopy($filename, $dest . DS . basename($filename), $minify)
        );
    }
    return $installed;
}
/**
 * Adds Composer autoload if exists.
 *
 * @return void
 */
function requireComposerAutoload(): void
{
    $composer = loadJson(__DIR__ . DS . 'composer.json');
    $vendor = isset($composer['config']) && isset($composer['config']['vendor-dir'])
        ? $composer['config']['vendor-dir']
        : 'vendor';
    if (file_exists($vendor . DS . 'autoload.php')) {
        require $vendor . DS . 'autoload.php';
    }
}
/** @var array components list */
$components = loadComponentsJson();
/** @var array installed components list */
$installed = [];
echo CS_GREEN, 'Starting the installation of the extra components', CS_RESET, LF;
requireComposerAutoload();
delRemovedComponents($components);
// Process every component
foreach ($components as $name => $data) {
    echo '  - Processing ', CS_GREEN, $name, CS_RESET, ' files', LF;
    $installed[$name] = [];
    $path = checkComponentPath($data);
    if (!$path) {
        continue;
    }
    // Component properties
    $files = getComponentFiles($data, $path);
    $noSubdirs = $data['ignore-subdirs'] ?? false;
    $minify = $data['minify'] ?? 'off';
    $destination = getDestinantion($name, $data);
    $installed[$name][] = [
        'path' => $destination,
        'type' => 'd',
    ];
    foreach ($files as $file) {
        $file = implode(DS, explode('/', $file));
        $dstFile = $file;
        if ($noSubdirs) {
            $dstFile = explode('/', $file);
            $dstFile = array_pop($dstFile);
        }
        $installed[$name] = array_merge(
            $installed[$name],
            recursiveCopy($path . DS . $file, $destination . DS . $dstFile, $minify)
        );
    }
}
// Write the lock file
echo CS_GREEN, 'Writing lock file', CS_RESET, LF;
if (!file_put_contents(LOCK_FILE, serialize($installed))) {
    fatalError('Can\'t write ' . LOCK_FILE . ' file.');
}
 |