Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add example app with React Intl #1055

Merged
merged 2 commits into from
Feb 24, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions examples/with-react-intl/.babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"presets": [
"next/babel"
],
"env": {
"development": {
"plugins": [
"react-intl"
]
},
"production": {
"plugins": [
["react-intl", {
"messagesDir": "lang/.messages/"
}]
]
}
}
}
1 change: 1 addition & 0 deletions examples/with-react-intl/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lang/.messages/
51 changes: 51 additions & 0 deletions examples/with-react-intl/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Example app with [React Intl][]

## How to use

Download the example [or clone the repo](https://github.com/zeit/next.js.git):

```bash
curl https://codeload.github.com/zeit/next.js/tar.gz/master | tar -xz --strip=2 next.js-master/examples/with-react-intl
cd with-react-intl
```

Install it and run:

```bash
npm install
npm run dev
```

Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download))

```bash
now
```

## The idea behind the example

This example app shows how to integrate [React Intl][] with Next.

### Features of this example app

- Server-side language negotiation
- React Intl locale data loading via `pages/_document.js` customization
- React Intl integration at Next page level via `pageWithIntl()` HOC
- `<IntlProvider>` creation with `locale`, `messages`, and `initialNow` props
- Default message extraction via `babel-plugin-react-intl` integration
- Translation management via build script and customized Next server

### Translation Management

This app stores translations and default strings in the `lang/` dir. This dir has `.messages/` subdir which is where React Intl's Babel plugin outputs the default messages it extracts from the source code. The default messages (`en.json` in this example app) is also generated by the build script. This file can then be sent to a translation service to perform localization for the other locales the app should support.

The translated messages files that exist at `lang/*.json` are only used during production, and are automatically provided to the `<IntlProvider>`. During development the `defaultMessage`s defined in the source code are used. To prepare the example app for localization and production run the build script and start the server in production mode:

```
$ npm run build
$ npm start
```

You can then switch your browser's language preferences to French and refresh the page to see the UI update accordingly.

[React Intl]: https://github.com/yahoo/react-intl
27 changes: 27 additions & 0 deletions examples/with-react-intl/components/Layout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react'
import {defineMessages, injectIntl} from 'react-intl'
import Head from 'next/head'
import Nav from './Nav'

const messages = defineMessages({
title: {
id: 'title',
defaultMessage: 'React Intl Next.js Example'
}
})

export default injectIntl(({intl, title, children}) => (
<div>
<Head>
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title || intl.formatMessage(messages.title)}</title>
</Head>

<header>
<Nav />
</header>

{children}

</div>
))
28 changes: 28 additions & 0 deletions examples/with-react-intl/components/Nav.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react'
import {FormattedMessage} from 'react-intl'
import Link from 'next/link'

export default () => (
<nav>
<li>
<Link href='/'>
<a><FormattedMessage id='nav.home' defaultMessage='Home' /></a>
</Link>
</li>
<li>
<Link href='/about'>
<a><FormattedMessage id='nav.about' defaultMessage='About' /></a>
</Link>
</li>

<style jsx>{`
nav {
display: flex;
}
li {
list-style: none;
margin-right: 1rem;
}
`}</style>
</nav>
)
44 changes: 44 additions & 0 deletions examples/with-react-intl/components/PageWithIntl.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React, {Component} from 'react'
import {IntlProvider, addLocaleData, injectIntl} from 'react-intl'

// Register React Intl's locale data for the user's locale in the browser. This
// locale data was added to the page by `pages/_document.js`. This only happens
// once, on initial page load in the browser.
if (typeof window !== 'undefined' && window.ReactIntlLocaleData) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is window.ReactIntlLocaleData created?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the locale data modules; e.g. https://unpkg.com/[email protected]/locale-data/en.js

The server selects one React Intl's locale data scripts and inlines it on the page. Since the locale data is packaged as a UMD module, in the browser it creates the window.ReactIntlLocaleData global.

More details here: https://github.com/yahoo/react-intl/wiki#locale-data-in-browsers

Copy link
Contributor

@sedubois sedubois Feb 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Passing locale data around like this seems contrived 🤔 As I commented in formatjs/formatjs#731 (comment)

Is there no way for getInitialProps to return the localeData and simply pass that to addLocaleData?

Copy link
Contributor Author

@ericf ericf Feb 12, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several issues with Next at play here:

  1. page/_document.js is bundled with Webpack, and errors our if you try to use fs or any other built-in Node modules even though it only runs on the client.

  2. The __NEXT_DATA__ is serialized using JSON.stringify(). This means that getInitialProps() must return an object that's JSON-serializable, and React Intl's locale data isn't JSON-serializable since it contains functions for pluralization.

  3. Calling addLocaleData() on every page transition wouldn't be ideal. It's best to only call this function one time.

