Js-lingui: Improve JS macro

Created on 12 Oct 2018  ·  26Comments  ·  Source: lingui/js-lingui

Is your feature request related to a problem?

Consider existing implementation:

import { setupI18n } from '@lingui/core'
import { t } from '@lingui/macro'

const i18n = setupI18n()

i18n._(t`Hello`)

This implementation has several disadvantages:

  • you have to explicitly import all i18n formatters (t, plural)
  • the i18n._(t'Hello') construct is just more complex that previous i18n.t

Describe the solution you'd like

import i18n from '@lingui/macro'

i18n`Hello ${world}`

This will be transformed to:

import i18n from '@lingui/core'

i18n._('Hello {world}', { world })

i18n is a single entry macro. @lingui/core package exports global i18n object which all macros use implicitly.

Describe alternatives you've considered

// i18n object extends the runtime i18n object, so all runtime methods are accessible as well:
i18n.activate('en')
i18n.load('en', catalog)
i18n.locale

// On top of core i18n API, there're these formatters:

// Basic usage
i18n`Hello ${world}`

// Override auto-generated ID
i18n('id')`Hello ${world}`

// Plurals and other formatters
i18n.plural({
  // Override auto-generated ID
  // id: 'custom id',

  value: 1,
  // template strings inside macros are processed as if they were wrapped in i18n`...` tag
  one: `${name} owns one book`,
  other: `${name} owns # books`
})

Additional context

Optional I18nProvider and withI18n/I18n consumers

Thanks to global i18n object, we can make I18nProvider completely optional. It'll be required only if you want to re-render your app on locale change without refresh.

Global variable

If global i18n object becomes a problem, we can make createI18nMacros function, which takes custom i18n object as an argument and creates a custom macro:

// This file must be named `*.macro.js` to be recognized as a macro
// i18n.macro.js
import { setupI18n, createI18nMacros } from '@lingui/core'

// custom i18n instance
const i18n = setupI18n()

export default createI18nMacros(i18n)
// app.js
import i18n from `./i18n.macro.js

i18n`Hello World`

Honestly, I'm not concerned much about using i18n global and it really simplifies the API a lot. First, there's a possible escape hatch. And second, I saw Parkour Atlas from Boston Dynamics today and I think global variables are nothing to be worried about nowadays.

This is a follow up from #353. Feedback appreciated @FredyC @vonovak @MartijnHols @queicherius (I remember having discussion with you about plugins/macros API, hence the mention. Please unsubscribe if I mentioned you by mistake)

💡new feature

Most helpful comment

What about this?

i18n`Message ID`
i18n('Help Text')`Message ID`

i18n.id('Message ID')`Default Message`
i18n.id('Message ID', 'Help Text')`Default Message`

This was early proposal of macros.

All 26 comments

While I can not comment too much on it, since I use the JSX syntax to do translations in my applications most of the times, the API looks good to me! 👍

I am definitely a big supporter of global i18n as I am already using it in two apps that way. I would love to see some better support for descriptions there, eg. i18n.desc('description')'Hello world'. To combine that with custom ID, it would be probably something like i18n('id').desc('description')'Hello world'. It's a bit more verbose probably, but I kinda prefer it instead of comments.

Another idea is about plural and other formatters to have value as an argument. It just feels bit cleaner.

i18n.plural(value, {
  one: `${name} owns one book`,
  other: `${name} owns # books`
})

Also, let me know if you need further help figuring out callable objects.

I am definitely a big supporter of global i18n as I am already using it in two apps that way. I would love to see some better support for descriptions there, eg. i18n.desc('description')'Hello world'. To combine that with custom ID, it would be probably something like i18n('id').desc('description')'Hello world'. It's a bit more verbose probably, but I kinda prefer it instead of comments.

Yeah, this is something we need to solve.

One solution might be this:

// shortcuts
i18n`Message ID`
i18n('Message ID')`Default message`

