Resources.php

<?php

namespace Lia\Compo;


/**
 * A component for managing multiple css & js files for a single request.
 *
 *
 * @todo Use config for default site title
 * @todo move SEO code into it's own component
 * @todo Auto-add charset=utf8 & viewport meta tags (with to disable)
 * @todo Add url prefix for compiled files as a config option
 * @todo allow specifying sort priority when ADDING a file
 * @todo better routing
 * @todo Separate large files from the block of compiled code. 
 * @todo minify js & css
 * @todo Create resources object that handles concatenation, so this class is merely an integration of said resource object
 */
class Resources extends \Lia\Compo {

    // files, urls, codes, and sorter all have 'css' and 'js' indices expected.
    protected $files = [];
    protected $urls = [];
    protected $codes = [];
    protected $sorter = [];

    public function onReady(){
        $lia = $this->lia;
    
        $lia->addApi('lia:resource.sort',[$this,'setResourceSorter']);
        $lia->addApiMethod('lia:resource.sort', 'setResourceSorter');
        $lia->addApi('lia:resource.addfile',[$this,'addResourceFile']);
        $lia->addApiMethod('lia:resource.addfile', 'addResourceFile');
        $lia->addApi('lia:resource.addurl',[$this,'addResourceUrl']);
        $lia->addApiMethod('lia:resource.addurl', 'addResourceUrl');
        $lia->addApi('lia:resources.gethtml',[$this,'getHeadHtml']);
        $lia->addApiMethod('lia:resources.gethtml', 'getHeadHtml');
    }

    public function onPackageReady(){
        parent::onPackageReady();
        if ($this->lia->hasApi('lia:config.default')){
            $this->lia->api('lia:config.default', 'lia:resource.recompileJsAfter', 60 * 60 * 24 * 30);
            $this->lia->api('lia:config.default', 'lia:resource.recompileCssAfter', 60 * 60 * 24 * 30);
            $this->lia->api('lia:config.default', 'lia:resource.useCache', true);
            $this->lia->api('lia:config.default', 'lia:resource.forceRecompile', false);
        }
    }

    /**
     * Returns routes for compiled resource files & later processes said routes
     */
    public function routePatternToRes($route,$response=null){
        if ($route===false){
            $jsMapFile = $this->lia->api('lia:cache.get', $this->cacheMapName('js'));
            $jsMap = $jsMapFile ? json_decode(file_get_contents($jsMapFile),true) : [];
            $cssMapFile = $this->lia->api('lia:cache.get', $this->cacheMapName('css'));
            $cssMap = $cssMapFile ? json_decode(file_get_contents($cssMapFile),true) : [];
            $map = array_merge($jsMap, $cssMap);
            $map = array_map(function($v){return '/'.$v;}, $map);

            return array_values($map);
        }
        $useCache = $this->lia->api('lia:config.get', 'lia:resource.useCache');
        $response->useTheme = false;

        $shortName = substr($route->url(),1);
        $ext = pathinfo($shortName,PATHINFO_EXTENSION);
        $this->cleanExt($ext);
        $cacheFileName = $this->cacheMapName($ext);
        $fPath = $this->lia->api('lia:cache.get',$shortName);
        $staticFile = null;
        if (is_file($fPath)){
            $staticFile = new \Lia\Utility\StaticFile($fPath);
        } else {
            $staticFile = new \Lia\Utility\StaticFile($shortName);
        }
        $response->addHeaders($staticFile->getContentTypeHeaders());

        if ($useCache){
            $response->addHeaders($staticFile->getCacheHeaders());

            if ($staticFile->userHasFileCached){
                $response->sendContent = false;
            } else {
                $c = file_get_contents($fPath);
                $response->content = $c;
            } 
            
        } else {
            $cacheFileName = $this->cacheMapName($ext);
            $cacheFPath = $this->lia->api('lia:cache.get',$cacheFileName);
            if ($cacheFPath == false||!is_file($cacheFPath)){
                //@TODO provide a proper error, both header() & comment
                $response->content = '/* file not found */';
                return;
            }
            $json = file_get_contents($fPath);
            $fileMap = json_decode($json,true);
            foreach ($fileMap as $longName => $storedShortName){
                if ($shortName!=$storedShortName)continue;
                $files = explode('--',$longName);
                foreach ($files as $file){
                    if (!file_exists($file)){
                        $respone->content .= "\n/* there is a missing file */\n";
                        continue;
                    }
                    $response->content .= "\n".file_get_contents($file)."\n";
                }
            } 
        }
    }
 
// APIs
    public function setSorter($ext, $sorter){
        // @TODO add debugging here, so one can trace the sorce of a previously set style sorter
        $this->cleanExt($ext);
        if (($this->sorter[$ext]??null)!==null){
            throw new \Liaison\Exception\Base("A '{$ext}' sorter has already been set.");
        }
        //@TODO maybe check if $sorter is callable
        $this->sorter[$ext] = $sorter;
    }

