From 2fe403ed9a8d6d8f2bf336f6cc3902a09853c443 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Tue, 5 Feb 2019 17:59:22 +0100 Subject: [PATCH] See #662: documentation about i18n / contexts, and first contextualized strings --- CONTRIBUTING.rst | 156 +++++++++++++++++++++- front/src/components/audio/PlayButton.vue | 32 +++-- front/src/components/auth/Signup.vue | 25 ++-- 3 files changed, 185 insertions(+), 28 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 19f034b9fb..35a0ebddf3 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -353,12 +353,160 @@ Internationalization -------------------- We're using https://github.com/Polyconseil/vue-gettext to manage i18n in the project. -When working on the front-end, any end-user string should be translated -using either ``<translate>yourstring</translate>`` or ``$gettext('yourstring')`` -function. +When working on the front-end, any end-user string should be marked as a translatable string, +with the proper context, as described below. + +Translations in HTML +^^^^^^^^^^^^^^^^^^^^ + +Translations in HTML use the ``<translate>`` tag:: + + <template> + <div> + <h1><translate :translate-context="'Content/Profile/Header'">User profile</translate></h1> + <p> + <translate + :translate-context="'Content/Profile/Paragraph'" + :translate-params="{username: 'alice'}"> + You are logged in as %{ username } + </translate> + </p> + <p> + <translate + :translate-context="'Content/Profile/Paragraph'" + translate-plural="You have %{ count } new messages, that's a lot!" + :translate-n="unreadMessagesCount" + :translate-params="{count: unreadMessagesCount}"> + You have 1 new message + </translate> + </p> + </div> + </template> + +Anything between the `<translate>` and `</translate>` delimiters will be considered as a translatable string. +You can use variables in the translated string via the ``:translate-params="{var: 'value'}"`` directive, and reference them like this: +``val value is %{ value }``. + +For pluralization, you need to use ``translate-params`` in conjunction with ``translate-plural`` and ``translate-n``: + +- ``translate-params`` should contain the variable you're using for pluralization (which is usually shown to the user) +- ``translate-n`` should match the same variable +- The ``<translate>`` delimiters contain the non-pluralized version of your string +- The ``translate-plural`` directive contains the pluralized version of your string + + +Translations in javascript +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Translations in javascript work by calling the ``this.$*gettext`` functions:: + + export default { + computed: { + strings () { + let tracksCount = 42 + let playButton = this.$pgettext('Sidebar/Player/Button/Verb, Short', 'Play') + let loginMessage = this.$pgettext('*/Login/Message', 'Welcome back %{ username }') + let addedMessage = this.$npgettext('*/Player/Message', 'One track was queued', '%{ count } tracks were queued', tracksCount) + console.log(this.$gettextInterpolate(addedMessage, {count: tracksCount})) + console.log(this.$gettextInterpolate(loginMessage, {username: 'alice'})) + } + } + } + +The first argument of the ``$pgettext`` and ``$npgettext`` functions is the string context. + +Contextualization +^^^^^^^^^^^^^^^^^ + +Translation contexts provided via the ``translate-context`` directive and the ``$pgettext`` and ``$npgettext`` are never shown to end users +but visible by Funkwhale translators. They help translators where and how the strings are used, +especially with short or ambiguous strings, like ``May``, which can refer a month or a verb. + +While we could in theory use free form context, like ``This string is inside a button, in the main page, and is a call to action``, +Funkwhale use a hierarchical structure to write contexts and keep them short and consistents accross the app. The previous context, +rewritten correctly would be: ``Content/Home/Button/Call to action``. + +This hierarchical structure is made of several parts: + +- The location part, which is required and refers to the big blocks found in Funkwhale UI where the translated string is displayed: + - ``Content`` + - ``Footer`` + - ``Menu`` + - ``Modal`` + - ``Sidebar`` + - ``*`` for strings that are not tied to a specific location + +- The feature part, which is required, and refers to the feature associated with the translated string: + - ``About`` + - ``Admin`` + - ``Album`` + - ``Artist`` + - ``Home`` + - ``Login`` + - ``Moderation`` + - ``Player`` + - ``Playlist`` + - ``Notifications`` + - ``Radio`` + - ``Settings`` + - ``Signup`` + - ``Track`` + - ``Queue`` + - ``*`` for strings that are not tied to a specific feature + +- The component part, which is required and refers to the type of element that contain the string: + - ``Button`` + - ``Card`` + - ``Dropdown`` + - ``Form`` + - ``Header`` + - ``Help text`` + - ``Icon`` + - ``Input`` + - ``Image`` + - ``Label`` + - ``Link`` + - ``List item`` + - ``Message`` + - ``Paragraph`` + - ``Placeholder`` + - ``Tab`` + - ``Table`` + - ``Title`` + - ``Tooltip`` + - ``*`` for strings that are not tied to a specific component + +The detail part, which is optional and refers to the contents of the string itself, such as: + - ``Call to action`` + - ``Verb`` + - ``Short`` + +Here are a few examples of valid context hierarchies: + +- ``Sidebar/Player/Button/Title`` +- ``Content/Home/Button/Call to action`` +- ``Footer/*/Help text`` +- ``*/*/*/Verb, Short`` +- ``Modal/Playlist/Button`` + +It's possible to nest multiple component parts to reach a higher level of detail: + +- ``Sidebar/Queue/Tab/Title`` +- ``Content/*/Button/Title`` +- ``Content/*/Table/Header`` +- ``Footer/*/List item/Link`` +- ``Content/*/Form/Help text`` + +Collecting translatable strings +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +If you want to ensure your translatable strings are correctly marked for translation, +you can try to extract them. Extraction is done by calling ``yarn run i18n-extract``, which -will pull all the strings from source files and put them in a PO file. +will pull all the strings from source files and put them in a PO files. + +You can then inspect the PO files to ensure everything is fine (but don't commit them, it's not needed). Contributing to the API ----------------------- diff --git a/front/src/components/audio/PlayButton.vue b/front/src/components/audio/PlayButton.vue index 07cb1f585a..425d0a1d73 100644 --- a/front/src/components/audio/PlayButton.vue +++ b/front/src/components/audio/PlayButton.vue @@ -7,15 +7,23 @@ :disabled="!playable" :class="buttonClasses.concat(['ui', {loading: isLoading}, {'mini': discrete}, {disabled: !playable}])"> <i :class="[playIconClass, 'icon']"></i> - <template v-if="!discrete && !iconOnly"><slot><translate>Play</translate></slot></template> + <template v-if="!discrete && !iconOnly"><slot><translate :v-context="'*/Queue/Button/Label/Short, Verb'">Play</translate></slot></template> </button> <div v-if="!discrete && !iconOnly" :class="['ui', {disabled: !playable}, 'floating', 'dropdown', {'icon': !dropdownOnly}, {'button': !dropdownOnly}]"> <i :class="dropdownIconClasses.concat(['icon'])"></i> <div class="menu"> - <button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"><i class="plus icon"></i><translate>Add to queue</translate></button> - <button class="item basic" ref="addNext" data-ref="addNext" :disabled="!playable" @click.stop.prevent="addNext()" :title="labels.playNext"><i class="step forward icon"></i><translate>Play next</translate></button> - <button class="item basic" ref="playNow" data-ref="playNow" :disabled="!playable" @click.stop.prevent="addNext(true)" :title="labels.playNow"><i class="play icon"></i><translate>Play now</translate></button> - <button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio"><i class="feed icon"></i><translate>Start radio</translate></button> + <button class="item basic" ref="add" data-ref="add" :disabled="!playable" @click.stop.prevent="add" :title="labels.addToQueue"> + <i class="plus icon"></i><translate :v-context="'*/Queue/Dropdown/Button/Label/Short'">Add to queue</translate> + </button> + <button class="item basic" ref="addNext" data-ref="addNext" :disabled="!playable" @click.stop.prevent="addNext()" :title="labels.playNext"> + <i class="step forward icon"></i><translate :v-context="'*/Queue/Dropdown/Button/Label/Short'">Play next</translate> + </button> + <button class="item basic" ref="playNow" data-ref="playNow" :disabled="!playable" @click.stop.prevent="addNext(true)" :title="labels.playNow"> + <i class="play icon"></i><translate :v-context="'*/Queue/Dropdown/Button/Label/Short'">Play now</translate> + </button> + <button v-if="track" class="item basic" :disabled="!playable" @click.stop.prevent="$store.dispatch('radios/start', {type: 'similar', objectId: track.id})" :title="labels.startRadio"> + <i class="feed icon"></i><translate :v-context="'*/Queue/Dropdown/Button/Label/Short'">Start radio</translate> + </button> </div> </div> </span> @@ -61,18 +69,18 @@ export default { computed: { labels () { return { - playNow: this.$gettext('Play now'), - addToQueue: this.$gettext('Add to current queue'), - playNext: this.$gettext('Play next'), - startRadio: this.$gettext('Play similar songs') + playNow: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play now'), + addToQueue: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Add to current queue'), + playNext: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play next'), + startRadio: this.$pgettext('*/Queue/Dropdown/Button/Title', 'Play similar songs') } }, title () { if (this.playable) { - return this.$gettext('Play...') + return this.$pgettext('*/Queue/Button/Title', 'Play...') } else { if (this.track) { - return this.$gettext('This track is not available in any library you have access to') + return this.$pgettext('*/Queue/Button/Title', 'This track is not available in any library you have access to') } } }, @@ -179,7 +187,7 @@ export default { if (tracks.length < 1) { return } - let msg = this.$ngettext('%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length) + let msg = this.$npgettext('*/Queue/Message', '%{ count } track was added to your queue', '%{ count } tracks were added to your queue', tracks.length) this.$store.commit('ui/addMessage', { content: this.$gettextInterpolate(msg, {count: tracks.length}), date: new Date() diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue index 815f0253a7..685be288db 100644 --- a/front/src/components/auth/Signup.vue +++ b/front/src/components/auth/Signup.vue @@ -2,22 +2,22 @@ <main class="main pusher" v-title="labels.title"> <section class="ui vertical stripe segment"> <div class="ui small text container"> - <h2><translate>Create a funkwhale account</translate></h2> + <h2><translate :v-context="'Content/Signup/Header'">Create a funkwhale account</translate></h2> <form :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']" @submit.prevent="submit()"> <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value"> - <translate>Registration are closed on this instance, you will need an invitation code to signup.</translate> + <translate :v-context="'Content/Signup/Form/Message'">Registration are closed on this instance, you will need an invitation code to signup.</translate> </p> <div v-if="errors.length > 0" class="ui negative message"> - <div class="header"><translate>We cannot create your account</translate></div> + <div class="header"><translate :v-context="'Content/Signup/Form/Message'">We cannot create your account</translate></div> <ul class="list"> <li v-for="error in errors">{{ error }}</li> </ul> </div> <div class="field"> - <label><translate>Username</translate></label> + <label><translate :v-context="'Content/Signup/Form/Label'">Username</translate></label> <input ref="username" name="username" @@ -28,7 +28,7 @@ v-model="username"> </div> <div class="field"> - <label><translate>Email</translate></label> + <label><translate :v-context="'Content/Signup/Form/Label'">Email</translate></label> <input ref="email" name="email" @@ -38,11 +38,11 @@ v-model="email"> </div> <div class="field"> - <label><translate>Password</translate></label> + <label><translate :v-context="'Content/Signup/Form/Label'">Password</translate></label> <password-input v-model="password" /> </div> <div class="field" v-if="!$store.state.instance.settings.users.registration_enabled.value"> - <label><translate>Invitation code</translate></label> + <label><translate :v-context="'Content/Signup/Form/Label'">Invitation code</translate></label> <input required type="text" @@ -51,7 +51,7 @@ v-model="invitation"> </div> <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"> - <translate>Create my account</translate> + <translate :v-context="'Content/Signup/Form/Button'">Create my account</translate> </button> </form> </div> @@ -94,12 +94,13 @@ export default { }, computed: { labels() { - let title = this.$gettext("Sign Up") - let placeholder = this.$gettext( + let title = this.$pgettext("*/Signup/Title", "Sign Up") + let placeholder = this.$pgettext( + "Content/Signup/Form/Placeholder", "Enter your invitation code (case insensitive)" ) - let usernamePlaceholder = this.$gettext("Enter your username") - let emailPlaceholder = this.$gettext("Enter your email") + let usernamePlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your username") + let emailPlaceholder = this.$pgettext("Content/Signup/Form/Placeholder", "Enter your email") return { title, usernamePlaceholder, -- GitLab