Skip to content

Commit

Permalink
feat: I18n (outline#1653)
Browse files Browse the repository at this point in the history
* feat: i18n

* Changing language single source of truth from TEAM to USER

* Changes according to @tommoor comments on PR

* Changed package.json for build:i18n and translation label

* Finished 1st MVP of i18n for outline

* new translation labels & Portuguese from Portugal translation

* Fixes from PR request

* Described language dropdown as an experimental feature

* Set keySeparator to false in order to cowork with html keys

* Added useTranslation to Breadcrumb

* Repositioned <strong> element

* Removed extra space from TemplatesMenu

* Fortified the test suite for i18n

* Fixed trans component problematic

* Check if selected language is available

* Update yarn.lock

* Removed unused Trans

* Removing debug variable from i18n init

* Removed debug variable

* test: update snapshots

* flow: Remove decorator usage to get proper flow typing
It's a shame, but hopefully we'll move to Typescript in the next 6 months and we can forget this whole Flow mistake ever happened

* translate: Drafts

* More translatable strings

* Mo translation strings

* translation: Search

* async translations loading

* cache translations in client

* Revert "cache translations in client"

This reverts commit 08fb61c.

* Revert localStorage cache for cache headers

* Update Crowdin configuration file

* Moved translation files to locales folder and fixed english text

* Added CONTRIBUTING File for CrowdIn

* chore: Move translations again to please CrowdIn

* fix: loading paths
chore: Add strings for editor

* fix: Improve validation on documents.import endpoint

* test: mock bull

* fix: Unknown mimetype should fallback to Markdown parsing if markdown extension (outline#1678)

* closes outline#1675

* Update CONTRIBUTING

* chore: Add link to translation portal from app UI

* refactor: Centralize language config

* fix: Ensure creation of i18n directory in build

* feat: Add language prompt

* chore: Improve contributing guidelines, add link from README

* chore: Normalize tab header casing

* chore: More string externalization

* fix: Language prompt in dark mode

Co-authored-by: André Glatzl <[email protected]>
  • Loading branch information
tommoor and glaand authored Nov 30, 2020
1 parent 63c73c9 commit 1285efc
Show file tree
Hide file tree
Showing 85 changed files with 6,433 additions and 2,614 deletions.
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,5 @@ SMTP_REPLY_EMAIL=

# Custom logo that displays on the authentication screen, scaled to height: 60px
# TEAM_LOGO=https://example.com/images/logo.png

DEFAULT_LANGUAGE=en_US
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ However, before working on a pull request please let the core team know by creat

If you’re looking for ways to get started, here's a list of ways to help us improve Outline:

* [Translation](TRANSLATION.md) into other languages
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
* Performance improvements, both on server and frontend
* Developer happiness and documentation
Expand Down
34 changes: 34 additions & 0 deletions TRANSLATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Translation

Outline is localized through community contributions. The text in Outline's user interface is in American English by default, we're very thankful for all help that the community provides bringing the app to different languages.

## Externalizing strings

Before a string can be translated, it must be externalized. This is the process where English strings in the source code are wrapped in a function that retrieves the translated string for the user’s language.

For externalization we use [react-i18next](https://react.i18next.com/), this provides the hooks [useTranslation](https://react.i18next.com/latest/usetranslation-hook) and the [Trans](https://react.i18next.com/latest/trans-component) component for wrapping English text.

PR's are accepted for wrapping English strings in the codebase that were not previously externalized.

## Translating strings

To manage the translation process we use [CrowdIn](https://translate.getoutline.com/), it keeps track of which strings in which languages still need translating, synchronizes with the codebase automatically, and provides a great editor interface.

You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:

1. Select the language for which you want to contribute (or vote for) a translation (below the language you can see the progress of the translation)
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)

2. Please choose the translation.json file from your desired language

3. Once a file is selected, all the strings associated with the version are displayed on the left side. To display the untranslated strings first, select the filter icon next to the search bar and select “All, Untranslated First”.The red square next to an English string shows that a string has not been translated yet. To provide a translation, select a string on the left side, provide a translation in the target language in the text box in the right side (singular and plural) and press the save button. As soon as a translation has been provided by another user (green square next to string), you can also vote on a translation provided by another user. The translation with the most votes is used unless a different translation has been approved by a proof reader. ![Editor UI](https://i.imgur.com/pldZCRs.png)

## Proofreading

Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.

If you are interested in becoming a proof reader, please contact one of the project managers in the Outline CrowdIn project or contact [@tommoor](https://github.com/tommoor). Similarly, if your language is not listed in the list of CrowdIn languages, please contact our project managers or [send us an email](https://www.getoutline.com/contact) so we can add your language.

## Release

Updated translations are automatically PR'd against the codebase by a bot and will be merged regularly so that new translations appear in the next release of Outline.
26 changes: 19 additions & 7 deletions app/components/Authenticated.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import { isCustomSubdomain } from "shared/utils/domains";
import AuthStore from "stores/AuthStore";
import LoadingIndicator from "components/LoadingIndicator";
import useStores from "../hooks/useStores";
import env from "env";

type Props = {
auth: AuthStore,
children?: React.Node,
children: React.Node,
};

const Authenticated = observer(({ auth, children }: Props) => {
const Authenticated = ({ children }: Props) => {
const { auth } = useStores();
const { i18n } = useTranslation();
const language = auth.user && auth.user.language;

// Watching for language changes here as this is the earliest point we have
// the user available and means we can start loading translations faster
React.useEffect(() => {
if (i18n.language !== language) {
i18n.changeLanguage(language);
}
}, [i18n, language]);

if (auth.authenticated) {
const { user, team } = auth;
const { hostname } = window.location;
Expand Down Expand Up @@ -43,6 +55,6 @@ const Authenticated = observer(({ auth, children }: Props) => {

auth.logout(true);
return <Redirect to="/" />;
});
};

export default inject("auth")(Authenticated);
export default observer(Authenticated);
21 changes: 14 additions & 7 deletions app/components/Avatar/AvatarWithPresence.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { observable } from "mobx";
import { observer } from "mobx-react";
import { EditIcon } from "outline-icons";
import * as React from "react";
import { withTranslation, type TFunction } from "react-i18next";
import styled from "styled-components";
import User from "models/User";
import UserProfile from "scenes/UserProfile";
Expand All @@ -16,6 +17,7 @@ type Props = {
isEditing: boolean,
isCurrentUser: boolean,
lastViewedAt: string,
t: TFunction,
};

@observer
Expand All @@ -37,20 +39,25 @@ class AvatarWithPresence extends React.Component<Props> {
isPresent,
isEditing,
isCurrentUser,
t,
} = this.props;

const action = isPresent
? isEditing
? t("currently editing")
: t("currently viewing")
: t("viewed {{ timeAgo }} ago", {
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
});

return (
<>
<Tooltip
tooltip={
<Centered>
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
<br />
{isPresent
? isEditing
? "currently editing"
: "currently viewing"
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
{action}
</Centered>
}
placement="bottom"
Expand Down Expand Up @@ -83,4 +90,4 @@ const AvatarWrapper = styled.div`
transition: opacity 250ms ease-in-out;
`;

export default AvatarWithPresence;
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);
25 changes: 16 additions & 9 deletions app/components/Breadcrumb.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @flow
import { observer, inject } from "mobx-react";
import { observer } from "mobx-react";
import {
ArchiveIcon,
EditIcon,
Expand All @@ -10,6 +10,7 @@ import {
TrashIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
Expand All @@ -19,6 +20,7 @@ import Document from "models/Document";
import CollectionIcon from "components/CollectionIcon";
import Flex from "components/Flex";
import BreadcrumbMenu from "./BreadcrumbMenu";
import useStores from "hooks/useStores";
import { collectionUrl } from "utils/routeHelpers";

type Props = {
Expand All @@ -28,13 +30,15 @@ type Props = {
};

function Icon({ document }) {
const { t } = useTranslation();

if (document.isDeleted) {
return (
<>
<CollectionName to="/trash">
<TrashIcon color="currentColor" />
&nbsp;
<span>Trash</span>
<span>{t("Trash")}</span>
</CollectionName>
<Slash />
</>
Expand All @@ -46,7 +50,7 @@ function Icon({ document }) {
<CollectionName to="/archive">
<ArchiveIcon color="currentColor" />
&nbsp;
<span>Archive</span>
<span>{t("Archive")}</span>
</CollectionName>
<Slash />
</>
Expand All @@ -58,7 +62,7 @@ function Icon({ document }) {
<CollectionName to="/drafts">
<EditIcon color="currentColor" />
&nbsp;
<span>Drafts</span>
<span>{t("Drafts")}</span>
</CollectionName>
<Slash />
</>
Expand All @@ -70,7 +74,7 @@ function Icon({ document }) {
<CollectionName to="/templates">
<ShapesIcon color="currentColor" />
&nbsp;
<span>Templates</span>
<span>{t("Templates")}</span>
</CollectionName>
<Slash />
</>
Expand All @@ -79,14 +83,17 @@ function Icon({ document }) {
return null;
}

const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
const Breadcrumb = ({ document, onlyText }: Props) => {
const { collections } = useStores();
const { t } = useTranslation();

let collection = collections.get(document.collectionId);
if (!collection) {
if (!document.deletedAt) return <div />;

collection = {
id: document.collectionId,
name: "Deleted Collection",
name: t("Deleted Collection"),
color: "currentColor",
};
}
Expand Down Expand Up @@ -141,7 +148,7 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
)}
</Wrapper>
);
});
};

const Wrapper = styled(Flex)`
display: none;
Expand Down Expand Up @@ -202,4 +209,4 @@ const CollectionName = styled(Link)`
overflow: hidden;
`;

export default inject("collections")(Breadcrumb);
export default observer(Breadcrumb);
34 changes: 16 additions & 18 deletions app/components/DocumentMeta.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
// @flow
import { inject, observer } from "mobx-react";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import AuthStore from "stores/AuthStore";
import CollectionsStore from "stores/CollectionsStore";
import Document from "models/Document";
import Breadcrumb from "components/Breadcrumb";
import Flex from "components/Flex";
import Time from "components/Time";
import useStores from "hooks/useStores";

const Container = styled(Flex)`
color: ${(props) => props.theme.textTertiary};
Expand All @@ -23,8 +23,6 @@ const Modified = styled.span`
`;

type Props = {
collections: CollectionsStore,
auth: AuthStore,
showCollection?: boolean,
showPublished?: boolean,
showLastViewed?: boolean,
Expand All @@ -34,8 +32,6 @@ type Props = {
};

function DocumentMeta({
auth,
collections,
showPublished,
showCollection,
showLastViewed,
Expand All @@ -44,6 +40,8 @@ function DocumentMeta({
to,
...rest
}: Props) {
const { t } = useTranslation();
const { collections, auth } = useStores();
const {
modifiedSinceViewed,
updatedAt,
Expand All @@ -67,37 +65,37 @@ function DocumentMeta({
if (deletedAt) {
content = (
<span>
deleted <Time dateTime={deletedAt} addSuffix />
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
</span>
);
} else if (archivedAt) {
content = (
<span>
archived <Time dateTime={archivedAt} addSuffix />
{t("archived")} <Time dateTime={archivedAt} addSuffix />
</span>
);
} else if (createdAt === updatedAt) {
content = (
<span>
created <Time dateTime={updatedAt} addSuffix />
{t("created")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
content = (
<span>
published <Time dateTime={publishedAt} addSuffix />
{t("published")} <Time dateTime={publishedAt} addSuffix />
</span>
);
} else if (isDraft) {
content = (
<span>
saved <Time dateTime={updatedAt} addSuffix />
{t("saved")} <Time dateTime={updatedAt} addSuffix />
</span>
);
} else {
content = (
<Modified highlight={modifiedSinceViewed}>
updated <Time dateTime={updatedAt} addSuffix />
{t("updated")} <Time dateTime={updatedAt} addSuffix />
</Modified>
);
}
Expand All @@ -112,25 +110,25 @@ function DocumentMeta({
if (!lastViewedAt) {
return (
<>
•&nbsp;<Modified highlight>Never viewed</Modified>
•&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
</>
);
}

return (
<span>
•&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
•&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
</span>
);
};

return (
<Container align="center" {...rest}>
{updatedByMe ? "You" : updatedBy.name}&nbsp;
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
{to ? <Link to={to}>{content}</Link> : content}
{showCollection && collection && (
<span>
&nbsp;in&nbsp;
&nbsp;{t("in")}&nbsp;
<strong>
<Breadcrumb document={document} onlyText />
</strong>
Expand All @@ -142,4 +140,4 @@ function DocumentMeta({
);
}

export default inject("collections", "auth")(observer(DocumentMeta));
export default observer(DocumentMeta);
Loading

0 comments on commit 1285efc

Please sign in to comment.