It's possible to address 1 and 2 of these issues in Next, and I'd be happy to rework example once they are changed, but as it stands now there isn't a good way around it.

For 2, serialize-javascript can be used which does support serializing functions. Changing Next to use this instead of raw JSON.stringify() would remove the need for this example to use a custom pages/_document.js.

Copy link
Contributor

@sedubois sedubois Feb 13, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the details 🙂

  1. IMHO worth to create a bug on Next.js
  2. the localeDataScript itself (loaded by require.resolve + readFileSync) is just a string, which is serializable? Then the string could be evaluated where needed in the HOC (e.g with eval)? Right now the intl code is spread all around and that's hardly maintainable.
  3. should work by returning a serverRendered: !process.browser in your getInitialProps and wrap the addLocaleData in if (props.serverRendered)?: https://github.com/relatenow/relate/blob/ee51f1dd6990177ff0924b1c0d78fddfd23aa564/hocs/withIntl.js#L43

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how is using eval worse than dangerouslySetInnerHtml? Both sound ominous to me anyway. (but I'm no expert on this)

In this case dangerouslySetInnerHTML is being used only on the server and it's just creating an inline <script> in the HMTL response. Your idea of using eval() would be running on the client would conflict with CSP; discussion here: #256

I hope not to spread i18n logic in my custom server etc just to handle this.

How are you doing HTTP content negotiation for choosing the locale on both the server and client?

I encourage people to who have apps that render on both the server and client to do the content negotiation and choose the correct locale server-side, and have the client use the locale the server decided on.

The main issue I ran into when trying to do this type of content negotiation and locale data loading in the HOC is that Webpack would bundle all locale data, all translated messages, and npm packages that were only meant to run on the client. I think the only way to keep everything in the HOC would involve i18n related stuff in a custom webpack config file — and I don't know what that would look like, but maybe you or others do?

Copy link
Contributor

@sedubois sedubois Feb 14, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'm not familiar with CSP, will try to read more on this.

For detecting the browser locale I used express-request-language, then store it in the session, and allow the user to customise it (server-side).

For co-locating the code a bit more, I just managed to load the message files from within the HOC (it does involve a slight webpack tweak): https://github.com/relatenow/relate/blob/master/hocs/withIntl.js#L25

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just updated to use the accept-language module to better co-locate the code: sedubois/relate@5c9d95a

This means that I don't need a custom server at all to use react-intl at this point.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sedubois do you want to send a PR to my branch with your suggested changes? Then I can merge your commits into this branch/PR.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ericf ok I can try to send something, maybe tomorrow (not sure if have time).

Object.keys(window.ReactIntlLocaleData).forEach((lang) => {
addLocaleData(window.ReactIntlLocaleData[lang])
})
}

export default (Page) => {
const IntlPage = injectIntl(Page)

return class PageWithIntl extends Component {
static async getInitialProps (context) {
let props
if (typeof Page.getInitialProps === 'function') {
props = await Page.getInitialProps(context)
}

// Get the `locale` and `messages` from the request object on the server.
// In the browser, use the same values that the server serialized.
const {req} = context
const {locale, messages} = req || window.__NEXT_DATA__.props

// Always update the current time on page load/transition because the
// <IntlProvider> will be a new instance even with pushState routing.
const now = Date.now()

return {...props, locale, messages, now}
}

render () {
const {locale, messages, now, ...props} = this.props
return (
<IntlProvider locale={locale} messages={messages} initialNow={now}>
<IntlPage {...props} />
</IntlProvider>
)
}
}
}
7 changes: 7 additions & 0 deletions examples/with-react-intl/lang/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"title": "React Intl Next.js Example",
"nav.home": "Home",
"nav.about": "About",
"description": "An example app integrating React Intl with Next.js",
"greeting": "Hello, World!"
}
7 changes: 7 additions & 0 deletions examples/with-react-intl/lang/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"title": "React Intl Next.js Exemple",
"nav.home": "Accueil",
"nav.about": "À propos de nous",
"description": "Un exemple d'application intégrant React Intl avec Next.js",
"greeting": "Bonjour le monde!"
}
21 changes: 21 additions & 0 deletions examples/with-react-intl/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "with-react-intl",
"version": "1.0.0",
"scripts": {
"dev": "node server.js",
"build": "next build && node ./scripts/default-lang",
"start": "NODE_ENV=production node server.js"
},
"dependencies": {
"accepts": "^1.3.3",
"babel-plugin-react-intl": "^2.3.1",
"glob": "^7.1.1",
"intl": "^1.2.5",
"next": "^2.0.0-beta",
"react": "^15.4.2",
"react-dom": "^15.4.2",
"react-intl": "^2.2.3"
},
"author": "",
"license": "ISC"
}
31 changes: 31 additions & 0 deletions examples/with-react-intl/pages/_document.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import Document, {Head, Main, NextScript} from 'next/document'

