Add a polyfill for vue-i18n's useI18n() composable (#6042)

* Add a polyfill for vue-i18n's useI18n() composable

* Address feedback

* JSDoc ESLint plugin requires each overload to be in a separate comment block
This commit is contained in:
absidue 2024-11-06 23:43:15 +01:00 committed by GitHub
parent 6d5296dd4c
commit c1d020d3df
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 184 additions and 11 deletions

View File

@ -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
}
}

View File

@ -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 === '<text>'
? 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}'`)
]
}
}
})
}
}
}
}
}

View File

@ -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,

View File

@ -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}\"",

View File

@ -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

View File

@ -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<string, unknown>} named
* @returns {string}
*/
/**
* @overload
* @param {string} key
* @param {Record<string, unknown>} named
* @param {number} plural
* @returns {string}
*/
/**
* @param {string} key
* @param {number | unknown[] | Record<string, unknown> | 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)
}