From fbdd88f29ccf0c4fbe957bb07b07d37b0cf892fa Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Thu, 25 May 2023 17:08:44 +0200 Subject: [PATCH 1/2] feat(po-format): add `explicitIdAsDefault` for po-format for easier migration --- packages/format-po/README.md | 15 +++ packages/format-po/src/po.test.ts | 142 +++++++++++++++++++++++++++ packages/format-po/src/po.ts | 42 +++++++- website/docs/releases/migration-4.md | 57 ++++++----- 4 files changed, 227 insertions(+), 29 deletions(-) diff --git a/packages/format-po/README.md b/packages/format-po/README.md index 739c7c41d..daa5bcca9 100644 --- a/packages/format-po/README.md +++ b/packages/format-po/README.md @@ -61,6 +61,21 @@ export type PoFormatterOptions = { * @default false */ printLinguiId?: boolean + + /** + * By default, the po-formatter treats the pair `msgid` + `msgctx` as the source + * for generating an ID by hashing its value. + * + * For messages with explicit IDs, the formatter adds a special comment `js-lingui-explicit-id` as a flag. + * When this flag is present, the formatter will use the `msgid` as-is without any additional processing. + * + * Set this option to true if you exclusively use explicit-ids in your project. + * + * https://lingui.dev/tutorials/react-patterns#using-custom-id + * + * @default false + */ + explicitIdAsDefault?: boolean } ``` diff --git a/packages/format-po/src/po.test.ts b/packages/format-po/src/po.test.ts index 2cfd8dc93..0a83fd111 100644 --- a/packages/format-po/src/po.test.ts +++ b/packages/format-po/src/po.test.ts @@ -122,6 +122,148 @@ describe("pofile format", () => { expect(actual).toMatchObject(catalog) }) + describe("explicitIdAsDefault", () => { + const catalog: CatalogType = { + // with generated id + Dgzql1: { + message: "with generated id", + translation: "", + context: "my context", + }, + + "custom.id": { + message: "with explicit id", + translation: "", + }, + } + + it("should set `js-lingui-generated-id` for messages with generated id when [explicitIdAsDefault: true]", () => { + const format = createFormatter({ + origins: true, + explicitIdAsDefault: true, + }) + + const serialized = format.serialize( + catalog, + defaultSerializeCtx + ) as string + + expect(serialized).toMatchInlineSnapshot(` + msgid "" + msgstr "" + "POT-Creation-Date: 2018-08-27 10:00+0000\\n" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=utf-8\\n" + "Content-Transfer-Encoding: 8bit\\n" + "X-Generator: @lingui/cli\\n" + "Language: en\\n" + + #. js-lingui-generated-id + msgctxt "my context" + msgid "with generated id" + msgstr "" + + msgid "custom.id" + msgstr "" + + `) + + const actual = format.parse(serialized, defaultParseCtx) + expect(actual).toMatchInlineSnapshot(` + { + Dgzql1: { + comments: [ + js-lingui-generated-id, + ], + context: my context, + extra: { + flags: [], + translatorComments: [], + }, + message: with generated id, + obsolete: false, + origin: [], + translation: , + }, + custom.id: { + comments: [], + context: null, + extra: { + flags: [], + translatorComments: [], + }, + obsolete: false, + origin: [], + translation: , + }, + } + `) + }) + + it("should set `js-explicit-id` for messages with explicit id when [explicitIdAsDefault: false]", () => { + const format = createFormatter({ + origins: true, + explicitIdAsDefault: false, + }) + + const serialized = format.serialize( + catalog, + defaultSerializeCtx + ) as string + + expect(serialized).toMatchInlineSnapshot(` + msgid "" + msgstr "" + "POT-Creation-Date: 2018-08-27 10:00+0000\\n" + "MIME-Version: 1.0\\n" + "Content-Type: text/plain; charset=utf-8\\n" + "Content-Transfer-Encoding: 8bit\\n" + "X-Generator: @lingui/cli\\n" + "Language: en\\n" + + msgctxt "my context" + msgid "with generated id" + msgstr "" + + #. js-lingui-explicit-id + msgid "custom.id" + msgstr "" + + `) + + const actual = format.parse(serialized, defaultParseCtx) + expect(actual).toMatchInlineSnapshot(` + { + Dgzql1: { + comments: [], + context: my context, + extra: { + flags: [], + translatorComments: [], + }, + message: with generated id, + obsolete: false, + origin: [], + translation: , + }, + custom.id: { + comments: [ + js-lingui-explicit-id, + ], + context: null, + extra: { + flags: [], + translatorComments: [], + }, + obsolete: false, + origin: [], + translation: , + }, + } + `) + }) + }) + it("should print lingui id if printLinguiId = true", () => { const format = createFormatter({ origins: true, printLinguiId: true }) diff --git a/packages/format-po/src/po.ts b/packages/format-po/src/po.ts index 6cf018f15..01c99652d 100644 --- a/packages/format-po/src/po.ts +++ b/packages/format-po/src/po.ts @@ -43,6 +43,21 @@ export type PoFormatterOptions = { * @default false */ printLinguiId?: boolean + + /** + * By default, the po-formatter treats the pair `msgid` + `msgctx` as the source + * for generating an ID by hashing its value. + * + * For messages with explicit IDs, the formatter adds a special comment `js-lingui-explicit-id` as a flag. + * When this flag is present, the formatter will use the `msgid` as-is without any additional processing. + * + * Set this option to true if you exclusively use explicit-ids in your project. + * + * https://lingui.dev/tutorials/react-patterns#using-custom-id + * + * @default false + */ + explicitIdAsDefault?: boolean } function isGeneratedId(id: string, message: MessageType): boolean { @@ -61,6 +76,7 @@ function getCreateHeaders(language: string): PO["headers"] { } const EXPLICIT_ID_FLAG = "js-lingui-explicit-id" +const GENERATED_ID_FLAG = "js-lingui-generated-id" const serialize = (catalog: CatalogType, options: PoFormatterOptions) => { return Object.keys(catalog).map((id) => { @@ -84,15 +100,24 @@ const serialize = (catalog: CatalogType, options: PoFormatterOptions) => { if (_isGeneratedId) { item.msgid = message.message + if (options.explicitIdAsDefault) { + if (!item.extractedComments.includes(GENERATED_ID_FLAG)) { + item.extractedComments.push(GENERATED_ID_FLAG) + } + } + if (options.printLinguiId) { if (!item.extractedComments.find((c) => c.includes("js-lingui-id"))) { item.extractedComments.push(`js-lingui-id: ${id}`) } } } else { - if (!item.extractedComments.includes(EXPLICIT_ID_FLAG)) { - item.extractedComments.push(EXPLICIT_ID_FLAG) + if (!options.explicitIdAsDefault) { + if (!item.extractedComments.includes(EXPLICIT_ID_FLAG)) { + item.extractedComments.push(EXPLICIT_ID_FLAG) + } } + item.msgid = id } @@ -116,7 +141,10 @@ const serialize = (catalog: CatalogType, options: PoFormatterOptions) => { }) } -function deserialize(items: POItem[]): CatalogType { +function deserialize( + items: POItem[], + options: PoFormatterOptions +): CatalogType { return items.reduce>((catalog, item) => { const message: MessageType = { translation: item.msgstr[0], @@ -133,7 +161,11 @@ function deserialize(items: POItem[]): CatalogType { let id = item.msgid // if generated id, recreate it - if (!item.extractedComments.includes(EXPLICIT_ID_FLAG)) { + if ( + options.explicitIdAsDefault + ? item.extractedComments.includes(GENERATED_ID_FLAG) + : !item.extractedComments.includes(EXPLICIT_ID_FLAG) + ) { id = generateMessageId(item.msgid, item.msgctxt) message.message = item.msgid } @@ -156,7 +188,7 @@ export function formatter(options: PoFormatterOptions = {}): CatalogFormatter { parse(content): CatalogType { const po = PO.parse(content) - return deserialize(po.items) + return deserialize(po.items, options) }, serialize(catalog, ctx): string { diff --git a/website/docs/releases/migration-4.md b/website/docs/releases/migration-4.md index 4c86fdc96..75cf3bb78 100644 --- a/website/docs/releases/migration-4.md +++ b/website/docs/releases/migration-4.md @@ -41,39 +41,48 @@ No migration steps are necessary for components provided by Lingui, such as `Tra ### Hash-based message ID generation and Context feature -The previous implementation had a flaw: there is an original message in the bundle at least 2 times + 1 translation. +Starting from Lingui v4, hash-based IDs are used internally for message lookups. -For the line "Hello world" it'll exist in the source code as ID in i18n call, then as a key in the message catalog, and then as a translation itself. Strings could be very long, not just a couple of words, so this may bring more kB to the bundle. +If you use natural language as an ID in your project, for example: +```ts +const message = t`My Message` +``` +you will benefit significantly from this change. Your bundles will become smaller because the source message will be removed from the bundle in favor of a short generated ID. -A much better option is generating a "stable" ID based on the msg + context as a hash with a fixed length. +If you use natural language as an ID, you don't need to do anything special to migrate. -Hash would be calculated at build time by macros. So macros instead of: +However, if you use explicit IDs, like this: -```js -const message = t({ - context: 'My context', - message: `Hello` -}) +```ts +const message = t({id: "my.message", message: `My Message`}) +``` -// ↓ ↓ ↓ ↓ ↓ ↓ +there are some changes you need to make to your catalogs to migrate properly. In order to distinguish between generated IDs and explicit IDs in the PO format, Lingui adds a special comment for messages with explicit IDs called `js-lingui-explicit-id`. -import { i18n } from "@lingui/core" -const message = i18n._(/*i18n*/{ - context: 'My context', - id: `Hello` -}) +Here's an example of the comment in a PO file: +```gettext +#. js-lingui-explicit-id +msgid "custom.id" +msgstr "" ``` -now generates: +You need to add this comment manually to all your messages with explicit IDs. + +If you exclusively use explicit IDs in your project, you may consider enabling a different processing mode for the PO formatter. This can be done in your Lingui config file: +```ts title="lingui.config.ts" +import { formatter } from '@lingui/po-format' +import { LinguiConfig } from '@lingui/config' -```js -import { i18n } from "@lingui/core" -const message = i18n._(/*i18n*/{ - id: "", - message: `Hello`, -}) +const config: LinguiConfig = { + // ... + format: formatter({ explicitIdAsDefault: true }), +} ``` +Enabling this mode will swap the logic, and the formatter will treat all messages as having explicit IDs without the need for the explicit flag comment. + +You can read more about the motivation behind this change in the [original RFC](https://github.com/lingui/js-lingui/issues/1360) + Also, we've added a possibility to provide a context for the message. For more details, see the [Providing a context for a message](/docs/tutorials/react-patterns.md#providing-a-context-for-a-message). The context feature affects the message ID generation and adds the `msgctxt` parameter in case of the PO catalog format extraction. @@ -137,13 +146,13 @@ Extractor supports TypeScript out of the box. Please delete it from your configu If your extract command looks like: ```bash -NODE_ENV=development lingui-extract +NODE_ENV=development lingui extract ``` Now you can safely change it to just: ```bash -lingui-extract +lingui extract ``` ### Public interface of `ExtractorType` was changed From d0aeb7bb79b6381470ac472376f65816bf4ed3ec Mon Sep 17 00:00:00 2001 From: Timofei Iatsenko Date: Thu, 25 May 2023 18:20:17 +0200 Subject: [PATCH 2/2] docs: add lazy translations migrations notes --- website/docs/releases/migration-4.md | 31 ++++++++++++++ website/docs/tutorials/react-patterns.md | 53 +++++++++++++++++++++++- website/docs/tutorials/react.md | 19 ++++++--- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/website/docs/releases/migration-4.md b/website/docs/releases/migration-4.md index 75cf3bb78..509d7f5e4 100644 --- a/website/docs/releases/migration-4.md +++ b/website/docs/releases/migration-4.md @@ -89,6 +89,37 @@ The context feature affects the message ID generation and adds the `msgctxt` par This also affects the `orderBy` with `messageId` as now the generated id is used when custom id is absent. To avoid confusion, we switched the default `orderBy` to use the source message (`message`) instead. +### Translation outside React components migration + +If you have been using the following pattern in your code: + +```tsx +import { t } from "@lingui/macro" + +const myMsg = t`Hello world!` + +export function Greeting(props: {}) { + return