// Full configuration
i18n({
  description: "Help text"
})`Default message`
i18n({
  id: "Message ID",
  description: "Help text"
})`Default message`

It's flexible and we can add any data in the future (e.g. context).

On the other hand, it's a bit verbose, but I either saw projects using id and description for every message or using description very rarely. For me it's totally ok to write object with description once in a while (I use Message ID as keys by default).

Another idea is about plural and other formatters to have value as an argument. It just feels bit cleaner.

i18n.plural(value, {
  one: `${name} owns one book`,
  other: `${name} owns # books`
})

If the second argument would be optional, then yes, otherwise I don't see much benefit.

Also, let me know if you need further help figuring out callable objects.

Actually, it won't be needed at all! Callable i18n is just a macro, which is transformed into i18n._ call.

i18n`Default message`
// Macros is transformed to:
// i18n._("Default message")

On the other hand, it's a bit verbose, but I either saw projects using id and description for every message or using description very rarely. For me it's totally ok to write object with description once in a while (I use Message ID as keys by default).

Yea, I think it's good to support i18n({ description: "Help text" })'Default message' eventually also, but as you say it's rather verbose. I really do like i18n.desc('description')'Hello world' as it feels more like part of the message. It feels more like another formatter. Personally, I can imagine using description over custom ID all over the place to mimic a context in a way. Using both is probably only for projects which opted for using IDs everywhere.

What about this?

i18n`Message ID`
i18n('Help Text')`Message ID`

i18n.id('Message ID')`Default Message`
i18n.id('Message ID', 'Help Text')`Default Message`

This was early proposal of macros.

None of the examples show how to do internationalization for values such as in defaultProps, could you shed some light on what you have in mind?

For ids and descriptions I think i18n.desc('Introduction shows on the first page users see after logging in.')`Hello ${name}` looks weird, even more if you add the id and who knows what other props in the future. I understand what's going on, but I fear it will be harder for new developers to understand and therefore apply.

I think it's easy to explain to someone and have them understand that i18n`Hello ${name}` is simply a short hand for i18n(`Hello ${name}`) which just behaves like a plain old JS function. So then in line with the JS-like syntax, how about simply passing the id and description in an object as second parameter?

i18n(`Hello ${name}`, { id: 'app.module.hello' })
i18n(`Hello ${name}`, { id: 'app.module.hello', description: 'Introduction shows on the first page users see after logging in.' })
i18n(`Hello ${name}`, { description: 'Introduction shows on the first page users see after logging in.' })

In addition some people might prefer to have a fully consistent syntax where the default value is also passed in the object. I wouldn't since my usage of ids and descriptions will be rare, but you could support both by checking for the type of the first arg.

i18n({ id: 'app.module.hello', value: `Hello ${name}` })

