diff --git a/_config/html.yml b/_config/html.yml index 0c3d04c2f..20479728c 100644 --- a/_config/html.yml +++ b/_config/html.yml @@ -8,3 +8,8 @@ SilverStripe\Core\Injector\Injector: HTMLValue: '%$SilverStripe\View\Parsers\HTMLValue' SilverStripe\Forms\HTMLEditor\HTMLEditorConfig: class: SilverStripe\Forms\HTMLEditor\TinyMCEConfig + SilverStripe\Forms\HTMLEditor\TinyMCEScriptGenerator: '%$SilverStripe\Forms\HTMLEditor\TinyMCECombinedGenerator' + SilverStripe\Forms\HTMLEditor\TinyMCECombinedGenerator: + class: SilverStripe\Forms\HTMLEditor\TinyMCECombinedGenerator + properties: + AssetHandler: '%$SilverStripe\Assets\Storage\GeneratedAssetHandler' diff --git a/src/Forms/HTMLEditor/TinyMCECombinedGenerator.php b/src/Forms/HTMLEditor/TinyMCECombinedGenerator.php new file mode 100644 index 000000000..ace718319 --- /dev/null +++ b/src/Forms/HTMLEditor/TinyMCECombinedGenerator.php @@ -0,0 +1,204 @@ +assetHandler = $assetHandler; + return $this; + } + + /** + * Get backend for assets + * @return GeneratedAssetHandler + */ + public function getAssetHandler() + { + return $this->assetHandler; + } + + /** + * Generate a script URL for the given config + * + * @param TinyMCEConfig $config + * @return string + */ + public function getScriptURL(TinyMCEConfig $config) + { + // Build URL for this config based on named config and hash of settings + $url = $this->generateFilename($config); + + // Pass content generator + return $this->getAssetHandler()->getContentURL($url, function () use ($config) { + return $this->generateContent($config); + }); + } + + /** + * Build raw config for tinymce + * + * @param TinyMCEConfig $config + * @return string + */ + public function generateContent(TinyMCEConfig $config) + { + $tinymceDir = $config->getTinyMCEPath(); + + // Core JS file + $files = [ $tinymceDir . '/tinymce' ]; + + // Add core languages + $language = $config->getOption('language'); + if ($language) { + $files[] = $tinymceDir . '/langs/' . $language; + } + + // Add plugins, along with any plugin specific lang files + foreach ($config->getPlugins() as $plugin => $path) { + // Add external plugin + if ($path) { + // Convert URLS to relative paths + if (Director::is_absolute_url($path) || strpos($path, '/') === 0) { + // De-absolute site urls + $path = Director::makeRelative($path); + if ($path) { + $files[] = $path; + } + } else { + // Relative URLs are safe + $files[] = $path; + } + continue; + } + + // Core tinymce plugin + $files[] = $tinymceDir . '/plugins/' . $plugin . '/plugin'; + if ($language) { + $files[] = $tinymceDir . '/plugins/' . $plugin . '/langs/' . $language; + } + } + + // Add themes + $theme = $config->getTheme(); + if ($theme) { + $files[] = $tinymceDir . '/themes/' . $theme . '/theme'; + if ($language) { + $files[] = $tinymceDir . '/themes/' . $theme . '/langs/' . $language; + } + } + + // Process source files + $files = array_filter(array_map(function ($file) { + // Prioritise preferred paths + $paths = [ + $file, + $file . '.min.js', + $file . '.js', + ]; + foreach ($paths as $path) { + if (file_exists(Director::baseFolder() . '/' . $path)) { + return $path; + } + } + return null; + }, $files)); + + // Set base URL for where tinymce is loaded from + $buffer = "var tinyMCEPreInit={base:'" . Convert::raw2js($tinymceDir) . "',suffix:'.min'};\n"; + + // Load all tinymce script files into buffer + foreach ($files as $file) { + $buffer .= $this->getFileContents(Director::baseFolder() . '/' . $file) . "\n"; + } + + // Mark all themes, plugins and languages as done + $buffer .= 'tinymce.each("' . Convert::raw2js(implode(',', $files)) . '".split(","),function(f){tinymce.ScriptLoader.markDone(f);});'; + + return $buffer . "\n"; + } + + + /** + * Returns the contents of the script file if it exists and removes the UTF-8 BOM header if it exists. + * + * @param string $file File to load. + * @return string File contents or empty string if it doesn't exist. + */ + protected function getFileContents($file) + { + $content = file_get_contents($file); + + // Remove UTF-8 BOM + if (substr($content, 0, 3) === pack("CCC", 0xef, 0xbb, 0xbf)) { + $content = substr($content, 3); + } + + return $content; + } + + /** + * Check if this config is registered under a given key + * + * @param TinyMCEConfig $config + * @return string + */ + protected function checkName(TinyMCEConfig $config) + { + $configs = HTMLEditorConfig::get_available_configs_map(); + foreach ($configs as $id => $name) { + if (HTMLEditorConfig::get($id) === $config) { + return $id; + } + } + return 'custom'; + } + + /** + * Get filename to use for this config + * + * @param TinyMCEConfig $config + * @return mixed + */ + public function generateFilename(TinyMCEConfig $config) + { + $hash = substr(sha1(json_encode($config->getAttributes())), 0, 10); + $name = $this->checkName($config); + $url = str_replace( + ['{name}', '{hash}'], + [$name, $hash], + $this->config()->get('filename_base') + ); + return $url; + } +} diff --git a/src/Forms/HTMLEditor/TinyMCEConfig.php b/src/Forms/HTMLEditor/TinyMCEConfig.php index e394effea..b11789e25 100644 --- a/src/Forms/HTMLEditor/TinyMCEConfig.php +++ b/src/Forms/HTMLEditor/TinyMCEConfig.php @@ -2,9 +2,11 @@ namespace SilverStripe\Forms\HTMLEditor; -use SilverStripe\Core\Convert; +use Exception; use SilverStripe\Control\Controller; use SilverStripe\Control\Director; +use SilverStripe\Core\Convert; +use SilverStripe\Core\Injector\Injector; use SilverStripe\Core\Manifest\Module; use SilverStripe\Core\Manifest\ModuleLoader; use SilverStripe\Dev\Deprecation; @@ -12,8 +14,6 @@ use SilverStripe\i18n\i18n; use SilverStripe\View\Requirements; use SilverStripe\View\SSViewer; use SilverStripe\View\ThemeResourceLoader; -use TinyMCE_Compressor; -use Exception; /** * Default configuration for HtmlEditor specific to tinymce @@ -627,8 +627,11 @@ class TinyMCEConfig extends HTMLEditorConfig $editor = array(); // Add standard editor.css - foreach ($this->config()->get('editor_css') as $editorCSS) { - $editor[] = Director::absoluteURL($this->resolvePath($editorCSS)); + $editorCSSFiles = $this->config()->get('editor_css'); + if ($editorCSSFiles) { + foreach ($editorCSSFiles as $editorCSS) { + $editor[] = Director::absoluteURL($this->resolvePath($editorCSS)); + } } // Themed editor.css @@ -651,29 +654,9 @@ class TinyMCEConfig extends HTMLEditorConfig */ public function getScriptURL() { - // If gzip is disabled just return core script url - $useGzip = HTMLEditorField::config()->get('use_gzip'); - if (!$useGzip) { - return $this->getTinyMCEPath() . '/tinymce.min.js'; - } - - // tinyMCE JS requirement - $gzipPath = BASE_PATH . '/' . $this->getTinyMCEPath() . '/tiny_mce_gzip.php'; - if (!file_exists($gzipPath)) { - throw new Exception("HTMLEditorField.use_gzip enabled, but file $gzipPath does not exist!"); - } - - require_once $gzipPath; - - $tag = TinyMCE_Compressor::renderTag(array( - 'url' => $this->getTinyMCEPath() . '/tiny_mce_gzip.php', - 'plugins' => implode(',', $this->getInternalPlugins()), - 'themes' => $this->getTheme(), - 'languages' => $this->getOption('language') - ), true); - preg_match('/src="([^"]*)"/', $tag, $matches); - - return html_entity_decode($matches[1]); + /** @var TinyMCEScriptGenerator $generator */ + $generator = Injector::inst()->get(TinyMCEScriptGenerator::class); + return $generator->getScriptURL($this); } public function init() diff --git a/src/Forms/HTMLEditor/TinyMCEGZIPGenerator.php b/src/Forms/HTMLEditor/TinyMCEGZIPGenerator.php new file mode 100644 index 000000000..b575b5ae6 --- /dev/null +++ b/src/Forms/HTMLEditor/TinyMCEGZIPGenerator.php @@ -0,0 +1,57 @@ +get('use_gzip'); + if (!$useGzip) { + return $config->getTinyMCEPath() . '/tinymce.min.js'; + } + + // tinyMCE JS requirement + $gzipPath = BASE_PATH . '/' . $config->getTinyMCEPath() . '/tiny_mce_gzip.php'; + if (!file_exists($gzipPath)) { + throw new Exception("HTMLEditorField.use_gzip enabled, but file $gzipPath does not exist!"); + } + + require_once $gzipPath; + + $tag = TinyMCE_Compressor::renderTag(array( + 'url' => $config->getTinyMCEPath() . '/tiny_mce_gzip.php', + 'plugins' => implode(',', $config->getInternalPlugins()), + 'themes' => $config->getTheme(), + 'languages' => $config->getOption('language') + ), true); + preg_match('/src="([^"]*)"/', $tag, $matches); + + return html_entity_decode($matches[1]); + } +} diff --git a/src/Forms/HTMLEditor/TinyMCEScriptGenerator.php b/src/Forms/HTMLEditor/TinyMCEScriptGenerator.php new file mode 100644 index 000000000..e358bce32 --- /dev/null +++ b/src/Forms/HTMLEditor/TinyMCEScriptGenerator.php @@ -0,0 +1,17 @@ +assertEquals(['plugin4', 'plugin5'], $c->getInternalPlugins()); } - public function testPluginCompression() - { - // This test requires the real tiny_mce_gzip.php file - $module = ModuleLoader::inst()->getManifest()->getModule('silverstripe/admin'); - if (!$module) { - $this->markTestSkipped('No silverstripe/admin module loaded'); - } - TinyMCEConfig::config()->set('base_dir', 'silverstripe/admin:thirdparty/tinymce'); - Config::modify()->set(Director::class, 'alternate_base_url', 'http://mysite.com/subdir'); - - // Build new config - $c = new TinyMCEConfig(); - $c->setTheme('modern'); - $c->setOption('language', 'es'); - $c->disablePlugins('table', 'emoticons', 'paste', 'code', 'link', 'importcss'); - $c->enablePlugins( - array( - 'plugin1' => 'mypath/plugin1.js', - 'plugin2' => '/anotherbase/mypath/plugin2.js', - 'plugin3' => 'https://www.google.com/plugin.js', - 'plugin4' => null, - 'plugin5' => null, - ) - ); - $attributes = $c->getAttributes(); - $config = Convert::json2array($attributes['data-config']); - $plugins = $config['external_plugins']; - $this->assertNotEmpty($plugins); - - // Test plugins included via gzip compresser - HTMLEditorField::config()->update('use_gzip', true); - $this->assertEquals( - 'silverstripe-admin/thirdparty/tinymce/tiny_mce_gzip.php?js=1&plugins=plugin4,plugin5&themes=modern&languages=es&diskcache=true&src=true', - $c->getScriptURL() - ); - - // If gzip is disabled only the core plugin is loaded - HTMLEditorField::config()->remove('use_gzip'); - $this->assertEquals( - 'silverstripe-admin/thirdparty/tinymce/tinymce.min.js', - $c->getScriptURL() - ); - } - public function testDisablePluginsByString() { $c = new TinyMCEConfig(); @@ -199,7 +155,7 @@ class HTMLEditorConfigTest extends SapphireTest $this->assertNotEmpty($cAttributes['data-config']); } - public function testExceptionThrownWhenTinyMCEPathCannotBeComputed() + public function testExceptionThrownWhenBaseDirAbsent() { TinyMCEConfig::config()->remove('base_dir'); ModuleLoader::inst()->pushManifest(new ModuleManifest(__DIR__)); @@ -213,19 +169,4 @@ class HTMLEditorConfigTest extends SapphireTest ModuleLoader::inst()->popManifest(); } } - - public function testExceptionThrownWhenTinyMCEGZipPathDoesntExist() - { - HTMLEditorField::config()->set('use_gzip', true); - /** @var TinyMCEConfig|PHPUnit_Framework_MockObject_MockObject $stub */ - $stub = $this->getMockBuilder(TinyMCEConfig::class) - ->setMethods(['getTinyMCEPath']) - ->getMock(); - $stub->method('getTinyMCEPath') - ->willReturn('fail'); - - $this->expectException(Exception::class); - $this->expectExceptionMessageRegExp('/does not exist/'); - $stub->getScriptURL(); - } } diff --git a/tests/php/Forms/HTMLEditor/TinyMCECombinedGeneratorTest.php b/tests/php/Forms/HTMLEditor/TinyMCECombinedGeneratorTest.php new file mode 100644 index 000000000..99eb1cf21 --- /dev/null +++ b/tests/php/Forms/HTMLEditor/TinyMCECombinedGeneratorTest.php @@ -0,0 +1,84 @@ +set('alternate_base_folder', __DIR__ . '/TinyMCECombinedGeneratorTest'); + Director::config()->set('alternate_base_url', 'http://www.mysite.com/basedir/'); + TinyMCEConfig::config()->set('base_dir', 'tinymce'); + } + + public function testConfig() + { + // Disable nonces + $c = new TinyMCEConfig(); + $c->setTheme('testtheme'); + $c->setOption('language', 'en'); + $c->disablePlugins('table', 'emoticons', 'paste', 'code', 'link', 'importcss'); + $c->enablePlugins( + array( + 'plugin1' => 'mycode/plugin1.js', // + 'plugin2' => '/anotherbase/mycode/plugin2.js', + 'plugin3' => 'https://www.google.com/mycode/plugin3.js', + 'plugin4' => null, + 'plugin5' => null, + 'plugin6' => '/basedir/mycode/plugin6.js', + 'plugin7' => '/basedir/mycode/plugin7.js', + ) + ); + HTMLEditorConfig::set_config('testconfig', $c); + + // Get config for this + /** @var TinyMCECombinedGenerator $generator */ + $generator = Injector::inst()->create(TinyMCECombinedGenerator::class); + $this->assertEquals( + '_tinymce/tinymce-testconfig-8d695fc0be.js', + $generator->generateFilename($c), + "Filename for config: " . json_encode($c->getAttributes()) . " should match expected value" + ); + $content = $generator->generateContent($c); + $this->assertStringStartsWith("var tinyMCEPreInit={base:'tinymce',suffix:'.min'};\n", $content); + // Main script file + $this->assertContains("/* tinymce.js */\n", $content); + // Locale file + $this->assertContains("/* en.js */\n", $content); + // Local plugins + $this->assertContains("/* plugin1.js */\n", $content); + $this->assertContains("/* plugin4.min.js */\n", $content); + $this->assertContains("/* plugin4/langs/en.js */\n", $content); + $this->assertContains("/* plugin5.js */\n", $content); + $this->assertContains("/* plugin6.js */\n", $content); + // Exclude non-local plugins + $this->assertNotContains('plugin2.js', $content); + $this->assertNotContains('plugin3.js', $content); + // Exclude missing file + $this->assertNotContains('plugin7.js', $content); + + // Check themes + $this->assertContains("/* theme.js */\n", $content); + $this->assertContains("/* testtheme/langs/en.js */\n", $content); + + // Register done scripts + $this->assertStringEndsWith( + <<