{t(myMsg)}

+} +``` +You will need to make some changes as this is a misuse of the library that actually worked in v3. + +Due to the changes caused by hash-based message ID feature described earlier, this approach will no longer work. + +Instead, please use [recommended](/docs/tutorials/react-patterns.md#lazy-translations) pattern for such translations: +```tsx +import { t } from "@lingui/macro" +import { useLingui } from "@lingui/react" + +const myMsg = msg`Hello world!` + +export function Greeting(props: {}) { + const { i18n } = useLingui() + + return

{i18n._(myMsg)}

+} +``` + ### Change in generated ICU messages for nested JSX Macros We have made a small change in how Lingui generates ICU messages for nested JSX Macros. We have removed leading spaces from the texts in all cases. diff --git a/website/docs/tutorials/react-patterns.md b/website/docs/tutorials/react-patterns.md index d00f11ce3..4ca1246f9 100644 --- a/website/docs/tutorials/react-patterns.md +++ b/website/docs/tutorials/react-patterns.md @@ -135,12 +135,16 @@ Sometimes you can't use [`Trans`](/docs/ref/macro.md#trans) component, for examp ``` In such case you need to use [`t`](/docs/ref/macro.md#t) macro to wrap message. [`t`](/docs/ref/macro.md#t) is equivalent for [`Trans`](/docs/ref/macro.md#trans), [`plural`](/docs/ref/macro.md#plural) is equivalent to [`Plural`](/docs/ref/macro.md#plural-1). +You also need to use `useLingui` hook to subscribe your component for locale updates. ```jsx import { t } from "@lingui/macro" +import { useLingui } from "@lingui/react" export default function ImageWithCaption() { - return {t`Image + useLingui() + + return {t`Image } ``` @@ -156,8 +160,38 @@ export function alert() { alert(t`...`) } ``` +:::note +The [`t`](/docs/ref/macro.md#t) macro can only be used in a reactive or re-executable context. + +```jsx +import { t } from "@lingui/macro" + +// ❌ Bad! This won't work because the `t` macro is used at the module level. +// The `t` macro returns a string, and once this string is assigned, it won't react to changes. +const alertProps = { + header: t`Alert`, + subHeader: t`Important message`, + message: t`This is an alert!`, + buttons: [t`OK`] +}; + +// ✅ Good! Every time the function is executed, the `t` macro will be re-executed as well, +// and the actual result will be returned. +function getAlertProps() { + return { + header: t`Alert`, + subHeader: t`Important message`, + message: t`This is an alert!`, + buttons: [t`OK`] + } +} +``` + +Another option would be to use the Lazy Translations pattern described in the following paragraph. -## Lazy translations +::: + +## Lazy Translations Messages don't have to be declared at the same code location where they're displayed. Tag a string with the [`defineMessage`](/docs/ref/macro.md#definemessage) macro, and you've created a "message descriptor", which can then be passed around as a variable, and can be displayed as a translated string by passing its `id` to [`Trans`](/docs/ref/macro.md#trans) as its `id` prop: @@ -226,12 +260,15 @@ If you need the prop to be displayed as a string-only translation, you can pass ```jsx import { t } from "@lingui/macro" +import { useLingui } from "@lingui/react" export default function ImageWithCaption(props) { return {props.caption} } export function HappySad(props) { + useLingui() + return
@@ -274,6 +311,7 @@ import { i18n } from "@lingui/core" const welcomeMessage = msg`Open`; +// ❌ Bad! This code won't work export function Welcome() { const buggyWelcome = useMemo(() => { return i18n._(welcomeMessage); @@ -281,4 +319,15 @@ export function Welcome() { return
{buggyWelcome}
; } + +// ✅ Good! `useMemo` has i18n object in the dependency +export function Welcome() { + const { i18n } = useLingui(); + + const welcome = useMemo(() => { + return i18n._(welcomeMessage); + }, [i18n]); + + return
{welcome}
; +} ``` diff --git a/website/docs/tutorials/react.md b/website/docs/tutorials/react.md index db16ea7f1..36dca8410 100644 --- a/website/docs/tutorials/react.md +++ b/website/docs/tutorials/react.md @@ -110,7 +110,7 @@ Let's start with the basics - static messages. These messages don't have any var All we need to make this heading translatable is wrap it in [`Trans`](/docs/ref/macro.md#trans) macro: ```jsx -import { Trans } from '@lingui/macro' +import { Trans } from '@lingui/macro';

