Skip to content

Commit 1285efc

Browse files
tommoorglaand
andauthored
feat: I18n (outline#1653)
* 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]>
1 parent 63c73c9 commit 1285efc

File tree

85 files changed

+6433
-2614
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

85 files changed

+6433
-2614
lines changed

.env.sample

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,5 @@ SMTP_REPLY_EMAIL=
6060

6161
# Custom logo that displays on the authentication screen, scaled to height: 60px
6262
# TEAM_LOGO=https://example.com/images/logo.png
63+
64+
DEFAULT_LANGUAGE=en_US

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ However, before working on a pull request please let the core team know by creat
164164

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

167+
* [Translation](TRANSLATION.md) into other languages
167168
* Issues with [`good first issue`](https://github.com/outline/outline/labels/good%20first%20issue) label
168169
* Performance improvements, both on server and frontend
169170
* Developer happiness and documentation

TRANSLATION.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Translation
2+
3+
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.
4+
5+
## Externalizing strings
6+
7+
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.
8+
9+
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.
10+
11+
PR's are accepted for wrapping English strings in the codebase that were not previously externalized.
12+
13+
## Translating strings
14+
15+
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.
16+
17+
You'll need to create a free account to use CrowdIn. Once you have joined, you can provide translations by following these steps:
18+
19+
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)
20+
![CrowdIn UI](https://i.imgur.com/AkbDY60.png)
21+
22+
2. Please choose the translation.json file from your desired language
23+
24+
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)
25+
26+
## Proofreading
27+
28+
Once a translation has been provided, a proof reader can approve the translation and mark it for use in Outline.
29+
30+
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.
31+
32+
## Release
33+
34+
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.

app/components/Authenticated.js

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
// @flow
2-
import { observer, inject } from "mobx-react";
2+
import { observer } from "mobx-react";
33
import * as React from "react";
4+
import { useTranslation } from "react-i18next";
45
import { Redirect } from "react-router-dom";
56
import { isCustomSubdomain } from "shared/utils/domains";
6-
import AuthStore from "stores/AuthStore";
77
import LoadingIndicator from "components/LoadingIndicator";
8+
import useStores from "../hooks/useStores";
89
import env from "env";
910

1011
type Props = {
11-
auth: AuthStore,
12-
children?: React.Node,
12+
children: React.Node,
1313
};
1414

