_page = $page; parent::__construct($page); } // Base path of the entire application private function _getBasePath() { return Prado::getPathOfNamespace('Web'); } // Translate URLs to filesystem paths, index return array by URLs private function _getBasePaths(array $urls) { $basePath = $this->_getBasePath(); return array_combine( $urls, array_map( function(string $path) use($basePath) { return $basePath . DIRECTORY_SEPARATOR . $path; }, $urls ) ); } // Base cache path, suffixed with subdirectory, create on demand private function _getCachePath(string $subdir = 'assets') { $cachePath = $this->Application->RuntimePath . DIRECTORY_SEPARATOR . $subdir; if (!is_dir($cachePath)) { if (file_exists($cachePath)) { throw new TIOException( sprintf( 'Client script manager cache path "%s" exists and is not a directory', $cachePath ) ); } else { mkdir($cachePath); } } return $cachePath; } // Cache path for a file of specified type private function _getCacheFilePath(string $path, string $type) { return $this->_getCachePath($type) . DIRECTORY_SEPARATOR . $path; } // Cache key for specific file set, including current theme private function _getFileCollectionCacheKey(array $files) { sort($files); if ($this->_page->Theme) { $files[] = $this->_page->Theme->Name; } return md5(implode(PHP_EOL, $files)); } // Last modification time of a file set private function _getFileCollectionMTime(array $files) { return max(array_map('filemtime', $files)); } // Storage (application cache) key for list of rendered assets of specified type // Rendered[ASSET_TYPE].[VIEW_ID] (VIEW_ID as rendered in Layout hidden field) private function _getRenderedAssetsStoreKey(string $type) { $template = $this->_page->Master; if (!$template instanceof Layout) { throw new TNotSupportedException( 'Compiled assets may only be used within Layout master class controls' ); } return sprintf('Rendered%s.%s', $type, $template->generateViewID()); } // Shorthand for JS assets cache key private function _getRenderedScriptsStoreKey() { return $this->_getRenderedAssetsStoreKey('Scripts'); } // Shorthand for CSS assets cache key private function _getRenderedSheetsStoreKey() { return $this->_getRenderedAssetsStoreKey('Sheets'); } // Application (primary) cache module, required to keep track of assets rendered on current page private function _getCache() { $cache = $this->Application->Cache; if (!$cache) { throw new TNotSupportedException( 'Compiled assets require cache to be configured' ); } return $cache; } // Check cache file validity, comparing to source file set private function _isCacheValid(string $cacheFile, array $paths) { return file_exists($cacheFile) && (filemtime($cacheFile) >= $this->_getFileCollectionMTime($paths)); } // Determine whether specific URL points to a local asset (i.e. existing on the filesystem) private function _isFileLocal(string $file) { $basePath = $this->_getBasePath(); return file_exists($basePath . DIRECTORY_SEPARATOR . $file); } // Filter URL set to leave only local assets private function _determineLocalFiles(array $files) { return array_filter( $files, [$this, '_isFileLocal'] ); } // Scripts - internal methods private $_renderedScriptsInitialized = FALSE; // Retrieve scripts already rendered on current page from application cache, // maintaining the state over callbacks private function _getRenderedScripts() { $sessionKey = $this->_getRenderedScriptsStoreKey(); if ($this->_page->IsCallBack || $this->_renderedScriptsInitialized) { return $this->_getCache()->get($sessionKey) ?: []; } else { $this->_getCache()->delete($sessionKey); $this->_renderedScriptsInitialized = TRUE; return []; } } // Store information on rendered scripts in application cache private function _appendRenderedScripts(array $newScripts, string $compiledFileKey) { $scripts = $this->_getRenderedScripts(); if (!isset($scripts[$compiledFileKey])) { $scripts[$compiledFileKey] = []; } $scripts[$compiledFileKey] = array_unique( array_merge( $scripts[$compiledFileKey], $newScripts ) ); $this->_getCache()->set( $this->_getRenderedScriptsStoreKey(), $scripts ); } // Compress JS file and return its content private function _getCompressedScript(string $path) { return trim(TJavaScript::JSMin( file_get_contents($path) )); } // Join multiple script files into single asset, mark all of them as rendered in parent private function _compileScriptFiles(array $files) { foreach ($files as $file) { $this->markScriptFileAsRendered($file); } $paths = $this->_getBasePaths($files); $cacheFile = $this->_getCacheFilePath( $this->_getFileCollectionCacheKey($paths) . '.js', 'scripts' ); $this->_appendRenderedScripts($files, $cacheFile); if (!$this->_isCacheValid($cacheFile, $paths)) { $scriptContent = implode( PHP_EOL, array_map( [$this, '_getCompressedScript'], $paths ) ); file_put_contents($cacheFile, $scriptContent); } return $this->Application->AssetManager->publishFilePath($cacheFile); } // Write output tag for a single, compiled JS asset, consisting of multiple local assets private function _renderLocalScriptFiles(THtmlWriter $writer, array $localFiles) { if ($localFiles) { $assetPath = $this->_compileScriptFiles($localFiles); $this->_setHTTP2PushHeader($assetPath); $writer->write(TJavascript::renderScriptFile($assetPath)); } } // Keep track of external JS scripts rendered on the page private function _renderExternalScriptFiles(THtmlWriter $writer, array $externalFiles) { if ($externalFiles) { foreach ($externalFiles as $file) { $this->markScriptFileAsRendered($file); $this->_appendRenderedScripts([$file], $file); } parent::renderScriptFiles($writer, $externalFiles); } } // Determine actual asset URL that a source JS file was rendered as (after compilation/compression) // FALSE means script wasn't rendered at all (i.e. was just registered in current callback) private function _getRenderedScriptUrl(string $registeredScript) { $renderedScripts = $this->_getRenderedScripts(); foreach ($renderedScripts as $compiledFile => $scripts) { if (in_array($registeredScript, $scripts)) { if (file_exists($compiledFile)) { return $this->Application->AssetManager->getPublishedUrl( $compiledFile ); } else { return $registeredScript; } } } return FALSE; } // Scripts - public interface overrides // In application modes "higher" than Debug, compile JS assets to as few files as possible public function renderScriptFiles($writer, array $files) { if ($this->getApplication()->getMode() !== TApplicationMode::Debug) { if ($files) { $localFiles = $this->_determineLocalFiles($files); $this->_renderLocalScriptFiles($writer, $localFiles); $externalFiles = array_diff($files, $localFiles); $this->_renderExternalScriptFiles($writer, $externalFiles); } } else { foreach ($files as $file) { $this->_setHTTP2PushHeader($file); } parent::renderScriptFiles($writer, $files); } } // When above compilation occurs, list of JS URLs a callback requires // significantly deviates from parent implementation. // Also, new scripts that have been registered in current callback may need compiling, too. public function getScriptUrls() { if ($this->getApplication()->getMode() !== TApplicationMode::Debug) { $registeredScripts = array_unique( array_merge( $this->_scripts, $this->_headScripts ) ); $scriptUrls = []; $newScripts = []; foreach ($registeredScripts as $registeredScript) { $renderedScriptUrl = $this->_getRenderedScriptUrl($registeredScript); if ($renderedScriptUrl) { $scriptUrls[] = $renderedScriptUrl; } else { $newScripts[] = $registeredScript; } } $newLocalScripts = $this->_determineLocalFiles($newScripts); $newRemoteScripts = array_diff($newScripts, $newLocalScripts); if ($newLocalScripts) { $scriptUrls[] = $this->_compileScriptFiles($newLocalScripts); } $scriptUrls = array_values( array_unique(array_merge($scriptUrls, $newRemoteScripts)) ); return $scriptUrls; } return parent::getScriptUrls(); } private $_scripts = []; private $_headScripts = []; // Keep track of what we're registering public function registerScriptFile($key, $file) { $this->_scripts[$key] = $file; return parent::registerScriptFile($key, $file); } // Keep track of what we're registering public function registerHeadScriptFile($key, $file) { $this->_headScripts[$key] = $file; return parent::registerHeadScriptFile($key, $file); } // Stylesheets - internal methods private $_renderedSheetsInitialized = FALSE; // Retrieve stylesheets already rendered on current page from application cache, // maintaining the state over callbacks private function _getRenderedSheets() { $sessionKey = $this->_getRenderedSheetsStoreKey(); if ($this->_page->IsCallBack || $this->_renderedSheetsInitialized) { return $this->_getCache()->get($sessionKey) ?: []; } else { $this->_getCache()->delete($sessionKey); $this->_renderedSheetsInitialized = TRUE; return []; } } // Store information on rendered stylesheets in application cache private function _appendRenderedSheets(array $newSheets, string $compiledFileKey) { $sheets = $this->_getRenderedSheets(); if (!isset($sheets[$compiledFileKey])) { $sheets[$compiledFileKey] = []; } $sheets[$compiledFileKey] = array_merge( $sheets[$compiledFileKey], $newSheets ); $this->_getCache()->set( $this->_getRenderedSheetsStoreKey(), $sheets ); } // Resolve all "url(FILE)" CSS directives pointing // to relative resources to specified path private function _fixStyleSheetPaths(string $content, string $originalUrl) { $originalDir = dirname($originalUrl . '.'); return preg_replace_callback( '/url\s*\([\'"]?(.*?)[\'"]?\)/', function($matches) use($originalDir) { $url = parse_url($matches[1]); // ignore absolute URLs and paths if (isset($url['scheme']) || isset($url['host']) || $url['path'][0] == '/') { return $matches[0]; } // resolve relative paths return str_replace( $matches[1], $originalDir . '/' . $matches[1], $matches[0] ); }, $content ); } // Compress CSS file and return its content private function _getCompressedSheet(string $origPath, string $path) { Prado::using('Lib.cssmin.CssMin'); return trim(CssMin::minify( $this->_fixStyleSheetPaths( file_get_contents($path), $origPath ) )); } // Join multiple stylesheet files into single asset private function _compileSheetFiles(array $files) { $paths = $this->_getBasePaths( array_map('reset', $files) ); // determine if file was registered as a themed sheet $correctedPaths = []; foreach ($paths as $url => $path) { $correctedPaths[ in_array($url, $this->_themeStyles) && $this->_page->Theme ? $this->_page->Theme->BaseUrl . DIRECTORY_SEPARATOR : $url ] = $path; } $cacheFile = $this->_getCacheFilePath( $this->_getFileCollectionCacheKey($paths) . '.css', 'styles' ); $this->_appendRenderedSheets($files, $cacheFile); if (!$this->_isCacheValid($cacheFile, $paths)) { $styleContent = implode( PHP_EOL, array_map( [$this, '_getCompressedSheet'], array_keys($correctedPaths), $correctedPaths ) ); file_put_contents($cacheFile, $styleContent); } return $this->Application->AssetManager->publishFilePath($cacheFile); } // Filter only local stylesheet file entries private function _determineLocalSheetFiles(array $files) { $basePath = $this->_getBasePath(); return array_filter( $files, function($file) use($basePath) { return file_exists($basePath . DIRECTORY_SEPARATOR . $file[0]); } ); } // Write HTML markup for CSS stylesheet private function _renderSheetFileTag(THtmlWriter $writer, string $href, string $media) { $writer->addAttribute('rel', 'stylesheet'); $writer->addAttribute('type', 'text/css'); $writer->addAttribute('media', $media); $writer->addAttribute('href', $href); $writer->renderBeginTag('link'); $writer->write(PHP_EOL); } // Group registered local CSS assets by media query string and render markup for compiled sheets private function _renderLocalSheetFiles(THtmlWriter $writer, array $localFiles) { if ($localFiles) { $fileTypes = []; foreach ($localFiles as $file) { $type = $file[1] ?: 'all'; if (!isset($fileTypes[$type])) { $fileTypes[$type] = []; } $fileTypes[$type][] = $file; } foreach ($fileTypes as $type => $files) { $assetPath = $this->_compileSheetFiles($files); $this->_setHTTP2PushHeader($assetPath); $this->_renderSheetFileTag($writer, $assetPath, $type); } } } // Render markup for external stylesheets private function _renderExternalSheetFiles(THtmlWriter $writer, array $externalFiles) { if ($externalFiles) { foreach ($externalFiles as $file) { $this->_appendRenderedSheets([$file], $file[0]); $this->_renderSheetFileTag($writer, $file[0], $file[1] ?: 'all'); } } } // Determine actual asset URL that a source CSS file was rendered as (after compilation/compression) // FALSE means sheet wasn't rendered at all (i.e. was just registered in current callback) // Media query types can easily be ignored in a callback request, only URLs matter private function _getRenderedSheetUrl(string $registeredSheet) { $renderedSheets = $this->_getRenderedSheets(); foreach ($renderedSheets as $compiledFile => $sheets) { foreach ($sheets as $sheet) { if ($registeredSheet[0] == $sheet[0]) { if (file_exists($compiledFile)) { return $this->Application->AssetManager->getPublishedUrl( $compiledFile ); } else { return $registeredSheet[0]; } } } } return FALSE; } // Stylesheets - public interface overrides // In application modes "higher" than Debug, compile CSS assets to as few files as possible public function renderStyleSheetFiles($writer) { if ($this->getApplication()->getMode() !== TApplicationMode::Debug) { $files = $this->_styles; if ($files) { $localFiles = $this->_determineLocalSheetFiles($files); $this->_renderLocalSheetFiles($writer, $localFiles); $externalFiles = array_diff_key($files, $localFiles); $this->_renderExternalSheetFiles($writer, $externalFiles); } } else { foreach ($this->getStyleSheetUrls() as $file) { $this->_setHTTP2PushHeader($file); } parent::renderStyleSheetFiles($writer); } } // When above compilation occurs, list of CSS URLs a callback requires // significantly deviates from parent implementation. // New stylesheets may need compiling, as well. public function getStyleSheetUrls() { if ($this->getApplication()->getMode() !== TApplicationMode::Debug) { $registeredSheets = $this->_styles; $sheetUrls = []; $newSheets = []; foreach ($registeredSheets as $registeredSheet) { $renderedSheetUrl = $this->_getRenderedSheetUrl( $registeredSheet ); if ($renderedSheetUrl) { $sheetUrls[] = $renderedSheetUrl; } else { $newSheets[] = $registeredSheet; } } $newLocalSheets = $this->_determineLocalSheetFiles($newSheets); $newLocalUrls = array_map('reset', $newLocalSheets); $newRemoteSheets = array_filter( $newSheets, function($sheet) use($newLocalUrls) { return !in_array($sheet[0], $newLocalUrls); } ); $newRemoteUrls = array_map('reset', $newRemoteSheets); if ($newLocalSheets) { $sheetUrls[] = $this->_compileSheetFiles($newLocalSheets); } $sheetUrls = array_values( array_unique(array_merge($sheetUrls, $newRemoteUrls)) ); return $sheetUrls; } // And even in Debug mode, theme sheets before fixing paths // might also have been published via assets manager, // so we have to discard these from parent list. return array_diff( parent::getStyleSheetUrls(), $this->_fixedStyleFiles ); } private $_styles = []; // Keep track of what we're registering public function registerStyleSheetFile($key, $file, $media = '') { $this->_styles[$key] = [$file, $media]; return parent::registerStyleSheetFile($key, $file, $media ?: 'all'); } private $_themeStyles = []; private $_fixedStyleFiles = []; // New method, automatically corrects URLs within stylesheet to current page theme // when sheets are not compiled (when they are, it's done on compilation anyways). // Such files are rewritten (not in place, though) and registered so that they don't show up // within published assets returned from asset manager, as they don't end up in the markup. public function registerThemeStyleSheetFile(string $key, string $file, string $media = '') { if ($this->getApplication()->getMode() !== TApplicationMode::Debug) { $this->_themeStyles[$key] = $file; } else { if ($this->_isFileLocal($file) && $this->_page->Theme) { $tempFile = $this->_getCacheFilePath( $this->_getFileCollectionCacheKey([ $this->_getBasePath() . DIRECTORY_SEPARATOR . $file ]) . '.' . basename($file), $this->_page->Theme->Name ); file_put_contents( $tempFile, $this->_fixStyleSheetPaths( file_get_contents( $this->_getBasePath() . DIRECTORY_SEPARATOR . $file ), $this->_page->Theme->BaseUrl . DIRECTORY_SEPARATOR ) ); $this->_fixedStyleFiles[] = $file; $file = $this->Application->AssetManager->publishFilePath($tempFile); } } return $this->registerStyleSheetFile($key, $file, $media ?: 'all'); } private $_pushHeaders = []; // Set Link: header for HTTP2 Push private function _setHTTP2PushHeader($file) { if (!in_array($file, $this->_pushHeaders)) { $this->_pushHeaders[] = $file; $this->getResponse()->appendHeader('Link: "<' . $file . '>; rel=preload"', FALSE); } } } ?>