Message Inbox

``` @@ -124,7 +124,7 @@ In general, macros are executed at compile time and they transform source code i Under the hood, all JSX macros are transformed into [`Trans`](/docs/ref/react.md#trans) component. Take a look at this short example. This is what we write: ```jsx -import { Trans } from '@lingui/macro' +import { Trans } from '@lingui/macro'; Hello {name} ``` @@ -132,14 +132,23 @@ import { Trans } from '@lingui/macro' And this is how the code is transformed: ```jsx -import { Trans } from '@lingui/react' +import { Trans } from '@lingui/react'; - + ``` -See the difference? [`Trans`](/docs/ref/react.md#trans) component receives `id` prop with a message in ICU MessageFormat syntax. +See the difference? [`Trans`](/docs/ref/react.md#trans) component receives `id` and `message` props with a message in ICU MessageFormat syntax. We could write it manually, but it's just easier and shorter to write JSX as we're used to and let macros to generate message for ourselves. +Another advantage of using macros is that all non-essential properties are dropped in the production build. This results in a significant reduction in the size footprint for internationalization. + +```jsx +// NODE_ENV=production +import { Trans } from '@lingui/react'; + + +``` + ### Extracting messages Back to our project. It's nice to use JSX and let macros generate messages under the hood. Let's check that it actually works correctly.