diff --git a/common/framework/Template.php b/common/framework/Template.php index a37dc0e44..bc16be971 100644 --- a/common/framework/Template.php +++ b/common/framework/Template.php @@ -25,7 +25,8 @@ class Template public $relative_path; public $cache_path; public $cache_enabled = true; - public $ob_level = 0; + public $source_type; + public $ob_level; public $vars; protected $_fragments = []; protected static $_loopvars = []; @@ -146,6 +147,7 @@ class Template $this->config->version = 2; $this->config->autoescape = true; } + $this->source_type = preg_match('!^((?:m\.)?[a-z]+)/!', $this->relative_dirname, $match) ? $match[1] : null; $this->_setCachePath(); } @@ -409,10 +411,183 @@ class Template } } + /** + * Check if a path should be treated as relative to the path of the current template. + * + * @param string $path + * @return bool + */ + public function isRelativePath(string $path): bool + { + return !preg_match('#^((?:https?|file|data):|[\/\{<])#i', $path); + } + + /** + * Convert a relative path using the given basepath. + * + * @param string $path + * @param string $basepath + * @return string + */ + public function convertPath(string $path, string $basepath): string + { + // Path relative to the Rhymix installation directory? + if (preg_match('#^\^/?(\w.+)$#s', $path, $match)) + { + $path = \RX_BASEURL . $match[1]; + } + + // Other paths will be relative to the given basepath. + else + { + $path = preg_replace('#/\./#', '/', $basepath . $path); + } + + // Remove extra slashes and parent directory references. + $path = preg_replace('#\\\\#', '/', $path); + $path = preg_replace('#//#', '/', $path); + while (($tmp = preg_replace('#/[^/]+/\.\./#', '/', $path)) !== $path) + { + $path = $tmp; + } + return $path; + } + /** * =================== HELPER FUNCTIONS FOR TEMPLATE v2 =================== */ + /** + * Load a resource from v2 @load directive. + * + * The Blade-style syntax does not have named arguments, so we must rely + * on the position and format of each argument to guess what it is for. + * Fortunately, there are only a handful of valid options for the type, + * media, and index attributes. + * + * @param ...$args + * @return void + */ + protected function _v2_loadResource(...$args): void + { + // Assign the path. + $path = null; + if (count($args)) + { + $path = array_shift($args); + } + if (empty($path)) + { + trigger_error('Resource loading directive used with no path', \E_USER_WARNING); + return; + } + + // Assign the remaining arguments to respective array keys. + $info = []; + while ($value = array_shift($args)) + { + if (preg_match('#^([\'"])(head|body)\1$#', $value, $match)) + { + $info['type'] = $match[2]; + } + elseif (preg_match('#^([\'"])((?:screen|print)[^\'"]*)\1$#', $value, $match)) + { + $info['media'] = $match[2]; + } + elseif (preg_match('#^([\'"])([0-9]+)\1$#', $value, $match)) + { + $info['index'] = $match[2]; + } + elseif (ctype_digit($value)) + { + $info['index'] = $value; + } + else + { + $info['vars'] = $value; + } + } + + // Check whether the path is an internal or external link. + $external = false; + if (preg_match('#^\^#', $path)) + { + $path = './' . ltrim($path, '^/'); + } + elseif ($this->isRelativePath($path)) + { + $path = $this->convertPath($path, './' . $this->relative_dirname); + } + else + { + $external = true; + } + + // Determine the type of resource. + if (!$external && str_starts_with($path, './common/js/plugins/')) + { + $type = 'jsplugin'; + } + elseif (!$external && preg_match('#/lang(\.xml)?$#', $path)) + { + $type = 'lang'; + } + elseif (preg_match('#\.(css|js|scss|less)($|\?|/)#', $path, $match)) + { + $type = $match[1]; + } + elseif (preg_match('#/css\d?\?.+#', $path)) + { + $type = 'css'; + } + else + { + $type = 'unknown'; + } + + // Load the resource. + if ($type === 'jsplugin') + { + if (preg_match('#/common/js/plugins/([^/]+)#', $path, $match)) + { + $plugin_name = $match[1]; + \Context::loadJavascriptPlugin($plugin_name); + } + else + { + trigger_error("Unable to find JS plugin at $path", \E_USER_WARNING); + } + } + elseif ($type === 'lang') + { + $lang_dir = preg_replace('#/lang\.xml$#', '', $path); + \Context::loadLang($lang_dir); + } + elseif ($type === 'js') + { + \Context::loadFile([ + $path, + $info['type'] ?? '', + $external ? $this->source_type : '', + isset($info['index']) ? intval($info['index']) : '', + ]); + } + elseif ($type === 'css' || $type === 'scss' || $type === 'less') + { + \Context::loadFile([ + $path, + $info['media'] ?? '', + $external ? $this->source_type : '', + isset($info['index']) ? intval($info['index']) : '', + $info['vars'] ?? [], + ]); + } + else + { + trigger_error("Unable to determine type of resource at $path", \E_USER_WARNING); + } + } + /** * Initialize v2 loop variable. * diff --git a/common/framework/parsers/template/TemplateParser_v2.php b/common/framework/parsers/template/TemplateParser_v2.php index 2cbf25a1f..441ad113b 100644 --- a/common/framework/parsers/template/TemplateParser_v2.php +++ b/common/framework/parsers/template/TemplateParser_v2.php @@ -24,7 +24,6 @@ class TemplateParser_v2 * Cache template path info here. */ public $template; - public $source_type; /** * Properties for internal bookkeeping. @@ -91,7 +90,6 @@ class TemplateParser_v2 { // Store template info in instance property. $this->template = $template; - $this->source_type = preg_match('!^((?:m\.)?[a-z]+)/!', $template->relative_dirname, $match) ? $match[1] : null; // Preprocessing. $content = $this->_preprocess($content); @@ -105,7 +103,7 @@ class TemplateParser_v2 $content = $this->_convertFragments($content); $content = $this->_convertClassAliases($content); $content = $this->_convertIncludes($content); - $content = $this->_convertAssets($content); + $content = $this->_convertResource($content); $content = $this->_convertLoopDirectives($content); $content = $this->_convertInlineDirectives($content); $content = $this->_convertMiscDirectives($content); @@ -185,13 +183,13 @@ class TemplateParser_v2 if ($match[2] !== 'srcset') { $src = trim($match[3]); - return $match[1] . sprintf('%s="%s"', $match[2], self::_isRelativePath($src) ? self::_convertRelativePath($src, $basepath) : $src); + return $match[1] . sprintf('%s="%s"', $match[2], $this->template->isRelativePath($src) ? $this->template->convertPath($src, $basepath) : $src); } else { $srcset = array_map('trim', explode(',', $match[3])); $result = array_map(function($src) use($basepath) { - return self::_isRelativePath($src) ? self::_convertRelativePath($src, $basepath) : $src; + return $this->template->isRelativePath($src) ? $this->template->convertPath($src, $basepath) : $src; }, array_filter($srcset, function($src) { return !empty($src); })); @@ -461,7 +459,7 @@ class TemplateParser_v2 } /** - * Convert asset loading directives. + * Convert resource loading directives. * * This can be used to load nearly every kind of asset, from scripts * and stylesheets to lang files to Rhymix core Javascript plugins. @@ -479,171 +477,31 @@ class TemplateParser_v2 * @param string $content * @return string */ - protected function _convertAssets(string $content): string + protected function _convertResource(string $content): string { // Convert XE-style load directives. $regexp = '#()#'; $content = preg_replace_callback($regexp, function($match) { $attrs = self::_getTagAttributes($match[1]); - $attrs['src'] = $attrs['src'] ?? ($attrs['target'] ?? null); - if (!$attrs['src']) return $match[0]; - return self::_escapeVars(self::_generateCodeForAsset($attrs)); + return vsprintf('_v2_loadResource(%s, %s, %s, %s); ?>', [ + var_export($attrs['src'] ?? ($attrs['target'] ?? ''), true), + var_export($attrs['type'] ?? ($attrs['media'] ?? ''), true), + var_export($attrs['index'] ?? '', true), + self::_convertVariableScope($attrs['vars'] ?? '') ?: '[]', + ]); }, $content); // Convert Blade-style load directives. $parentheses = self::_getRegexpForParentheses(1); $regexp = '#(?_v2_loadResource(%s); ?>', $args); }, $content); return $content; } - /** - * Subroutine for determining the role of each argument to @load. - * - * The Blade-style syntax does not have named arguments, so we must rely - * on the position and format of each argument to guess what it is for. - * Fortunately, there are only a handful of valid options for the type, - * media, and index attributes. - * - * @param array $args - * @return array - */ - protected function _arrangeArgumentsForAsset(array $args): array - { - // Assign the path. - $info = []; - if (preg_match('#^([\'"])([^\'"]+)\1$#', array_shift($args) ?? '', $match)) - { - $info['src'] = $match[2]; - } - if (!$info['src']) - { - return []; - } - - // Assign the remaining arguments to respective array keys. - while ($value = array_shift($args)) - { - if (preg_match('#^([\'"])(head|body)\1$#', $value, $match)) - { - $info['type'] = $match[2]; - } - elseif (preg_match('#^([\'"])((?:screen|print)[^\'"]*)\1$#', $value, $match)) - { - $info['media'] = $match[2]; - } - elseif (preg_match('#^([\'"])([0-9]+)\1$#', $value, $match)) - { - $info['index'] = $match[2]; - } - elseif (ctype_digit($value)) - { - $info['index'] = $value; - } - else - { - $info['vars'] = $value; - } - } - - return $info; - } - - /** - * Subroutine to generate code for asset loading. - * - * @param array $info - * @return string - */ - protected function _generateCodeForAsset(array $info): string - { - // Determine whether the path is an internal or external link. - $path = $info['src']; - $external = false; - if (preg_match('#^\^#', $path)) - { - $path = './' . ltrim($path, '^/'); - } - elseif (self::_isRelativePath($path)) - { - $path = self::_convertRelativePath($path, './' . $this->template->relative_dirname); - } - else - { - $external = true; - } - - // Determine the type of resource. - if (!$external && str_starts_with($path, './common/js/plugins/')) - { - $restype = 'jsplugin'; - } - elseif (!$external && preg_match('#/lang(\.xml)?$#', $path)) - { - $restype = 'lang'; - } - elseif (preg_match('#\.(css|js|scss|less)($|\?|/)#', $path, $match)) - { - $restype = $match[1]; - } - elseif (preg_match('#/css\d?\?.+#', $path)) - { - $restype = 'css'; - } - else - { - $restype = 'unknown'; - } - - // Generate code for each type of asset. - if ($restype === 'jsplugin') - { - if (preg_match('#/common/js/plugins/([^/]+)#', $path, $match)) - { - $plugin_name = $match[1]; - return sprintf('', var_export($plugin_name, true)); - } - else - { - return sprintf('', var_export($path, true)); - } - } - elseif ($restype === 'lang') - { - $lang_dir = preg_replace('#/lang\.xml$#', '', $path); - return sprintf('', var_export($lang_dir, true)); - } - elseif ($restype === 'js') - { - return vsprintf('', [ - var_export($path, true), - var_export($info['type'] ?? '', true), - var_export($external ? $this->source_type : '', true), - var_export(isset($info['index']) ? intval($info['index']) : '', true), - ]); - } - elseif ($restype === 'unknown') - { - return sprintf('', var_export($path, true)); - } - else - { - return vsprintf('', [ - var_export($path, true), - var_export($info['media'] ?? '', true), - var_export($external ? $this->source_type : '', true), - var_export(isset($info['index']) ? intval($info['index']) : '', true), - empty($info['vars']) ? '[]' : self::_convertVariableScope($info['vars']) - ]); - } - } - /** * Convert loop and condition directives. * @@ -1072,48 +930,6 @@ class TemplateParser_v2 return $content; } - /** - * Check if a path should be treated as relative to the path of the current template. - * - * @param string $path - * @return bool - */ - protected static function _isRelativePath(string $path): bool - { - return !preg_match('#^((?:https?|file|data):|[\/\{<])#i', $path); - } - - /** - * Check if a path should be treated as relative to the path of the current template. - * - * @param string $path - * @param string $basepath - * @return string - */ - protected static function _convertRelativePath(string $path, string $basepath): string - { - // Path relative to the Rhymix installation directory? - if (preg_match('#^\^/?(\w.+)$#s', $path, $match)) - { - $path = \RX_BASEURL . $match[1]; - } - - // Other paths will be relative to the given basepath. - else - { - $path = preg_replace('#/\./#', '/', $basepath . $path); - } - - // Remove extra slashes and parent directory references. - $path = preg_replace('#\\\\#', '/', $path); - $path = preg_replace('#//#', '/', $path); - while (($tmp = preg_replace('#/[^/]+/\.\./#', '/', $path)) !== $path) - { - $path = $tmp; - } - return $path; - } - /** * Convert variable scope. * diff --git a/tests/_data/template/css/style.scss b/tests/_data/template/css/style.scss new file mode 100644 index 000000000..cc216d24c --- /dev/null +++ b/tests/_data/template/css/style.scss @@ -0,0 +1,5 @@ +.foo { + .bar { + width: 100%; + } +} diff --git a/tests/_data/template/v2example.compiled.html b/tests/_data/template/v2example.compiled.html index 8b2168f65..303b7e715 100644 --- a/tests/_data/template/v2example.compiled.html +++ b/tests/_data/template/v2example.compiled.html @@ -2,8 +2,8 @@
setVars($__vars); echo $__tpl->compile(); })("common/tpl", 'refresh.html'); ?>
-
- +
_v2_loadResource('^/common/js/plugins/ckeditor/'); ?>
+_v2_loadResource('css/style.scss', 'print', '', []); ?> foo = 'FOOFOOFOO'; diff --git a/tests/_data/template/v2example.html b/tests/_data/template/v2example.html index 0e414027a..306b90af9 100644 --- a/tests/_data/template/v2example.html +++ b/tests/_data/template/v2example.html @@ -3,7 +3,7 @@ @use('Rhymix\Framework\Push', 'Push')
@include('^/common/tpl/refresh.html')
@load('^/common/js/plugins/ckeditor/')
- + assertStringContainsString($target, $this->_parse($source)); } - public function testAssetLoading() + public function testResourceLoading() { // CSS, SCSS, LESS with media and variables $source = ''; - $target = "foo]); ?>"; + $target = "_v2_loadResource('assets/hello.scss', 'print', '', \$__Context->foo); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = ''; - $target = ""; + $target = "_v2_loadResource('../hello.css', 'screen and (max-width: 800px)', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // JS with type and index $source = ''; - $target = ""; + $target = "_v2_loadResource('assets/hello.js', 'head', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = ''; - $target = ""; + $target = "_v2_loadResource('assets/../otherdir/hello.js', 'body', '20', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // External script $source = ''; - $target = ""; + $target = "_v2_loadResource('//cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.min.js', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // External webfont $source = ''; - $target = ""; + $target = "_v2_loadResource('https://fonts.googleapis.com/css2?family=Roboto&display=swap', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // Path relative to Rhymix installation directory $source = ''; - $target = ""; + $target = "_v2_loadResource('^/common/js/foobar.js', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // JS plugin $source = ''; - $target = ""; + $target = "_v2_loadResource('^/common/js/plugins/ckeditor/', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // Lang file $source = ''; - $target = ""; + $target = "_v2_loadResource('^/modules/member/lang', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = ''; - $target = ""; + $target = "_v2_loadResource('^/modules/legacy_module/lang/lang.xml', '', '', []); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style SCSS with media and variables $source = "@load('assets/hello.scss', 'print', \$vars)"; - $target = "vars]); ?>"; + $target = "_v2_loadResource('assets/hello.scss', 'print', \$__Context->vars); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = "@load ('../hello.css', 'screen')"; - $target = ""; + $target = "_v2_loadResource('../hello.css', 'screen'); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style JS with type and index $source = "@load('assets/hello.js', 'body', 10)"; - $target = ""; + $target = "_v2_loadResource('assets/hello.js', 'body', 10); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = "@load ('assets/hello.js', 'head')"; - $target = ""; + $target = "_v2_loadResource('assets/hello.js', 'head'); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = "@load ('assets/hello.js')"; - $target = ""; + $target = "_v2_loadResource('assets/hello.js'); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style external script $source = "@load ('//cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.min.js')"; - $target = ""; + $target = "_v2_loadResource('//cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.min.js'); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style external webfont $source = "@load('https://fonts.googleapis.com/css2?family=Roboto&display=swap')"; - $target = ""; + $target = "_v2_loadResource('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style path relative to Rhymix installation directory $source = '@load ("^/common/js/foobar.js")'; - $target = ""; + $target = '_v2_loadResource("^/common/js/foobar.js"); ?>'; $this->assertEquals($target, $this->_parse($source)); // Blade-style JS plugin $source = "@load('^/common/js/plugins/ckeditor/')"; - $target = ""; + $target = "_v2_loadResource('^/common/js/plugins/ckeditor/'); ?>"; $this->assertEquals($target, $this->_parse($source)); // Blade-style lang file $source = "@load('^/modules/member/lang')"; - $target = ""; + $target = "_v2_loadResource('^/modules/member/lang'); ?>"; $this->assertEquals($target, $this->_parse($source)); $source = '@load("^/modules/legacy_module/lang/lang.xml")'; - $target = ""; + $target = '_v2_loadResource("^/modules/legacy_module/lang/lang.xml"); ?>'; $this->assertEquals($target, $this->_parse($source)); } @@ -1014,7 +1014,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit // Get compiled code $compiled_output = $tmpl->compileDirect('./tests/_data/template', 'v2example.html'); $tmpvar = preg_match('/\$__tmp_([0-9a-f]{14})/', $compiled_output, $m) ? $m[1] : ''; - Rhymix\Framework\Storage::write(\RX_BASEDIR . 'tests/_data/template/v2example.compiled.html', $compiled_output); + //Rhymix\Framework\Storage::write(\RX_BASEDIR . 'tests/_data/template/v2example.compiled.html', $compiled_output); $expected = file_get_contents(\RX_BASEDIR . 'tests/_data/template/v2example.compiled.html'); $expected = preg_replace('/RANDOM_LOOP_ID/', $tmpvar, $expected); $this->assertEquals( @@ -1024,7 +1024,7 @@ class TemplateParserV2Test extends \Codeception\Test\Unit // Get final output $executed_output = $tmpl->compile(); - Rhymix\Framework\Storage::write(\RX_BASEDIR . 'tests/_data/template/v2example.executed.html', $executed_output); + //Rhymix\Framework\Storage::write(\RX_BASEDIR . 'tests/_data/template/v2example.executed.html', $executed_output); $expected = file_get_contents(\RX_BASEDIR . 'tests/_data/template/v2example.executed.html'); $expected = preg_replace('/RANDOM_LOOP_ID/', $tmpvar, $expected); $this->assertEquals( @@ -1042,6 +1042,12 @@ class TemplateParserV2Test extends \Codeception\Test\Unit $this->_normalizeWhitespace($fragment_output) ); + // Check that resource is loaded + $list = \Context::getJsFile('body'); + $this->assertStringContainsString('/rhymix/common/js/plugins/ckeditor/', array_first($list)['file']); + $list = \Context::getCssFile(); + $this->assertStringContainsString('/rhymix/tests/_data/template/css/style.scss', array_first($list)['file']); + // Loop variable $tmpl = new \Rhymix\Framework\Template('./tests/_data/template', 'v2loops.html'); $tmpl->disableCache();