    //@TODO maybe remove public setSorter()...
    public function setResourceSorter($ext, $sorter) {
        return $this->setSorter($ext,$sorter);
    }
    public function addResourceFile($file){
        if (!is_file($file)){
            throw new \Lia\Exception\Base("Path '{$file}' is not a file");
        }
        $ext = pathinfo($file,PATHINFO_EXTENSION);
        $this->cleanExt($ext);
        $file = realpath($file);
        $this->files[$ext][$file] = $file;
    }
    public function addResourceUrl($url){
        $path = parse_url($url,PHP_URL_PATH);
        $ext = pathinfo($path,PATHINFO_EXTENSION);
        $this->cleanExt($ext);
        $this->urls[$ext][] = $url;
    }

    public function getHeadHtml(){
        $html = "\n"
            .'    '.$this->getFileTag('js')."\n"
            .'    '.$this->getFileTag('css')."\n"
            .'    '.$this->getUrlTag('js')."\n"
            .'    '.$this->getUrlTag('css')."\n"
            // .'    '.$this->getScriptCodeTag()."\n"
            // .'    '.$this->getStyleCodeTag()."\n"
            .'    '.str_replace("\n","\n    ",$this->lia->api('lia:seo.gethtml'))."\n";
        $html = "\n    ".trim($html)."\n";
        return $html;
    }



// Utility functions //why are some of these public??
    public function getFileTag($ext){
        $this->cleanExt($ext);
        $url = $this->getCompiledFilesUrl($ext);
        if ($url==false)return '';
        switch ($ext){
            case 'css':
                $html = '<link rel="stylesheet" href="'.$url.'" />';
                break;
            case 'js':
                $html = '<script type="text/javascript" src="'.$url.'"></script>';
                break;
            default:
                $html = '';
        }
        return $html;
    }
    public function getUrlTag($ext){
        $this->cleanExt($ext);
        $html = [];
        foreach (($this->urls[$ext]??[]) as $url){
            switch ($ext){
                case 'css':
                    $html[] = '<link rel="stylesheet" href="'.$url.'" />';
                    break;
                case 'js':
                    $html[] = '<script type="text/javascript" src="'.$url.'"></script>';
                    break;
                default:
                    // $html[] = '';
            }
        }
        $output =  "\n    ".implode("\n    ",$html)."\n";
        return $output;
    }

    protected function cleanExt(&$ext){
        $ext = strtolower($ext);
        if ($ext!='css'&&$ext!='js')throw new \Lia\Exception\Base("Only js and css are allowed at this time, but '{$ext}' was given.");
    }
    protected function cacheMapName($ext){
        return "lia.resource.{$ext}FileMap";
    }
    
// putting together the resource files
    public function getSortedFiles($ext){
        // @TODO cache the sorted scripts list
        $this->cleanExt($ext);
        $files = $this->files[$ext] ?? [];
        $sorter = $this->sorter[$ext] ?? null;
        if ($sorter!==null){
            $files = $sorter($files);
        }
        return $files;
    }
    public function concatenateFiles($ext){
        $this->cleanExt($ext);
        $files = $this->getSortedFiles($ext);
        $separator = "\n";

        $content = [];
        foreach ($files as $file){
            $content[] = file_get_contents($file);
        }
        
        return implode($separator, $content);
    }
    public function concatenateFileNames($ext){
        $this->cleanExt($ext);
        $files = $this->getSortedFiles($ext);
        $longName = implode('--',$files);
        return $longName;
    }
    
    public function compileFilesToCache($ext){
        $this->cleanExt($ext);
        $lia = $this->lia;
        
        $longName = $this->concatenateFileNames($ext);
        if ($longName=='')return false;
        $cachedMapName = $this->cacheMapName($ext);
        $files = $lia->getCacheFile($cachedMapName);
        $files = $lia->api('lia:cache.get',$cachedMapName);
        $files = $files==false ? [] : json_decode(file_get_contents($files), true);
        $shortName = $files[$longName] ?? 'lia-resource.'.uniqid().'.'.$ext;
        $cachedFile = $lia->api('lia:cache.get', $shortName);
        if ($cachedFile&&!$lia->api('lia:config.get', "lia:resource.forceRecompile"))return $cachedFile;
        $content = $this->concatenateFiles($ext);
        $path = $lia->api('lia:cache.set',$shortName, $content, 60*60*24*30);
        $files[$longName] = $shortName;
        $lia->api('lia:cache.set',$cachedMapName, json_encode($files), 60*60*24*30);
        return $path;
    }

    public function getCompiledFilesUrl($ext){
        $this->cleanExt($ext);
        $file = $this->compileFilesToCache($ext);
        if ($file==false)return false;
        $longName = $this->concatenateFileNames($ext);
        $files = $this->lia->api('lia:cache.get',$this->cacheMapName($ext));
        $files = $files==false ? [] : json_decode(file_get_contents($files), true);
        $shortName = $files[$longName];

        return '/'.$shortName;
    }
}