Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Release Notes for CKEditor for Craft CMS

## Unreleased

- It’s now possible to set the “Config Options” setting to a JS or JSON file within `config/ckeditor/`. ([#540](https://github.com/craftcms/ckeditor/pull/540))
- It’s now possible to set the “Custom Styles” setting to a CSS file within `config/ckeditor/`. ([#540](https://github.com/craftcms/ckeditor/pull/540))
- The v5 migration now automatically creates JS/JSON/CSS files within `config/ckeditor/` for any Config Options/Custom Styles from CKEditor Configs that were used by two or more fields. ([#540](https://github.com/craftcms/ckeditor/pull/540))

## 5.2.1 - 2026-03-18

- Fixed a bug where `@import` statements within custom styles weren’t working. ([#538](https://github.com/craftcms/ckeditor/pull/538))
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"require": {
"php": "^8.2",
"craftcms/cms": "^5.9.0",
"craftcms/html-field": "^3.4.0",
"craftcms/html-field": "^3.5.0",
"embed/embed": "^4.4",
"nystudio107/craft-code-editor": ">=1.0.8 <=1.0.13 || ^1.0.16"
},
Expand Down
12 changes: 6 additions & 6 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

228 changes: 190 additions & 38 deletions src/Field.php
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,64 @@ private static function adjustFieldValues(
}
}

/** @var array<string|false> */
private static array $cssFileContents = [];
/** @var array<array|false> */
private static array $jsonFileContents = [];
/** @var array<string|false> */
private static array $jsFileContents = [];

private static function cssFileContents(string $file): ?string
{
if (!isset(self::$cssFileContents[$file])) {
$path = self::configFilePath($file);
if (file_exists($path)) {
self::$cssFileContents[$file] = file_get_contents($path);
} else {
self::$cssFileContents[$file] = false;
Craft::warning("Could not load CKEditor CSS file \"$file\".", __METHOD__);
}
}
return self::$cssFileContents[$file] ?: null;
}

private static function jsonFileContents(string $file): array
{
if (!isset(self::$jsonFileContents[$file])) {
$path = self::configFilePath($file);
try {
self::$jsonFileContents[$file] = Json::decodeFromFile($path) ?? [];
} catch (InvalidArgumentException $e) {
Craft::warning("Could not decode JSON from CKEditor config file \"$file\": " . $e->getMessage(), __METHOD__);
self::$jsonFileContents[$file] = [];
}
}

return self::$jsonFileContents[$file];
}

private static function jsFileContents(string $file): ?string
{
if (!isset(self::$jsFileContents[$file])) {
$path = self::configFilePath($file);
if (file_exists($path)) {
self::$jsFileContents[$file] = file_get_contents($path);
} else {
self::$jsFileContents[$file] = false;
Craft::warning("Could not load CKEditor config JS file \"$file\".", __METHOD__);
}
}
return self::$jsFileContents[$file] ?: null;
}

/**
* @since 5.3.0
*/
public static function configFilePath(string $file): string
{
return sprintf('%s/ckeditor/%s', Craft::$app->getPath()->getConfigPath(), $file);
}

/**
* Normalizes an entry type into a `craft\ckeditor\models\EntryType` object.
*
Expand Down Expand Up @@ -471,12 +529,24 @@ public static function entryType(EntryType|CkeEntryType|string|array $entryType)
*/
public ?string $js = null;

/**
* @var string|null The config file that should be used to configure CKEditor.
* @since 5.3.0
*/
public ?string $jsFile = null;

/**
* @var string|null CSS styles that should be registered for the field.
* @since 5.0.0
*/
public ?string $css = null;

/**
* @var string|null The CSS file that should be used to style CKEditor contents.
* @since 5.3.0
*/
public ?string $cssFile = null;

/**
* @var int|null The total number of words allowed.
* @since 3.5.0
Expand Down Expand Up @@ -604,30 +674,51 @@ public function __construct($config = [])
$config['ckeConfig'],
);

if (!array_key_exists('options', $config)) {
// Only use `json` or `js`, not both
if (!empty($config['json'])) {
unset($config['js']);
$config['json'] = trim($config['json']);
if ($config['json'] === '' || preg_match('/^\{\s*\}$/', $config['json'])) {
unset($config['json']);
}
} else {
unset($config['json']);
if (isset($config['js'])) {
if (isset($config['configMode'])) {
switch ($config['configMode']) {
case 'js':
$config['js'] = trim($config['js']);
if ($config['js'] === '' || preg_match('/^return\s*\{\s*\}$/', $config['js'])) {
unset($config['js']);
}
}
unset($config['json'], $config['jsFile']);
break;
case 'file':
if (empty($config['jsFile'])) {
$config['jsFile'] = null;
}
unset($config['json'], $config['js']);
break;
default:
$config['json'] = trim($config['json']);
if ($config['json'] === '' || preg_match('/^\{\s*\}$/', $config['json'])) {
unset($config['json']);
}
unset($config['js'], $config['jsFile']);
break;
}

unset($config['configMode']);
}

if (isset($config['css'])) {
$config['css'] = trim($config['css']);
if ($config['css'] === '') {
unset($config['css']);
if (isset($config['cssMode'])) {
switch ($config['cssMode']) {
case 'file':
if (empty($config['cssFile'])) {
$config['cssFile'] = null;
}
unset($config['css']);
break;
default:
$config['css'] = trim($config['css']);
if ($config['css'] === '') {
unset($config['css']);
}
unset($config['cssFile']);
break;
}

unset($config['cssMode']);
}

if (isset($config['entryTypes']) && $config['entryTypes'] === '') {
Expand Down Expand Up @@ -994,18 +1085,42 @@ private function settingsHtml(bool $readOnly): string

$jsonSchemaUri = sprintf('https://craft-code-editor.com/%s', $view->namespaceInputId('config-options-json'));

$configMode = match (true) {
!empty($this->js) => 'js',
!empty($this->jsFile) => 'file',
default => 'json',
};

$cssMode = match (true) {
!empty($this->cssFile) => 'file',
default => 'css',
};

return $view->renderTemplate('ckeditor/_field-settings.twig', [
'field' => $this,
'importStatements' => CkeditorConfig::getImportStatements(),
'toolbarBuilderId' => $view->namespaceInputId('toolbar-builder'),
'configOptionsId' => $view->namespaceInputId('config-options'),
'cssOptionsId' => $view->namespaceInputId('css-options'),
'toolbarItems' => CkeditorConfig::normalizeToolbarItems(CkeditorConfig::$toolbarItems),
'plugins' => CkeditorConfig::getAllPlugins(),
'jsonSchema' => CkeditorConfigSchema::create(),
'jsonSchemaUri' => $jsonSchemaUri,
'advanceLinkOptions' => CkeditorConfig::advanceLinkOptions(),
'entryTypes' => $this->getEntryTypes(),
'userGroupOptions' => $userGroupOptions,
'jsFileOptions' => $this->configOptions(
dir: 'ckeditor',
only: ['*.json', '*.js'],
includeDefault: false,
includeExtensions: true,
),
'cssFileOptions' => $this->configOptions(
dir: 'ckeditor',
only: ['*.css'],
includeDefault: false,
includeExtensions: true,
),
'purifierConfigOptions' => $this->configOptions('htmlpurifier'),
'baseIconsUrl' => "$bundle->baseUrl/images",
'volumeOptions' => $volumeOptions,
Expand All @@ -1016,6 +1131,8 @@ private function settingsHtml(bool $readOnly): string
'value' => null,
],
], $transformOptions),
'configMode' => $configMode,
'cssMode' => $cssMode,
'readOnly' => $readOnly,
]);
}
Expand Down Expand Up @@ -1369,23 +1486,6 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
]);
$this->trigger(self::EVENT_MODIFY_CONFIG, $event);

if (isset($this->options)) {
// translate the placeholder text
if (isset($this->options['placeholder']) && is_string($this->options['placeholder'])) {
$this->options['placeholder'] = Craft::t('site', $this->options['placeholder']);
}

$configOptionsJs = Json::encode($this->options);
} elseif (isset($this->js)) {
$configOptionsJs = <<<JS
(() => {
$this->js
})()
JS;
} else {
$configOptionsJs = '{}';
}

$removePlugins = Collection::empty();

// remove MediaEmbedToolbar for now
Expand Down Expand Up @@ -1435,17 +1535,28 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
$importCompliantUiLanguage = BaseCkeditorPackageAsset::getImportCompliantLanguage(BaseCkeditorPackageAsset::uiLanguage());
$uiTranslationImport = "import coreTranslations from 'ckeditor5/translations/$importCompliantUiLanguage.js';";

$view->registerScriptWithVars(fn($baseConfigJs, $toolbarJs, $languageJs, $showWordCountJs, $wordLimitJs, $characterLimitJs, $imageMode) => <<<JS
$configJs = $this->configJs();

$view->registerScriptWithVars(fn(
$baseConfigJs,
$toolbarJs,
$languageJs,
$showWordCountJs,
$wordLimitJs,
$characterLimitJs,
$imageMode,
) => <<<JS
$imports
$uiTranslationImport
import {create} from '@craftcms/ckeditor';

(($) => {
let instance;
const customConfig = $configJs;
const config = Object.assign({
translations: [coreTranslations],
language: $languageJs,
}, $baseConfigJs, $configOptionsJs, {
}, $baseConfigJs, customConfig, {
plugins: $configPlugins,
removePlugins: []
});
Expand All @@ -1459,7 +1570,7 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
// special case for heading config, because of the Heading Levels
// see https://github.com/craftcms/ckeditor/issues/431
const baseHeadings = $baseConfigJs?.heading?.options;
const configOptionHeadings = $configOptionsJs?.heading?.options;
const configOptionHeadings = customConfig?.heading?.options;
const nativeHeadingModels = ['paragraph', 'heading1', 'heading2', 'heading3', 'heading4', 'heading5', 'heading6'];

if (baseHeadings && configOptionHeadings && baseHeadings != configOptionHeadings) {
Expand Down Expand Up @@ -1572,8 +1683,8 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
]);
}

if ($this->css) {
$css = $this->css;
$css = $this->css();
if ($css) {
$imports = [];
preg_match_all('/@import .+;?/m', $css, $importMatches);
for ($i = 0; $i < count($importMatches[0]); $i++) {
Expand Down Expand Up @@ -1601,6 +1712,47 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $stat
]);
}

private function configJs(): string
{
if (isset($this->jsFile) && strtolower(pathinfo($this->jsFile, PATHINFO_EXTENSION)) === 'json') {
$this->options = self::jsonFileContents($this->jsFile);
}

if (isset($this->options)) {
// translate the placeholder text
if (isset($this->options['placeholder']) && is_string($this->options['placeholder'])) {
$this->options['placeholder'] = Craft::t('site', $this->options['placeholder']);
}

return Json::encode($this->options);
}

if (isset($this->jsFile)) {
$js = self::jsFileContents($this->jsFile);
} else {
$js = $this->js;
}

if ($js === null) {
return '{}';
}

return <<<JS
(() => {
$js
})()
JS;
}

private function css(): ?string
{
if (isset($this->cssFile)) {
return self::cssFileContents($this->cssFile);
}

return $this->css;
}

/**
* @inheritdoc
*/
Expand Down
Loading
Loading