I've also been thinking about the global i18n a bit and I think it's a great idea for most apps, just not mine 😄. The app I work on most of my time has a super clear separation of parts (simplified it's main + 36 parts). I don't think a global i18n would make a lot of sense for this as I want to chunk and namespaces localisations into those parts. The i18n.macro.js proposal would be fine for this.

None of the examples show how to do internationalization for values such as in defaultProps, could you shed some light on what you have in mind?

Yeah, this is something we need to solve if we allow to use i18n object anywhere. How to explain to the users, that in some places they can use i18n directly, but in other places they need to use lazy translations?

Default props are gonna always use lazy translations - i.e. instead of translating immediately, the translation will be performed during rendering.

I think it's easy to explain to someone and have them understand that i18nHello ${name} is simply a short hand for i18n(Hello ${name}) which just behaves like a plain old JS function. So then in line with the JS-like syntax, how about simply passing the id and description in an object as second parameter?

Interesting. Especially the idea place the message description after the message. It makes sense, because the ID is used only as a reference in catalog and description is for translators. Users navigate the code based on messages.

In addition some people might prefer to have a fully consistent syntax where the default value is also passed in the object.

It would great to have just one way how to define messages. Let's leave it open.

Um, I might be missing something, but why is it such a problem to do a translation of defaultProps (and anything top level)? I mean a macro gets transformed to a runtime code like i18n._('Hello {world}', { world }). As long as the catalog is loaded it should be able to read a message from it and return it. Why it should be a problem? Since the i18n is global, it doesn't need to wait for any rendering. Ensuring that the catalog is loaded before loading other modules that contain translation is a concern that fits into userland imo.

Default props are evaluated when module is loaded, but the locale is set afterwards. I don’t feel that loading module lazily instead of translation is a better solution here although it may work in some cases.

On 16 Oct 2018, at 08:44, Daniel K. notifications@github.com wrote:

Um, I might be missing something, but why is it such a problem to do a translation of defaultProps (and anything top level)? I mean a macro gets transformed to a runtime code like i18n._('Hello {world}', { world }). As long as the catalog is loaded it should be able to read a message from it and return it. Why it should be a problem? Ensuring that the catalog is loaded before loading other modules that contain translation is a concern that fits into userland imo.


You are receiving this because you authored the thread.
Reply to this email directly, view it on GitHub, or mute the thread.

I've just updated the RFC.

Things to consider:

  • translation of default props (or anything when modules are loaded)
  • namespaces

@tricoder42 I like it very much, it's clean and tidy :)

I would like to see the alternative syntax for comments. It's just a personal preference, I don't like boilerplates :)

t('Comment for translators')`Default message`

Also, I got rather confused about lazy translations part. How is a second example related to lazy? You are using plural there. I thought there will be something like lazy.plural? And the last example i18nMark got me lost completely. Why is that needed with lazy macro present?

What's the actual issue with default props. Lazy translations are exactly for that, aren't they?

@FredyC Thanks for feedback!

I would like to have API as simple as possible:

// Message without metadata
t`Default message`

// Message with metadata
t({
  id: 'msg.id',
  comment: 'Comment for translators'
})`Default message`

It's very similar to Trans tag, except in JSX it's easier to pass props. It works the same for ID, comment and in the future for any metadata, so it's easy to extend. I understand it's a bit longer than

t('Comment for translators')`Default message`

but it's explicit and readable.

Also, I got rather confused about lazy translations part. How is a second example related to lazy? You are using plural there. I thought there will be something like lazy.plural?

Good catch! I rewrote the RFC in the last minute, because first I was using t.plural and lazy.plural. Then I decided it's not such a difference to have standalone plural macro, in the same way as we have Plural macro. I have to think about it.

And the last example i18nMark got me lost completely. Why is that needed with lazy macro present?

i18nMark is just internal thing. It won't be visible in production code and it'll be generated only in development mode. User won't see it in their code.

RFC Updated (changes)

Two major changes:

  1. t macro syntax
  2. Syntax for lazy translations

t macro

t macro has only two usages: as a template tag and as a function:

Template tag

Just a shortcut, only useful when messages are used as IDs and there're no additional metadata.

import { t } from "@lingui/macro"

const translation = t`Hello ${name}`

Function

Allows override message ID and provide additional metadata (e.g. comment for translators). If value of message is template literal, it's transformed as if it were wrapped in t tag.

import { t } from '@lingui/macro'

// Message is used as an ID
t({
   message: `Hello ${name}`,
   comment: "Comment for translators"
})

// Custom ID
t({
   id: "msg.hello",
   message: `Hello ${name}`,
   comment: "Comment for translators"
})

// Custom ID, without default message
t({
   id: "msg.hello",
   comment: "Comment for translators"
})

Lazy translations

Each macro has .lazy variant which forces lazy translation:

import { t, plural, arg } from `@lingui/macro`

// define the message
const msg = t.lazy`Default message`

// translate it
const translation = msg()

const books = plural.lazy({
   value: arg('count'),
   one: '# book',
   other: '# books'
})

const translation = books({ count: 42 })

