Skip to content
Open
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
91 changes: 90 additions & 1 deletion assets/js/app/editor/Components/Html.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,92 @@
<template>
<div>
<trumbowyg :id="id" v-model="val" :name="name" :config="config"></trumbowyg>
<trumbowyg v-if="ready" :id="id" v-model="val" :name="name" :config="config"></trumbowyg>
</div>
</template>

<script>
import jQuery from 'jquery';
import trumbowyg from 'vue-trumbowyg';
import 'trumbowyg/dist/ui/trumbowyg.css';
import { strip } from '../../../filters/string';

// Trumbowyg's UI translations, one langs/<code>.min.js per language. In lazy mode each
// file is its own chunk (trumbowyg-lang-<code>.js), fetched only when requested.
// Loading a file registers it on jQuery.trumbowyg.langs. English is built in.
const langFiles = import.meta.webpackContext('trumbowyg/dist/langs', {
recursive: false,
regExp: /\.min\.js$/,
mode: 'lazy',
chunkName: 'trumbowyg-lang-[request]',
});

// Trumbowyg names some languages differently from the ISO codes Bolt uses. Its `my`
// file is meant to be Malay, while ISO `my` is Burmese, so that one must not match
// as-is. Malay (`ms`) is not aliased to it either: the file registers itself as `id`.
const langAliases = new Map([
['be', 'by'],
['fil', 'ph'],
['my', null],
['nn', 'nb'],
['no', 'nb'],
['sr', 'rs'],
['sr_latn', 'rs_latin'],
['tl', 'ph'],
['uk', 'ua'],
['zh', 'zh_cn'],
['zh_hans', 'zh_cn'],
['zh_hant', 'zh_tw'],
['zh_hk', 'zh_tw'],
['zh_mo', 'zh_tw'],
]);

// Returns the Trumbowyg language matching a Bolt locale, or null when Trumbowyg has no
// translation for it. Tries the whole locale first, then drops one part at a time:
// `sr_Latn_RS` -> `sr_latn` -> `sr`, `de_AT` -> `de`.
function findLang(locale) {
const parts = locale.toLowerCase().replace(/-/g, '_').split('_');

for (let length = parts.length; length > 0; length--) {
const candidate = parts.slice(0, length).join('_');
const lang = langAliases.has(candidate) ? langAliases.get(candidate) : candidate;

if (lang && langFiles.keys().includes(`./${lang}.min.js`)) {
return lang;
}
}

return null;
}

// Loads the Trumbowyg language matching a Bolt locale and resolves to its code, or to
// 'en' when there is no translation or it fails to load.
async function loadLang(locale) {
const lang = findLang(locale);

if (!lang) {
return 'en';
}

try {
await langFiles(`./${lang}.min.js`);
} catch {
return 'en';
}

// A file that registers itself under another name would leave Trumbowyg without
// the language it was told to use.
if (!jQuery.trumbowyg.langs[lang]) {
return 'en';
}

// Arabic, Hebrew and Persian also set `_dir: 'rtl'`, which Trumbowyg applies to the
// editable area. The text direction belongs to the content being edited, not to the
// backend language, so only the labels are used.
delete jQuery.trumbowyg.langs[lang]._dir;

return lang;
}

