diff --git a/_scripts/eslint-rules/plugin.mjs b/_scripts/eslint-rules/plugin.mjs new file mode 100644 index 0000000000000..1baf7ddbe4a37 --- /dev/null +++ b/_scripts/eslint-rules/plugin.mjs @@ -0,0 +1,11 @@ +import useI18nPolyfillRule from './use-i18n-polyfill-rule.mjs' + +export default { + meta: { + name: 'eslint-plugin-freetube', + version: '1.0' + }, + rules: { + 'use-i18n-polyfill': useI18nPolyfillRule + } +} diff --git a/_scripts/eslint-rules/use-i18n-polyfill-rule.mjs b/_scripts/eslint-rules/use-i18n-polyfill-rule.mjs new file mode 100644 index 0000000000000..1d27f74f0f9d1 --- /dev/null +++ b/_scripts/eslint-rules/use-i18n-polyfill-rule.mjs @@ -0,0 +1,62 @@ +import { dirname, relative, resolve } from 'path' + +const polyfillPath = resolve(import.meta.dirname, '../../src/renderer/composables/use-i18n-polyfill') + +function getRelativePolyfillPath(filePath) { + const relativePath = relative(dirname(filePath), polyfillPath).replaceAll('\\', '/') + + if (relativePath[0] !== '.') { + return `./${relativePath}` + } + + return relativePath +} + +/** @type {import('eslint').Rule.RuleModule} */ +export default { + meta: { + type: 'problem', + fixable: 'code' + }, + create(context) { + return { + 'ImportDeclaration[source.value="vue-i18n"]'(node) { + const specifierIndex = node.specifiers.findIndex(specifier => specifier.type === 'ImportSpecifier' && specifier.imported.name === 'useI18n') + + if (specifierIndex !== -1) { + context.report({ + node: node.specifiers.length === 1 ? node : node.specifiers[specifierIndex], + message: "Please use FreeTube's useI18n polyfill, as vue-i18n's useI18n composable does not work when the vue-i18n is in legacy mode, which is needed for components using the Options API.", + fix: context.physicalFilename === '' + ? undefined + : (fixer) => { + const relativePath = getRelativePolyfillPath(context.physicalFilename) + + // If the import only imports `useI18n`, we can just update the source/from text + // Else we need to create a new import for `useI18n` and remove useI18n from the original one + if (node.specifiers.length === 1) { + return fixer.replaceText(node.source, `'${relativePath}'`) + } else { + const specifier = node.specifiers[specifierIndex] + + let specifierText = 'useI18n' + + if (specifier.imported.name !== specifier.local.name) { + specifierText += ` as ${specifier.local.name}` + } + + return [ + fixer.removeRange([ + specifierIndex === 0 ? specifier.start : node.specifiers[specifierIndex - 1].end, + specifierIndex === node.specifiers.length - 1 ? specifier.end : node.specifiers[specifierIndex + 1].start + ]), + fixer.insertTextAfter(node, `\nimport { ${specifierText} } from '${relativePath}'`) + ] + } + } + }) + } + } + } + } +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 29a7e2ecc4a96..b2c2286ea3183 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,7 @@ import eslintPluginYml from 'eslint-plugin-yml' import yamlEslintParser from 'yaml-eslint-parser' import neostandard from 'neostandard' import jsdoc from 'eslint-plugin-jsdoc' +import freetube from './_scripts/eslint-rules/plugin.mjs' import activeLocales from './static/locales/activeLocales.json' with { type: 'json' } @@ -42,6 +43,7 @@ export default [ plugins: { unicorn: eslintPluginUnicorn, jsdoc, + freetube, }, languageOptions: { @@ -126,6 +128,8 @@ export default [ 'jsdoc/check-types': 'error', 'jsdoc/no-bad-blocks': 'error', 'jsdoc/no-multi-asterisks': 'error', + + 'freetube/use-i18n-polyfill': 'error', }, }, @@ -220,7 +224,7 @@ export default [ } }, { - files: ['_scripts/*.mjs'], + files: ['_scripts/**/*.mjs'], languageOptions: { globals: { ...globals.node, diff --git a/package.json b/package.json index 5c41d0d72ba98..3c9890f5712a1 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "lint-all": "run-p lint lint-json", "lint": "run-p eslint-lint lint-style", "lint-fix": "run-p eslint-lint-fix lint-style-fix", - "eslint-lint": "eslint --config eslint.config.mjs \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"./_scripts/*.mjs\"", - "eslint-lint-fix": "eslint --config eslint.config.mjs --fix \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"./_scripts/*.mjs\"", + "eslint-lint": "eslint --config eslint.config.mjs \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"_scripts/**/*.mjs\"", + "eslint-lint-fix": "eslint --config eslint.config.mjs --fix \"./src/**/*.js\" \"./src/**/*.vue\" \"./static/**/*.js\" \"./_scripts/*.js\" \"_scripts/**/*.mjs\"", "lint-json": "eslint --config eslint.config.mjs \"./static/**/*.json\"", "lint-style": "stylelint \"**/*.{css,scss}\"", "lint-style-fix": "stylelint --fix \"**/*.{css,scss}\"", diff --git a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js index 316a4e66dcf78..45f26b48cf92a 100644 --- a/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js +++ b/src/renderer/components/ft-shaka-video-player/ft-shaka-video-player.js @@ -3,10 +3,9 @@ import path from 'path' import { computed, defineComponent, onBeforeUnmount, onMounted, reactive, ref, shallowRef, watch } from 'vue' import shaka from 'shaka-player' +import { useI18n } from '../../composables/use-i18n-polyfill' import store from '../../store/index' -import i18n from '../../i18n/index' - import { IpcChannels } from '../../../constants' import { AudioTrackSelection } from './player-components/AudioTrackSelection' import { FullWindowButton } from './player-components/FullWindowButton' @@ -115,6 +114,8 @@ export default defineComponent({ 'toggle-theatre-mode' ], setup: function (props, { emit, expose }) { + const { locale, t } = useI18n() + /** @type {shaka.Player|null} */ let player = null @@ -993,7 +994,7 @@ export default defineComponent({ events.dispatchEvent(new CustomEvent('localeChanged')) } - watch(() => i18n.locale, setLocale) + watch(locale, setLocale) // #endregion player locales @@ -1502,7 +1503,7 @@ export default defineComponent({ }) } catch (err) { console.error(`Parse failed: ${err.message}`) - showToast(i18n.t('Screenshot Error', { error: err.message })) + showToast(t('Screenshot Error', { error: err.message })) canvas.remove() return } @@ -1567,7 +1568,7 @@ export default defineComponent({ await fs.mkdir(dirPath, { recursive: true }) } catch (err) { console.error(err) - showToast(i18n.t('Screenshot Error', { error: err })) + showToast(t('Screenshot Error', { error: err })) canvas.remove() return } @@ -1581,11 +1582,11 @@ export default defineComponent({ fs.writeFile(filePath, arr) .then(() => { - showToast(i18n.t('Screenshot Success', { filePath })) + showToast(t('Screenshot Success', { filePath })) }) .catch((err) => { console.error(err) - showToast(i18n.t('Screenshot Error', { error: err })) + showToast(t('Screenshot Error', { error: err })) }) }) }, mimeType, imageQuality) @@ -2319,7 +2320,7 @@ export default defineComponent({ player.getNetworkingEngine().registerResponseFilter(responseFilter) } - await setLocale(i18n.locale) + await setLocale(locale.value) // check if the component is already getting destroyed // which is possible because this function runs asynchronously diff --git a/src/renderer/composables/use-i18n-polyfill.js b/src/renderer/composables/use-i18n-polyfill.js new file mode 100644 index 0000000000000..a8b7fa871dc82 --- /dev/null +++ b/src/renderer/composables/use-i18n-polyfill.js @@ -0,0 +1,95 @@ +/* eslint-disable @intlify/vue-i18n/no-dynamic-keys */ +import { computed } from 'vue' + +import i18n from '../i18n/index' + +/** + * Polyfill for vue-i18n's useI18n composable, as it is not available in Vue 2 + * and doesn't work when vue-i18n 9+ (used for Vue 3) is set to `legacy: true`, + * which is needed for Options API components. + * + * Yes, vue-i18n 9 has an `allowComposition` option, + * but it comes with limitations that this polyfill doesn't have and was removed in vue-i18n 10. + * + * @see https://vue-i18n.intlify.dev/guide/migration/vue3#limitations + * @see https://vue-i18n.intlify.dev/guide/migration/breaking10.html#drop-allowcomposition-option + */ +export function useI18n() { + const locale = computed({ + get() { + return i18n.locale + }, + set(locale) { + i18n.locale = locale + } + }) + + return { + locale, + t + } +} + +/** + * @overload + * @param {string} key + * @returns {string} + */ + +/** + * @overload + * @param {string} key + * @param {number} plural + * @returns {string} + */ + +/** + * @overload + * @param {string} key + * @param {unknown[]} list + * @returns {string} + */ + +/** + * @overload + * @param {string} key + * @param {unknown[]} list + * @param {number} plural + * @returns {string} + */ + +/** + * @overload + * @param {string} key + * @param {Record} named + * @returns {string} + */ + +/** + * @overload + * @param {string} key + * @param {Record} named + * @param {number} plural + * @returns {string} + */ + +/** + * @param {string} key + * @param {number | unknown[] | Record | undefined} arg1 + * @param {number | undefined} arg2 + * @returns {string} + */ +function t(key, arg1, arg2) { + // Remove these lines in the Vue 3 migration and pass all args to the `.t()` call + if (typeof arg1 === 'number') { + return i18n.tc(key, arg1) + } else if (typeof arg2 === 'number') { + return i18n.tc(key, arg2, arg1) + } + + if (arg1 != null) { + return i18n.t(key, arg1) + } + + return i18n.t(key) +}