15-
const Authenticated = observer(({ auth, children }: Props) => {
15+
const Authenticated = ({ children }: Props) => {
16+
const { auth } = useStores();
17+
const { i18n } = useTranslation();
18+
const language = auth.user && auth.user.language;
19+
20+
// Watching for language changes here as this is the earliest point we have
21+
// the user available and means we can start loading translations faster
22+
React.useEffect(() => {
23+
if (i18n.language !== language) {
24+
i18n.changeLanguage(language);
25+
}
26+
}, [i18n, language]);
27+
1628
if (auth.authenticated) {
1729
const { user, team } = auth;
1830
const { hostname } = window.location;
@@ -43,6 +55,6 @@ const Authenticated = observer(({ auth, children }: Props) => {
4355

4456
auth.logout(true);
4557
return <Redirect to="/" />;
46-
});
58+
};
4759

48-
export default inject("auth")(Authenticated);
60+
export default observer(Authenticated);

app/components/Avatar/AvatarWithPresence.js

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { observable } from "mobx";
44
import { observer } from "mobx-react";
55
import { EditIcon } from "outline-icons";
66
import * as React from "react";
7+
import { withTranslation, type TFunction } from "react-i18next";
78
import styled from "styled-components";
89
import User from "models/User";
910
import UserProfile from "scenes/UserProfile";
@@ -16,6 +17,7 @@ type Props = {
1617
isEditing: boolean,
1718
isCurrentUser: boolean,
1819
lastViewedAt: string,
20+
t: TFunction,
1921
};
2022

2123
@observer
@@ -37,20 +39,25 @@ class AvatarWithPresence extends React.Component<Props> {
3739
isPresent,
3840
isEditing,
3941
isCurrentUser,
42+
t,
4043
} = this.props;
4144

45+
const action = isPresent
46+
? isEditing
47+
? t("currently editing")
48+
: t("currently viewing")
49+
: t("viewed {{ timeAgo }} ago", {
50+
timeAgo: distanceInWordsToNow(new Date(lastViewedAt)),
51+
});
52+
4253
return (
4354
<>
4455
<Tooltip
4556
tooltip={
4657
<Centered>
47-
<strong>{user.name}</strong> {isCurrentUser && "(You)"}
58+
<strong>{user.name}</strong> {isCurrentUser && `(${t("You")})`}
4859
<br />
49-
{isPresent
50-
? isEditing
51-
? "currently editing"
52-
: "currently viewing"
53-
: `viewed ${distanceInWordsToNow(new Date(lastViewedAt))} ago`}
60+
{action}
5461
</Centered>
5562
}
5663
placement="bottom"
@@ -83,4 +90,4 @@ const AvatarWrapper = styled.div`
8390
transition: opacity 250ms ease-in-out;
8491
`;
8592

86-
export default AvatarWithPresence;
93+
export default withTranslation()<AvatarWithPresence>(AvatarWithPresence);

app/components/Breadcrumb.js

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// @flow
2-
import { observer, inject } from "mobx-react";
2+
import { observer } from "mobx-react";
33
import {
44
ArchiveIcon,
55
EditIcon,
@@ -10,6 +10,7 @@ import {
1010
TrashIcon,
1111
} from "outline-icons";
1212
import * as React from "react";
13+
import { useTranslation } from "react-i18next";
1314
import { Link } from "react-router-dom";
1415
import styled from "styled-components";
1516
import breakpoint from "styled-components-breakpoint";
@@ -19,6 +20,7 @@ import Document from "models/Document";
1920
import CollectionIcon from "components/CollectionIcon";
2021
import Flex from "components/Flex";
2122
import BreadcrumbMenu from "./BreadcrumbMenu";
23+
import useStores from "hooks/useStores";
2224
import { collectionUrl } from "utils/routeHelpers";
2325

2426
type Props = {
@@ -28,13 +30,15 @@ type Props = {
2830
};
2931

3032
function Icon({ document }) {
33+
const { t } = useTranslation();
34+
3135
if (document.isDeleted) {
3236
return (
3337
<>
3438
<CollectionName to="/trash">
3539
<TrashIcon color="currentColor" />
3640
&nbsp;
37-
<span>Trash</span>
41+
<span>{t("Trash")}</span>
3842
</CollectionName>
3943
<Slash />
4044
</>
@@ -46,7 +50,7 @@ function Icon({ document }) {
4650
<CollectionName to="/archive">
4751
<ArchiveIcon color="currentColor" />
4852
&nbsp;
49-
<span>Archive</span>
53+
<span>{t("Archive")}</span>
5054
</CollectionName>
5155
<Slash />
5256
</>
@@ -58,7 +62,7 @@ function Icon({ document }) {
5862
<CollectionName to="/drafts">
5963
<EditIcon color="currentColor" />
6064
&nbsp;
61-
<span>Drafts</span>
65+
<span>{t("Drafts")}</span>
6266
</CollectionName>
6367
<Slash />
6468
</>
@@ -70,7 +74,7 @@ function Icon({ document }) {
7074
<CollectionName to="/templates">
7175
<ShapesIcon color="currentColor" />
7276
&nbsp;
73-
<span>Templates</span>
77+
<span>{t("Templates")}</span>
7478
</CollectionName>
7579
<Slash />
7680
</>
@@ -79,14 +83,17 @@ function Icon({ document }) {
7983
return null;
8084
}
8185

82-
const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
86+
const Breadcrumb = ({ document, onlyText }: Props) => {
87+
const { collections } = useStores();
88+
const { t } = useTranslation();
89+
8390
let collection = collections.get(document.collectionId);
8491
if (!collection) {
8592
if (!document.deletedAt) return <div />;
8693

8794
collection = {
8895
id: document.collectionId,
89-
name: "Deleted Collection",
96+
name: t("Deleted Collection"),
9097
color: "currentColor",
9198
};
9299
}
@@ -141,7 +148,7 @@ const Breadcrumb = observer(({ document, collections, onlyText }: Props) => {
141148
)}
142149
</Wrapper>
143150
);
144-
});
151+
};
145152

146153
const Wrapper = styled(Flex)`
147154
display: none;
@@ -202,4 +209,4 @@ const CollectionName = styled(Link)`
202209
overflow: hidden;
203210
`;
204211

205-
export default inject("collections")(Breadcrumb);
212+
export default observer(Breadcrumb);

app/components/DocumentMeta.js

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
// @flow
2-
import { inject, observer } from "mobx-react";
2+
import { observer } from "mobx-react";
33
import * as React from "react";
4+
import { useTranslation } from "react-i18next";
45
import { Link } from "react-router-dom";
56
import styled from "styled-components";
6-
import AuthStore from "stores/AuthStore";
7-
import CollectionsStore from "stores/CollectionsStore";
87
import Document from "models/Document";
98
import Breadcrumb from "components/Breadcrumb";
109
import Flex from "components/Flex";
1110
import Time from "components/Time";
11+
import useStores from "hooks/useStores";
1212

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

2525
type Props = {
26-
collections: CollectionsStore,
27-
auth: AuthStore,
2826
showCollection?: boolean,
2927
showPublished?: boolean,
3028
showLastViewed?: boolean,
@@ -34,8 +32,6 @@ type Props = {
3432
};
3533

3634
function DocumentMeta({
37-
auth,
38-
collections,
3935
showPublished,
4036
showCollection,
4137
showLastViewed,
@@ -44,6 +40,8 @@ function DocumentMeta({
4440
to,
4541
...rest
4642
}: Props) {
43+
const { t } = useTranslation();
44+
const { collections, auth } = useStores();
4745
const {
4846
modifiedSinceViewed,
4947
updatedAt,
@@ -67,37 +65,37 @@ function DocumentMeta({
6765
if (deletedAt) {
6866
content = (
6967
<span>
70-
deleted <Time dateTime={deletedAt} addSuffix />
68+
{t("deleted")} <Time dateTime={deletedAt} addSuffix />
7169
</span>
7270
);
7371
} else if (archivedAt) {
7472
content = (
7573
<span>
76-
archived <Time dateTime={archivedAt} addSuffix />
74+
{t("archived")} <Time dateTime={archivedAt} addSuffix />
7775
</span>
7876
);
7977
} else if (createdAt === updatedAt) {
8078
content = (
8179
<span>
82-
created <Time dateTime={updatedAt} addSuffix />
80+
{t("created")} <Time dateTime={updatedAt} addSuffix />
8381
</span>
8482
);
8583
} else if (publishedAt && (publishedAt === updatedAt || showPublished)) {
8684
content = (
8785
<span>
88-
published <Time dateTime={publishedAt} addSuffix />
86+
{t("published")} <Time dateTime={publishedAt} addSuffix />
8987
</span>
9088
);
9189
} else if (isDraft) {
9290
content = (
9391
<span>
94-
saved <Time dateTime={updatedAt} addSuffix />
92+
{t("saved")} <Time dateTime={updatedAt} addSuffix />
9593
</span>
9694
);
9795
} else {
9896
content = (
9997
<Modified highlight={modifiedSinceViewed}>
100-
updated <Time dateTime={updatedAt} addSuffix />
98+
{t("updated")} <Time dateTime={updatedAt} addSuffix />
10199
</Modified>
102100
);
103101
}
@@ -112,25 +110,25 @@ function DocumentMeta({
112110
if (!lastViewedAt) {
113111
return (
114112
<>
115-
•&nbsp;<Modified highlight>Never viewed</Modified>
113+
•&nbsp;<Modified highlight>{t("Never viewed")}</Modified>
116114
</>
117115
);
118116
}
119117

120118
return (
121119
<span>
122-
•&nbsp;Viewed <Time dateTime={lastViewedAt} addSuffix shorten />
120+
•&nbsp;{t("Viewed")} <Time dateTime={lastViewedAt} addSuffix shorten />
123121
</span>
124122
);
125123
};
126124

127125
return (
128126
<Container align="center" {...rest}>
129-
{updatedByMe ? "You" : updatedBy.name}&nbsp;
127+
{updatedByMe ? t("You") : updatedBy.name}&nbsp;
130128
{to ? <Link to={to}>{content}</Link> : content}
131129
{showCollection && collection && (
132130
<span>
133-
&nbsp;in&nbsp;
131+
&nbsp;{t("in")}&nbsp;
134132
<strong>
135133
<Breadcrumb document={document} onlyText />
136134
</strong>
@@ -142,4 +140,4 @@ function DocumentMeta({
142140
);
143141
}
144142

145-
export default inject("collections", "auth")(observer(DocumentMeta));
143+
export default observer(DocumentMeta);

0 commit comments

Comments
 (0)