// The document (which is SSR-only) needs to be customized to expose the locale
// data for the user's locale for React Intl to work in the browser.
export default class IntlDocument extends Document {
static async getInitialProps (context) {
const props = await super.getInitialProps(context)
const {req: {localeDataScript}} = context
return {
...props,
localeDataScript
}
}

render () {
return (
<html>
<Head />
<body>
<Main />
<script
dangerouslySetInnerHTML={{
__html: this.props.localeDataScript
}}
/>
<NextScript />
</body>
</html>
)
}
}
25 changes: 25 additions & 0 deletions examples/with-react-intl/pages/about.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, {Component} from 'react'
import {FormattedRelative} from 'react-intl'
import pageWithIntl from '../components/PageWithIntl'
import Layout from '../components/Layout'

class About extends Component {
static async getInitialProps ({req}) {
return {someDate: Date.now()}
}

render () {
return (
<Layout>
<p>
<FormattedRelative
value={this.props.someDate}
updateInterval={1000}
/>
</p>
</Layout>
)
}
}

export default pageWithIntl(About)
26 changes: 26 additions & 0 deletions examples/with-react-intl/pages/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import React from 'react'
import {FormattedMessage, FormattedNumber, defineMessages} from 'react-intl'
import Head from 'next/head'
import pageWithIntl from '../components/PageWithIntl'
import Layout from '../components/Layout'

const {description} = defineMessages({
description: {
id: 'description',
defaultMessage: 'An example app integrating React Intl with Next.js'
}
})

export default pageWithIntl(({intl}) => (
<Layout>
<Head>
<meta name='description' content={intl.formatMessage(description)} />
</Head>
<p>
<FormattedMessage id='greeting' defaultMessage='Hello, World!' />
</p>
<p>
<FormattedNumber value={1000} />
</p>
</Layout>
))
19 changes: 19 additions & 0 deletions examples/with-react-intl/scripts/default-lang.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
const {readFileSync, writeFileSync} = require('fs')
const {resolve} = require('path')
const glob = require('glob')

const defaultMessages = glob.sync('./lang/.messages/**/*.json')
.map((filename) => readFileSync(filename, 'utf8'))
.map((file) => JSON.parse(file))
.reduce((messages, descriptors) => {
descriptors.forEach(({id, defaultMessage}) => {
if (messages.hasOwnProperty(id)) {
throw new Error(`Duplicate message id: ${id}`)
}
messages[id] = defaultMessage
})
return messages
}, {})

writeFileSync('./lang/en.json', JSON.stringify(defaultMessages, null, 2))
console.log(`> Wrote default messages to: "${resolve('./lang/en.json')}"`)
53 changes: 53 additions & 0 deletions examples/with-react-intl/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Polyfill Node with `Intl` that has data for all locales.
// See: https://formatjs.io/guides/runtime-environments/#server
const IntlPolyfill = require('intl')
Intl.NumberFormat = IntlPolyfill.NumberFormat
Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat

const {readFileSync} = require('fs')
const {basename} = require('path')
const {createServer} = require('http')
const accepts = require('accepts')
const glob = require('glob')
const next = require('next')

const dev = process.env.NODE_ENV !== 'production'
const app = next({dev})
const handle = app.getRequestHandler()

// Get the supported languages by looking for translations in the `lang/` dir.
const languages = glob.sync('./lang/*.json').map((f) => basename(f, '.json'))

// We need to expose React Intl's locale data on the request for the user's
// locale. This function will also cache the scripts by lang in memory.
const localeDataCache = new Map()
const getLocaleDataScript = (locale) => {
const lang = locale.split('-')[0]
if (!localeDataCache.has(lang)) {
const localeDataFile = require.resolve(`react-intl/locale-data/${lang}`)
const localeDataScript = readFileSync(localeDataFile, 'utf8')
localeDataCache.set(lang, localeDataScript)
}
return localeDataCache.get(lang)
}

// We need to load and expose the translations on the request for the user's
// locale. These will only be used in production, in dev the `defaultMessage` in
// each message description in the source code will be used.
const getMessages = (locale) => {
return require(`./lang/${locale}.json`)
}

app.prepare().then(() => {
createServer((req, res) => {
const accept = accepts(req)
const locale = accept.language(dev ? ['en'] : languages)
req.locale = locale
req.localeDataScript = getLocaleDataScript(locale)
req.messages = dev ? {} : getMessages(locale)
handle(req, res)
}).listen(3000, (err) => {
if (err) throw err
console.log('> Read on http://localhost:3000')
})
})