ckeditor5-livewire 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ckeditor5-livewire",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CKEditor 5 integration for Laravel Livewire",
5
5
  "author": "Mateusz Bagiński <cziken58@gmail.com>",
6
6
  "license": "MIT",
@@ -106,7 +106,7 @@ describe('context component', () => {
106
106
  expect(context?.plugins.get('CustomPlugin')).toBeInstanceOf(CustomPlugin);
107
107
  });
108
108
 
109
- it('registered plugins should support custom translations', async () => {
109
+ describe('language', () => {
110
110
  class CustomPlugin extends ContextPlugin {
111
111
  static get pluginName() {
112
112
  return 'CustomPlugin';
@@ -117,71 +117,96 @@ describe('context component', () => {
117
117
  }
118
118
  }
119
119
 
120
- CustomEditorPluginsRegistry.the.register('CustomPlugin', () => CustomPlugin);
120
+ beforeEach(() => {
121
+ CustomEditorPluginsRegistry.the.register('CustomPlugin', () => CustomPlugin);
122
+ });
121
123
 
122
- livewireStub.$internal.appendComponentToDOM({
123
- name: 'ckeditor5-context',
124
- el: createContextHtmlElement(),
125
- canonical: createContextSnapshot(DEFAULT_TEST_CONTEXT_ID, {
126
- customTranslations: {
127
- en: {
128
- HELLO: 'Hello from CustomPlugin',
124
+ it('registered plugins should support custom translations', async () => {
125
+ livewireStub.$internal.appendComponentToDOM({
126
+ name: 'ckeditor5-context',
127
+ el: createContextHtmlElement(),
128
+ canonical: createContextSnapshot(DEFAULT_TEST_CONTEXT_ID, {
129
+ customTranslations: {
130
+ en: {
131
+ HELLO: 'Hello from CustomPlugin',
132
+ },
129
133
  },
130
- },
131
- config: {
132
- plugins: ['CustomPlugin'],
133
- },
134
- }),
135
- });
134
+ config: {
135
+ plugins: ['CustomPlugin'],
136
+ },
137
+ }),
138
+ });
136
139
 
137
- const { context } = await waitForTestContext();
138
- const plugin = context?.plugins.get('CustomPlugin') as CustomPlugin;
140
+ const { context } = await waitForTestContext();
141
+ const plugin = context?.plugins.get('CustomPlugin') as CustomPlugin;
139
142
 
140
- expect(plugin.getHelloTitle()).toBe('Hello from CustomPlugin');
141
- });
143
+ expect(plugin.getHelloTitle()).toBe('Hello from CustomPlugin');
144
+ });
142
145
 
143
- it('should support custom language for context translations', async () => {
144
- class CustomPlugin extends ContextPlugin {
145
- static get pluginName() {
146
- return 'CustomPlugin';
147
- }
146
+ it('should support custom language for context translations', async () => {
147
+ livewireStub.$internal.appendComponentToDOM({
148
+ name: 'ckeditor5-context',
149
+ el: createContextHtmlElement(),
150
+ canonical: createContextSnapshot(
151
+ DEFAULT_TEST_CONTEXT_ID,
152
+ {
153
+ customTranslations: {
154
+ en: {
155
+ HELLO: 'Hello from CustomPlugin',
156
+ },
157
+ pl: {
158
+ HELLO: 'Witaj z CustomPlugin',
159
+ },
160
+ },
161
+ config: {
162
+ plugins: ['CustomPlugin'],
163
+ },
164
+ },
165
+ {
166
+ ui: 'pl',
167
+ content: 'pl',
168
+ },
169
+ ),
170
+ });
148
171
 
149
- getHelloTitle() {
150
- return this.context.t('HELLO');
151
- }
152
- }
172
+ const { context } = await waitForTestContext();
173
+ const plugin = context?.plugins.get('CustomPlugin') as CustomPlugin;
153
174
 
154
- CustomEditorPluginsRegistry.the.register('CustomPlugin', () => CustomPlugin);
175
+ expect(plugin.getHelloTitle()).toBe('Witaj z CustomPlugin');
176
+ });
155
177
 
156
- livewireStub.$internal.appendComponentToDOM({
157
- name: 'ckeditor5-context',
158
- el: createContextHtmlElement(),
159
- canonical: createContextSnapshot(
160
- DEFAULT_TEST_CONTEXT_ID,
161
- {
162
- customTranslations: {
163
- en: {
164
- HELLO: 'Hello from CustomPlugin',
178
+ it('should resolve $translation references in the context configuration', async () => {
179
+ livewireStub.$internal.appendComponentToDOM({
180
+ name: 'ckeditor5-context',
181
+ el: createContextHtmlElement(),
182
+ canonical: createContextSnapshot(
183
+ DEFAULT_TEST_CONTEXT_ID,
184
+ {
185
+ customTranslations: {
186
+ en: {
187
+ Custom: 'Custom value',
188
+ },
189
+ pl: {
190
+ Custom: 'Wartość niestandardowa',
191
+ },
165
192
  },
166
- pl: {
167
- HELLO: 'Witaj z CustomPlugin',
193
+ config: {
194
+ customPlugin: {
195
+ label: { $translation: 'Custom' },
196
+ },
168
197
  },
169
198
  },
170
- config: {
171
- plugins: ['CustomPlugin'],
199
+ {
200
+ ui: 'pl',
201
+ content: 'pl',
172
202
  },
173
- },
174
- {
175
- ui: 'pl',
176
- content: 'pl',
177
- },
178
- ),
179
- });
203
+ ),
204
+ });
180
205
 
181
- const { context } = await waitForTestContext();
182
- const plugin = context?.plugins.get('CustomPlugin') as CustomPlugin;
206
+ const { context } = await waitForTestContext();
183
207
 
184
- expect(plugin.getHelloTitle()).toBe('Witaj z CustomPlugin');
208
+ expect((context!.config.get('customPlugin') as any).label).toBe('Wartość niestandardowa');
209
+ });
185
210
  });
186
211
  });
187
212
 
@@ -9,6 +9,8 @@ import {
9
9
  loadAllEditorTranslations,
10
10
  loadEditorPlugins,
11
11
  normalizeCustomTranslations,
12
+ resolveEditorConfigElementReferences,
13
+ resolveEditorConfigTranslations,
12
14
  } from '../editor/utils';
13
15
  import { ContextsRegistry } from './contexts-registry';
14
16
 
@@ -47,8 +49,14 @@ export class ContextComponentHook extends ClassHook<Snapshot> {
47
49
  ...watchdogConfig,
48
50
  });
49
51
 
52
+ // Construct parsed config. First resolve DOM element references in the provided configuration.
53
+ let resolvedConfig = resolveEditorConfigElementReferences(config);
54
+
55
+ // Then resolve translation references in the provided configuration, using the mixed translations.
56
+ resolvedConfig = resolveEditorConfigTranslations([...mixedTranslations].reverse(), language.ui, resolvedConfig);
57
+
50
58
  await instance.create({
51
- ...config,
59
+ ...resolvedConfig,
52
60
  language,
53
61
  plugins: loadedPlugins,
54
62
  ...mixedTranslations.length && {
@@ -531,6 +531,39 @@ describe('editor component', () => {
531
531
 
532
532
  expect(editor.t('Bold')).toBe('Czcionka grubaśna');
533
533
  });
534
+
535
+ it('should resolve $translation references in the editor configuration', async () => {
536
+ const preset = createEditorPreset(
537
+ 'classic',
538
+ {
539
+ customPlugin: {
540
+ label: { $translation: 'Custom' },
541
+ },
542
+ },
543
+ {
544
+ pl: {
545
+ Custom: 'Mocarna czcionka',
546
+ },
547
+ },
548
+ );
549
+
550
+ livewireStub.$internal.appendComponentToDOM<EditorSnapshot>({
551
+ name: 'ckeditor5',
552
+ el: createEditorHtmlElement(),
553
+ canonical: {
554
+ ...createEditorSnapshot(),
555
+ preset,
556
+ language: {
557
+ ui: 'pl',
558
+ content: 'pl',
559
+ },
560
+ },
561
+ });
562
+
563
+ const editor = await waitForTestEditor();
564
+
565
+ expect((editor.config.get('customPlugin') as any).label).toBe('Mocarna czcionka');
566
+ });
534
567
  });
535
568
 
536
569
  describe('`watchdog` snapshot parameter`', () => {
@@ -21,6 +21,7 @@ import {
21
21
  queryEditablesElements,
22
22
  queryEditablesSnapshotContent,
23
23
  resolveEditorConfigElementReferences,
24
+ resolveEditorConfigTranslations,
24
25
  setEditorEditableHeight,
25
26
  unwrapEditorContext,
26
27
  unwrapEditorWatchdog,
@@ -222,9 +223,15 @@ export class EditorComponentHook extends ClassHook<Snapshot> {
222
223
  sourceElements = sourceElements['main'];
223
224
  }
224
225
 
226
+ // Construct parsed config. First resolve DOM element references in the provided configuration.
227
+ let resolvedConfig = resolveEditorConfigElementReferences(config);
228
+
229
+ // Then resolve translation references in the provided configuration, using the mixed translations.
230
+ resolvedConfig = resolveEditorConfigTranslations([...mixedTranslations].reverse(), language.ui, resolvedConfig);
231
+
225
232
  // Construct parsed config.
226
233
  const parsedConfig = {
227
- ...resolveEditorConfigElementReferences(config),
234
+ ...resolvedConfig,
228
235
  initialData,
229
236
  licenseKey,
230
237
  plugins: loadedPlugins,
@@ -8,5 +8,6 @@ export * from './load-editor-translations';
8
8
  export * from './normalize-custom-translations';
9
9
  export * from './query-editor-editables';
10
10
  export * from './resolve-editor-config-elements-references';
11
+ export * from './resolve-editor-config-translations';
11
12
  export * from './set-editor-editable-height';
12
13
  export * from './wrap-with-watchdog';
@@ -1,3 +1,5 @@
1
+ import type { Translations } from 'ckeditor5';
2
+
1
3
  /**
2
4
  * Loads all required translations for the editor based on the language configuration.
3
5
  *
@@ -10,7 +12,7 @@
10
12
  export async function loadAllEditorTranslations(
11
13
  language: { ui: string; content: string; },
12
14
  hasPremium: boolean,
13
- ) {
15
+ ): Promise<Translations[]> {
14
16
  const translations = [language.ui, language.content];
15
17
  const loadedTranslations = await Promise.all(
16
18
  [
@@ -0,0 +1,131 @@
1
+ import type { Translations } from 'ckeditor5';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { resolveEditorConfigTranslations } from './resolve-editor-config-translations';
6
+
7
+ describe('resolveEditorConfigTranslations', () => {
8
+ let translationsPacks: Translations[];
9
+
10
+ beforeEach(() => {
11
+ translationsPacks = [
12
+ makePack('en', {
13
+ HELLO: 'Hello world',
14
+ NESTED: 'nested value',
15
+ ARRAY: 'array value',
16
+ }),
17
+ ];
18
+ });
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ it('resolves a single translation reference', () => {
25
+ const config = {
26
+ foo: { $translation: 'HELLO' },
27
+ };
28
+
29
+ const result = resolveEditorConfigTranslations(translationsPacks, 'en', config);
30
+ expect(result.foo).toBe('Hello world');
31
+ });
32
+
33
+ it('respects specified language when multiple packs present', () => {
34
+ const packs: Translations[] = [
35
+ makePack('en', { KEY: 'english' }),
36
+ makePack('pl', { KEY: 'polish' }),
37
+ ];
38
+
39
+ const result = resolveEditorConfigTranslations(packs, 'pl', { foo: { $translation: 'KEY' } });
40
+ expect(result.foo).toBe('polish');
41
+ });
42
+
43
+ it('returns null if translation key not found', () => {
44
+ const config = {
45
+ foo: { $translation: 'MISSING' },
46
+ };
47
+
48
+ const result = resolveEditorConfigTranslations(translationsPacks, 'en', config);
49
+ expect(result.foo).toBeNull();
50
+ });
51
+
52
+ it('recursively resolves nested translation references', () => {
53
+ const config = {
54
+ nested: {
55
+ bar: { $translation: 'NESTED' },
56
+ },
57
+ };
58
+
59
+ const result = resolveEditorConfigTranslations(translationsPacks, 'en', config);
60
+ expect(result.nested.bar).toBe('nested value');
61
+ });
62
+
63
+ it('resolves translation references in arrays', () => {
64
+ const config = [
65
+ { $translation: 'HELLO' },
66
+ { $translation: 'ARRAY' },
67
+ { notTranslation: 123 },
68
+ ];
69
+
70
+ const result = resolveEditorConfigTranslations(translationsPacks, 'en', config);
71
+
72
+ expect(result[0]).toBe('Hello world');
73
+ expect(result[1]).toBe('array value');
74
+ expect(result[2]).toEqual({ notTranslation: 123 });
75
+ });
76
+
77
+ it('returns primitives as is', () => {
78
+ expect(resolveEditorConfigTranslations(translationsPacks, 'en', 42 as any)).toBe(42);
79
+ expect(resolveEditorConfigTranslations(translationsPacks, 'en', 'foo' as any)).toBe('foo');
80
+ expect(resolveEditorConfigTranslations(translationsPacks, 'en', null as any)).toBe(null);
81
+ expect(resolveEditorConfigTranslations(translationsPacks, 'en', undefined as any)).toBe(undefined);
82
+ });
83
+
84
+ it('warns for missing translation key', () => {
85
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
86
+ const config = { foo: { $translation: 'UNKNOWN' } };
87
+
88
+ resolveEditorConfigTranslations(translationsPacks, 'en', config);
89
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Translation not found for key: UNKNOWN'));
90
+ });
91
+
92
+ it('selects translations based on the provided language when multiple packs are given', () => {
93
+ const packs: Translations[] = [
94
+ makePack('en', { HELLO: 'Hello world', ARRAY: 'array value' }),
95
+ makePack('pl', { NESTED: 'nested value' }),
96
+ ];
97
+
98
+ const config = {
99
+ foo: { $translation: 'HELLO' },
100
+ nested: { bar: { $translation: 'NESTED' } },
101
+ arr: [{ $translation: 'ARRAY' }],
102
+ missing: { $translation: 'NOTHING' },
103
+ };
104
+
105
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
106
+ const resultEn = resolveEditorConfigTranslations(packs, 'en', config);
107
+
108
+ expect(resultEn.foo).toBe('Hello world');
109
+ expect(resultEn.nested.bar).toBeNull();
110
+ expect(resultEn.arr[0]).toBe('array value');
111
+ expect(resultEn.missing).toBeNull();
112
+
113
+ const resultPl = resolveEditorConfigTranslations(packs, 'pl', config);
114
+
115
+ expect(resultPl.foo).toBeNull();
116
+ expect(resultPl.nested.bar).toBe('nested value');
117
+ expect(resultPl.arr[0]).toBeNull();
118
+ expect(resultPl.missing).toBeNull();
119
+
120
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Translation not found for key: NOTHING'));
121
+ });
122
+ });
123
+
124
+ function makePack(lang: string, dict: Record<string, string>): Translations {
125
+ return {
126
+ [lang]: {
127
+ dictionary: dict,
128
+ getPluralForm: null,
129
+ },
130
+ };
131
+ }
@@ -0,0 +1,77 @@
1
+ import type { Translations } from 'ckeditor5';
2
+
3
+ /**
4
+ * Resolves translation references in a configuration object.
5
+ *
6
+ * The configuration may contain objects with the form `{ $translation: "some.key" }`.
7
+ * These are replaced with the actual string from the provided translations map.
8
+ *
9
+ * The function will walk the provided object recursively, handling arrays and
10
+ * nested objects. Primitive values are returned as-is. If a translation key is
11
+ * not present in the map, a warning is logged and `null` is returned for that
12
+ * value.
13
+ *
14
+ * @param translations - An array of CKEditor `Translations` objects. Each translation
15
+ * pack will be searched in order for the requested key, and the
16
+ * first matching value will be returned. This mirrors the format
17
+ * returned by `loadAllEditorTranslations` and simplifies the
18
+ * caller's API.
19
+ * @param language - Language identifier to look up in the packs. Only this locale
20
+ * will be consulted, ensuring that keys from other languages are
21
+ * ignored even if they appear earlier in the array.
22
+ * @param obj - Configuration object to process
23
+ * @returns Processed configuration object with resolved translations.
24
+ */
25
+ export function resolveEditorConfigTranslations<T>(
26
+ translations: Translations[],
27
+ language: string,
28
+ obj: T,
29
+ ): T {
30
+ if (!obj || typeof obj !== 'object') {
31
+ return obj;
32
+ }
33
+
34
+ if (Array.isArray(obj)) {
35
+ return obj.map(item => resolveEditorConfigTranslations(translations, language, item)) as T;
36
+ }
37
+
38
+ const anyObj = obj as any;
39
+
40
+ if (anyObj.$translation && typeof anyObj.$translation === 'string') {
41
+ const key: string = anyObj.$translation;
42
+ const value = getTranslationValue(translations, key, language);
43
+
44
+ if (value === undefined) {
45
+ console.warn(`Translation not found for key: ${key}`);
46
+ }
47
+
48
+ return (value !== undefined ? value : null) as T;
49
+ }
50
+
51
+ const result = Object.create(null);
52
+
53
+ for (const [key, value] of Object.entries(obj)) {
54
+ result[key] = resolveEditorConfigTranslations(translations, language, value);
55
+ }
56
+
57
+ return result as T;
58
+ }
59
+
60
+ /**
61
+ * Look up a translation value inside the provided map or array of CKEditor packs.
62
+ */
63
+ function getTranslationValue(
64
+ translations: Translations[],
65
+ key: string,
66
+ language: string,
67
+ ): string | ReadonlyArray<string> | undefined {
68
+ for (const pack of translations) {
69
+ const langData = pack[language];
70
+
71
+ if (langData?.dictionary && key in langData.dictionary) {
72
+ return langData.dictionary[key] as string | ReadonlyArray<string>;
73
+ }
74
+ }
75
+
76
+ return undefined;
77
+ }