I am still a bit confused about that lazy loading. You have these two in the same scope. How would a user know which one to use? I assume that non-lazy variant shouldn't be used in _top_ scope but only inside the functions? It's kinda hard to enforce that and imo leaves inexperienced user stumbling.

import { t } from "@lingui/macro"

const translation = t`Hello ${name}`
const msg = t.lazy`Default message`

The RFC isn't about exact implementation, but rather about the syntax. I wanted to figure out in advance and show how it's different from current implementation.

To address your concerns: Lazy translations are required everywhere where catalogs aren't loaded yet.

  1. We might throw hard error in development if user tries to use eager translation in such context.
  2. I'm still thinking about "implicit" lazyness. The would be only one macro, which is transformed to i18n._ call. The magic would be inside that method: If catalogs aren't loaded, lazy translation is returned.
I18n.prototype._ = function (id) {
  if (!this.catalogs[this.language]) {
    return this.lazy(id)
  }
}

I18n.prototype.lazy = function (id) {
  const i18n = this
  function translate(params) {
    return i18n._(id, params)
  }

  translate.toString = translate
  return translate
}

The usage is simple:

const msg = t`Message` // you get lazy translation
i18n.load(catalogs)
i18n.activate('cs')
console.log(msg)  // you get translation of `Message` in Czech

Important thing here: you work with lazy/eager message in the same way. E.g. when you use lazy translation for default props and eager translation as actual props, the component code looks the same:

function Hello(msg) {
  // msg might be both function (lazy translation) or string (eager translation).
  // if msg is function, React serialized it to string. Lazy translations are
  // evaluated when serialized.
  return <div>{msg}</div>
}

Hello.defaultProps = {
  msg: t`Hello`
}

function App() {
  return (
    <div>
      <Hello />
      <Hello msg="Good morning" />
    </div>
  )
}

TLDR:

Approach 1 is easy to do and should help anyone without need to read the documentation. When you do the mistake, the library tells you how to fix it.

Approach 2 is a bit magical, but very tempting. It would handle lazyness automatically under the hood.

Ok, approach 2 definitely sounds good, perhaps too good to be true? Are there any caveats you can think of? Except it feels magical, but that means it's easy to use and that's what this should be about, right?

I mean why you wouldn't go with implicit laziness in the first place if it works the best in most cases? In what cases it might not work?

I don't know to be honest.

One problem might be when you try to use String.prototype methods inside your render function:

function Hello(name) {
  // this won't work with lazy translation, because function doesn't have a split method
  const initials = name.split(' ').map(chunk => chunk[0]).join('')
  return <span>{initials}</span>
}

So, you always have to remember to convert any "translatable" strings explicitly with toString.

Also, I'm a bit worried that no matter how good the approach will be, there's always gonna be usecases where you want lazy/eager translation explicitly. For those scenarios we could keep macro.lazy for explicit lazy translations and (t'...').toString() for explicit eager translation. Once we have these escape hatches, I would go with approach 2.

Also, I'm a bit worried that no matter how good the approach will be, there's always gonna be usecases where you want lazy/eager translation explicitly. For those scenarios we could keep macro.lazy for explicit lazy translations and (t'...').toString() for explicit eager translation. Once we have these escape hatches, I would go with approach 2.

Sound good to me. What about having an actual t.eager that would add the toString call there? I don't mean it as a replacement, but additional API to basically avoid questions from the newcomer "why is toString here?". It would be easier to comprehend with such semantics imo.

The would be only one macro, which is transformed to i18n._ call

I am curious. Are you planning some dev warning when i18n is not in the scope? Or is it going to be injected during the transformation? That wouldn't work if someone wants to export a custom instance of i18n.

I am curious. Are you planning some dev warning when i18n is not in the scope? Or is it going to be injected during the transformation? That wouldn't work if someone wants to export a custom instance of i18n.

One approach is require i18n in the scope, as React does it. Drawback is that users have to configure their linters that i18n might be unused variable.