export default {
name: 'EditorHtml',
components: {
Expand All @@ -19,9 +97,14 @@ export default {
label: String,
name: String,
id: String,
locale: {
type: String,
default: 'en',
},
},
data: () => {
return {
ready: false,
val: null,
config: {
btns: [
Expand All @@ -40,6 +123,12 @@ export default {
},
};
},
async created() {
// Trumbowyg reads its language when it initialises, so it is only rendered once
// the language is set.
this.config.lang = await loadLang(this.locale);
this.ready = true;
},
mounted() {
this.val = strip(this.value);
},
Expand Down
1 change: 1 addition & 0 deletions templates/_partials/fields/html.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
:value="{{ value|json_encode }}"
:name='{{ name|json_encode }}'
:id='{{ id|json_encode}}'
:locale='{{ app.request.locale|json_encode }}'
></editor-html>
{% endblock %}
112 changes: 111 additions & 1 deletion tests/unit/components/Html.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it } from 'vitest';
import jQuery from 'jquery';
import { describe, expect, it, vi } from 'vitest';
import Html from '@/editor/Components/Html.vue';
import { trumbowygStub } from '../helpers/stubs';
import { contextLoader } from '../helpers/webpack-context';

const defaultProps = {
value: 'Some <strong>html</strong>',
Expand Down Expand Up @@ -68,4 +70,112 @@ describe('EditorHtml', () => {
expect(config.btns).toContainEqual(['viewHTML']);
expect(config.btns.flat()).not.toContain('undo');
});

describe('toolbar language', () => {
type TrumbowygLangs = Record<string, Record<string, string> | undefined>;
const langs = () => (jQuery as unknown as { trumbowyg: { langs: TrumbowygLangs } }).trumbowyg.langs;

// Trumbowyg is only rendered once its language is known, and loading one
// is a dynamic import, so wait for the editor rather than for a tick.
const mountReady = async (props: Record<string, unknown> = {}) => {
const wrapper = mountHtml(props);
await vi.waitFor(() => expect(editor(wrapper).exists()).toBe(true));

return wrapper;
};

const lang = (wrapper: ReturnType<typeof mount>) => (editor(wrapper).props('config') as { lang: string }).lang;

it('stays in English without a locale, and loads nothing', async () => {
const load = vi.spyOn(contextLoader, 'load');
const wrapper = await mountReady();

expect(lang(wrapper)).toBe('en');
expect(load).not.toHaveBeenCalled();
});

it('loads the translation matching the locale', async () => {
const load = vi.spyOn(contextLoader, 'load');
const wrapper = await mountReady({ locale: 'cs' });

expect(lang(wrapper)).toBe('cs');
expect(load).toHaveBeenCalledOnce();
expect(load.mock.calls[0][0]).toBe('./cs.min.js');
expect(langs().cs?.viewHTML).toBe('Zobrazit HTML');
});

it('renders the editor only once the language is loaded', async () => {
const wrapper = mountHtml({ locale: 'cs' });
await nextTick();

expect(editor(wrapper).exists()).toBe(false);

await vi.waitFor(() => expect(editor(wrapper).exists()).toBe(true));
expect(lang(wrapper)).toBe('cs');
});

it.each([
['de_AT', 'de'],
['pt_BR', 'pt_br'],
['pt-BR', 'pt_br'],
['es_MX', 'es'],
['sr_Latn_RS', 'rs_latin'],
['sr_Cyrl_RS', 'rs'],
])('falls back from %s to the closest translation, %s', async (locale, expected) => {
const wrapper = await mountReady({ locale });

expect(lang(wrapper)).toBe(expected);
});

it.each([
['uk', 'ua'],
['sr', 'rs'],
['be', 'by'],
['fil', 'ph'],
['tl', 'ph'],
['no', 'nb'],
['nn', 'nb'],
['zh', 'zh_cn'],
['zh_Hans', 'zh_cn'],
['zh_Hant', 'zh_tw'],
['zh_HK', 'zh_tw'],
['zh_MO', 'zh_tw'],
])('maps %s to the Trumbowyg name %s', async (locale, expected) => {
const wrapper = await mountReady({ locale });

expect(lang(wrapper)).toBe(expected);
});

// Trumbowyg's `my` file is meant to be Malay, while ISO `my` is Burmese,
// and the file registers itself as `id`, so Malay has no usable file either.
it.each(['my', 'my_MM', 'ms', 'xx', 'en_GB'])('stays in English for %s, and loads nothing', async locale => {
const load = vi.spyOn(contextLoader, 'load');
const wrapper = await mountReady({ locale });

expect(lang(wrapper)).toBe('en');
expect(load).not.toHaveBeenCalled();
});

it('stays in English when the translation fails to load', async () => {
vi.spyOn(contextLoader, 'load').mockRejectedValueOnce(new Error('Loading chunk failed'));
const wrapper = await mountReady({ locale: 'fr' });

expect(lang(wrapper)).toBe('en');
});

it('stays in English when the translation registers no language under its name', async () => {
vi.spyOn(contextLoader, 'load').mockResolvedValueOnce({});
const wrapper = await mountReady({ locale: 'vi' });

expect(lang(wrapper)).toBe('en');
});

it.each(['ar', 'he', 'fa'])('uses the %s labels but keeps the editing direction', async locale => {
const wrapper = await mountReady({ locale });

expect(lang(wrapper)).toBe(locale);
expect(langs()[locale]?.viewHTML).toBeTruthy();
expect(langs()[locale]).not.toHaveProperty('_dir');
});
});
});
56 changes: 56 additions & 0 deletions tests/unit/helpers/webpack-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* `import.meta.webpackContext()` for the test run.
*
* Webpack replaces the call at build time with a context over the matching
* files. Vite has no equivalent, so the `webpack-context` plugin in
* vitest.config.ts rewrites the call to this function, installed by setup.ts as
* `globalThis.__webpackContext`. The plugin lists the directory in place of the
* path it was given, as a map of `./<file>` to a lazy import of that file.
*
* Only the form the app uses is supported: a non-recursive, lazy context. Its
* keys are `./<file>`, as webpack's are, and calling it with one imports that
* file and resolves to the module.
*/

export type ContextFiles = Record<string, () => Promise<unknown>>;

export type WebpackContextOptions = {
recursive?: boolean;
regExp?: RegExp;
mode?: string;
chunkName?: string;
};

export type LazyWebpackContext = {
(request: string): Promise<unknown>;
keys(): string[];
};

/**
* Imports one file of a context. A spec spies on `load` to see which file was
* requested, or to make it fail to load, as a chunk can in the browser.
*/
export const contextLoader = {
load: (request: string, importFile: () => Promise<unknown>): Promise<unknown> => importFile(),
};

export function webpackContext(files: ContextFiles, options: WebpackContextOptions = {}): LazyWebpackContext {
const { recursive = true, regExp = /^\.\/.*$/, mode = 'sync' } = options;

if (recursive || mode !== 'lazy') {
throw new Error('Only non-recursive, lazy webpack contexts are supported in the unit tests.');
}

const keys = Object.keys(files).filter(key => regExp.test(key));

const context = (request: string): Promise<unknown> => {
if (!keys.includes(request)) {
return Promise.reject(new Error(`Cannot find module '${request}'`));
}

return contextLoader.load(request, files[request]);
};
context.keys = () => [...keys];

return context;
}
6 changes: 6 additions & 0 deletions tests/unit/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import $ from 'jquery';
import { afterEach, beforeEach, expect, vi } from 'vitest';
import { resetDom } from './helpers/dom';
import { installWarningHandler, resetWarnings, reviewWarnings } from './helpers/warnings';
import { webpackContext } from './helpers/webpack-context';

Vue.config.productionTip = false;
Vue.config.devtools = false;
Expand All @@ -14,6 +15,11 @@ Vue.config.devtools = false;
(window as unknown as Record<string, unknown>).$ = $;
(window as unknown as Record<string, unknown>).jQuery = $;

// The target vitest.config.ts rewrites `import.meta.webpackContext` to. Html.vue
// calls it when the module is first imported, so it has to exist before any spec
// imports a component.
(globalThis as unknown as Record<string, unknown>).__webpackContext = webpackContext;

// Date.vue's updated() hook calls both on the flatpickr input. jsdom's
// constraint-validation support is partial, so make sure they exist.
if (!HTMLInputElement.prototype.reportValidity) {
Expand Down
34 changes: 33 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,44 @@
import { defineConfig } from 'vitest/config';
import vue2 from '@vitejs/plugin-vue2';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const root = path.dirname(fileURLToPath(import.meta.url));

export default defineConfig({
plugins: [vue2()],
plugins: [
vue2(),
// Html.vue builds its Trumbowyg language context with webpack's
// `import.meta.webpackContext()`, which Vite does not implement. Route
// the call to the stand-in in tests/unit/helpers/webpack-context.ts,
// which setup.ts installs, and replace the node_modules path it was
// given with a lazy import of every file in that directory.
{
name: 'webpack-context',
transform(code) {
if (!code.includes('import.meta.webpackContext(')) {
return null;
}

const rewritten = code.replace(
/import\.meta\.webpackContext\(\s*(['"])(.+?)\1/g,
(_match, _quote, request: string) => {
const directory = path.resolve(root, 'node_modules', request);
const files = fs
.readdirSync(directory)
.map(file => `'./${file}': () => import(${JSON.stringify(path.join(directory, file))})`);

return `globalThis.__webpackContext({ ${files.join(', ')} }`;
},
);

// The call is rewritten in place on a single line, so line
// numbers, and with them coverage, are unaffected.
return { code: rewritten, map: null };
},
},
],
resolve: {
// Components import each other without the extension (`from './_SubMenu'`),
// which webpack resolves via its own `resolve.extensions`. Vite does not
Expand Down
Loading