Skip to content

Commit

Permalink
Add a polyfill for vue-i18n's useI18n() composable (FreeTubeApp#6042)
Browse files Browse the repository at this point in the history
* Add a polyfill for vue-i18n's useI18n() composable

* Address feedback

* JSDoc ESLint plugin requires each overload to be in a separate comment block
  • Loading branch information
absidue authored and Alban Dumas committed Jan 24, 2025
1 parent edbca29 commit 9fe235c
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 11 deletions.
11 changes: 11 additions & 0 deletions _scripts/eslint-rules/plugin.mjs
Original file line number Diff line number Diff line change
@@ -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
}
}
62 changes: 62 additions & 0 deletions _scripts/eslint-rules/use-i18n-polyfill-rule.mjs
Original file line number Diff line number Diff line change
@@ -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}'`)
]
}
}
})
}
}
}
}
}
6 changes: 5 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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' }

Expand Down Expand Up @@ -42,6 +43,7 @@ export default [
plugins: {
unicorn: eslintPluginUnicorn,
jsdoc,
freetube,
},

languageOptions: {
Expand Down Expand Up @@ -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',
},
},

Expand Down Expand Up @@ -220,7 +224,7 @@ export default [
}
},
{
files: ['_scripts/*.mjs'],
files: ['_scripts/**/*.mjs'],
languageOptions: {
globals: {
...globals.node,
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}\"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -993,7 +994,7 @@ export default defineComponent({
events.dispatchEvent(new CustomEvent('localeChanged'))
}

watch(() => i18n.locale, setLocale)
watch(locale, setLocale)

// #endregion player locales

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions src/renderer/composables/use-i18n-polyfill.js
Original file line number Diff line number Diff line change
@@ -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)
}

0 comments on commit 9fe235c

Please sign in to comment.