I was planning to convert t macro to import i18n from "@lingui/core" as we talked about. If you want to override it, you can set coreModule in lingui config. coreModule: "./i18n.config.js" for default export or coreModule: ["i18n", "./i18n.config.js"] for named export. Seems easy to start with, easy to override for advanced usecases and also Lingui config will allow several configuration parameter for macros anyway (e.g. message ID minification).

What do you think?

One approach is require i18n in the scope, as React does it. Drawback is that users have to configure their linters that i18n might be unused variable.

This is going to be a problem in TypeScript sadly. There is a noUnusedLocals option in TypeScript itself (not TSLint) and it does not allow any exceptions. The React is an only built-in exception.

Personally, I wouldn't really mind the same what I am doing right now with i18n.t. Yes, it's a bit longer, but it's more explicit and easy to comprehend which i18n instance is used. I am not sure how hard would be for macros to support both ways. Doing this in config sounds rather unnatural, to be honest.

Personally, I wouldn't really mind the same what I am doing right now with i18n.t. Yes, it's a bit longer, but it's more explicit and easy to comprehend which i18n instance is used. I am not sure how hard would be for macros to support both ways.

The only possible solution would be to transform i18n macro into i18n object, which I'm trying to avoid. The inevitable question is: when should I import i18n from core/config and when from macro? Sure we could add warnings to runtime i18n.t, but for me this approach feels unnatural, ie. when I import soemthing from macro package and then call runtime methods on it (e.g. i18n.activate).

Doing this in config sounds rather unnatural, to be honest.

It isn't common, but we have to balance tradeoffs of several imperfect solutions. I understand it sounds weird for the first time. However, 99% of the code will import from macro package, 1% (config and language switcher) will import from core module. You gonna set it once and then it'll just work(tm).

The only possible solution would be to transform i18n macro into i18n object, which I'm trying to avoid. The inevitable question is: when should I import i18n from core/config and when from macro? Sure we could add warnings to runtime i18n.t, but for me this approach feels unnatural, ie. when I import soemthing from macro package and then call runtime methods on it (e.g. i18n.activate).

I guess it has to be based on naming imported variable exactly i18n and in that case, you know that you don't import anything. It could be two-phase transform (if that's possible). It would first convert i18n.t to just a t (and friends) while ensuring the import. Second, the phase would common for everything and just do the i18n._ transformation not caring about import being available.

It isn't common, but we have to balance tradeoffs of several imperfect solutions. I understand it sounds weird for the first time. However, 99% of the code will import from macro package, 1% (config and language switcher) will import from core module. You gonna set it once and then it'll just work(tm).

I guess you are right on this, but I believe that the above could be a less fragile approach. I think I would even like to try to tackle that "first phase" so it doesn't disrupt your plans. Obviously, it would be much more efficient to do that transformation in one swoop, but that can be always improved later.

Also, good thing is that migration would be much easier since this wouldn't be a breaking change. Only improved into the form of a function call with id/comments.

Edit: I am thinking that being able to import (or even create) the i18n on file basis gives a better control. What if for some reason you need different settings for a one or few modules instead of a global one? Approach with config would fail here...

Hi,

Not sure if it's the right place to add a link to the new facebook open sourced project, but the "desc" seems to be automatically extracted (that could be interesting too). And the "hashToText" could be related to the message key optimization.

Regards

@ghostd Let's not bloat the V3 requirements more than necessary, there is enough trouble getting it out as it is.

Besides, as much as good it looks on a paper that it will auto internationalize strings in jsx, the Lingui <Trans> beats it as you can wrap whole structures in it. Sure, it's not automatic, but way more powerful imo.

fbt is actually very similar and I would like to try similar approach someday. I checked the demo and with <fbt> you can wrap elements as well. They just do one extra step, when they replace children of fbt with translation at compile time, but something we could do with macros as well.

So, I'm glad they published it because I'll definitely take a look into it. After the v3 is released 😳

Resolved in #499

Was this page helpful?
0 / 5 - 0 ratings