Merged in chore/update-readmes (pull request #2751)

chore: Update README

* Update readme


Approved-by: Chuma Mcphoy (We Ahead)
Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-09-03 08:54:50 +00:00
parent f7ef58eafa
commit ca408bbbb5
4 changed files with 475 additions and 449 deletions

205
README.md
View File

@@ -1,186 +1,51 @@
# Scandic Monorepo
This is the monorepo for Scandic's web projects.
This is the monorepo for Scandic Hotels' web projects. It includes multiple apps and packages that together make up the Scandic Hotels web ecosystem.
## What's inside?
This repo includes the following packages/apps:
### Apps
### Apps and Packages
Inside the `/apps` directory, you'll find our standalone sites and APIs:
- `scandic-web`: [Next.js](https://nextjs.org/) app for our public website
- `design-system`: a shared library of styles
- `scandic-web`: [Next.js](https://nextjs.org/) app for our public website [scandichotels.com](https://scandichotels.com).
- `scandic-redirect`: Netlify Functions that handle redirects for `scandic-web`.
- `partner-sas`: [Next.js](https://nextjs.org/) app for our partner site [sas.scandichotels.com](https://sas.scandichotels.com). (⚠️ Work in progress ⚠️)
- `redis-api`: API wrapping [Redis](https://redis.io/) that we use for caching.
### Packages
Inside the `/packages` directory, you'll find our shared libraries and utilities:
- `design-system`: A shared library of styles and components used by our React apps.
- `booking-flow`: This is our booking flow that is used by both `scandic-web` and `partner-sas`. It's built to be used by a [Next.js](https://nextjs.org/) app.
- `trpc`: A [tRPC](https://trpc.io/) data layer that connects our apps and packages to the backend APIs.
- `common`: A collection of shared utilities, helpers and types used across both our apps and our packages.
- `typescript-config`: Shared TypeScript configs for all apps and packages.
> A note about dependencies between our apps and packages:
> In general all apps are allowed to depend on any package, but packages have a few caveats:
>
> `design-system` should never import from `booking-flow` or `trpc`.
> `common` should never import from anything except `typescript-config`.
## Getting Started
To get started, clone this repository and run `yarn install` in the root directory.
To get started, clone this repository and run `yarn install` in the root directory. We use [Turbo](https://turborepo.com/) to handle our monorepo tasks.
### Running `scandic-web` locally
See `package.json` for all available scripts, but here's the most commonly used:
To run the `scandic-web` app locally see its [README](./apps/scandic-web/README.md).
- `yarn dev:web`: Start the development server for the `scandic-web` app.
- `yarn dev:sas`: Start the development server for the `partner-sas` app.
- `yarn dev:ds`: Start the development server for the `design-system` package.
- `yarn lint`: Run ESLint and type checking across all apps and packages.
- `yarn test`: Run tests for the codebase.
## Translations
If this is the first time you are running one of our apps you can run `yarn env:web` or `yarn env:sas` to see what environment variables you need for those apps.
### Integration with Lokalise
For more details see the respective apps and packages' README files.
> For more information read about [the workflow below](#markdown-header-the-workflow).
## More documentation
#### Message extraction from codebase
Extracts the messages from calls to `intl.formatMessage()` and other supported methods on `intl` across our apps and packages.
Running the following command will generate a JSON file at `./scripts/i18n/extracted.json`. The format of this file is for consumption by Lokalise. This JSON file is what gets uploaded to Lokalise.
```bash
yarn i18n:extract
```
#### Checking for changes between codebase and Lokalise
> _NOTE_: Diff only considers the English language.
It is recommended to download the latest labels from Lokalise to make sure you have the latest before diffing. See below.
- Run the message extraction above.
- Run `yarn i18n:diff`
#### Message upload to Lokalise
Set the environment variable `LOKALISE_API_KEY` to the API key for Lokalise in `.env.local`.
Running the following command will upload the JSON file, that was generated by extraction, to Lokalise.
It supports the different upload phases from Lokalise meaning that once this command completes the messages are available for translation in Lokalise.
```bash
yarn i18n:upload
```
#### Message download from Lokalise
Set the environment variable `LOKALISE_API_KEY` to the API key for Lokalise in `.env.local`.
Running the following command will download the translated assets from Lokalise to your local working copy.
_DOCUMENTATION PENDING FOR FULL WORKFLOW._
```bash
yarn i18n:download
```
#### Message compilation
Compiles the assets that were downloaded from Lokalise into the dictionaries used by the codebase.
_DOCUMENTATION PENDING FOR FULL WORKFLOW._
```bash
yarn i18n:compile
```
### Message distribution
Distributes the compiled dictionaries to the apps that need them.
```
yarn i18n:distribute
```
#### Convenience script targets
Extract and upload: `yarn i18n:push`
Download, compile and distribute: `yarn i18n:pull`
Extract, upload, download and compile (push && pull): `yarn i18n:sync`
### The workflow
We use the following technical stack to handle translations of UI labels.
- [react-intl](https://formatjs.io/docs/getting-started/installation/): Library for handling translations in the codebase.
- [Lokalise](https://lokalise.com/): TMS (Translations Management System) for handling the translations from the editor side.
A translation is usually called a "message" in the context of i18n with react-intl.
In the codebase we use the [Imperative API](https://formatjs.github.io/docs/react-intl/api/) of react-intl. This allows us to use the same patterns and rules regardless of where we are formatting messages (JSX, data, utilities, etc). We do not use the [React components](https://formatjs.github.io/docs/react-intl/components/) of react-intl for the same reason, they would only work in JSX and would possibly differ in implementation and patterns with other parts of the code.
To define messages we primarily invoke `intl.formatMessage` (but `intl` has other methods for other purposes too!). We take care not to name the message, we do that by **not** passing the `id` attribute to `formatMessage`. The reason for this is that we also have implemented the [@formatjs/cli](https://formatjs.io/docs/tooling/cli) and the SWC plugin. Due to the SWC plugin being a fairly new project and also due to version mismatching reasons, we are using a pinned version of the SWC plugin. Once we upgrade to Next.js 15 we can upgrade the SWC plugin too and skip pinning it. Together, these two are responsible for allowing us to extract defined messages in our codebase. This optimizes the developer workflow by freeing up developers from having to name things and to not be wary of duplicates/collisions as they will be handled by the extraction tool and Lokalise.
Example of a simple message:
```typescript
const myMessage = intl.formatMessage({
defaultMessage: "Hello from the docs!",
})
```
In cases where extra information is helpful to the translators, e.g. short sentences which are hard to translate without context or we are dealing with homographs (words that are spelled the same but have different meanings), we can also specify a `description` key in the `formatMessage` call. This allows the tooling to extract all the different permutations of the declared message along with their respective descriptions. The same sentence/word will show up multiple times in Lokalise with different contexts, allowing them to be translated indivudually. The description is intended to assist translators using Lokalise by providing context or additional information. The value is an object with the following structure:
```typescript
description = string | {
context?: string // natural language string providing context for translators in Lokalise (optional)
limit?: number // character limit for the key enforced by Lokalise (optional)
tags?: string // comma separated string (optional)
}
```
Examples of a homograph with different context:
```typescript
const myMessage1 = intl.formatMessage({
defaultMessage: "Book",
description: "The action to reserve a room",
})
const myMessage2 = intl.formatMessage({
defaultMessage: "Book",
description: "A physical book that you can read",
})
```
Examples with a (contrived) sentence:
```typescript
const myMessage1 = intl.formatMessage({
defaultMessage: "He gave her a ring!",
description: "A man used a phone to call a woman",
})
const myMessage2 = intl.formatMessage({
defaultMessage: "He gave her a ring!",
description: "A man gave a woman a piece of jewelry",
})
```
#### Diagram
A diagram showing the high level workflow of translations. It is currently a manual process syncing messages to and from Lokalise into the codebase.
[![](https://mermaid.ink/img/pako:eNqdVM1u2zAMfhVC5_YFcthhaw8bug5Ye2rcA2PRthBZMiRqWVD03UfFipwtbVBMF1HkJ_Ljj_SiWq9JrVRn_a4dMDDc_Wxc40BWTJs-4DRAGwiZYP1l3jP2eYbkdUO_yPqJQpzt60YtqkY9w_U1aOqMowgjxYi9CMYBDwQ5-gYjCeYTfDa8Se2WuPqpGnEzBySnz-jRbw7YMqxvi_AuwQJ4i-GILqG1ewjJQWyDmRgYQ0-yDcjHIBdSQKchTdajjhAoJsvG9fDt4cc9sIc7v0VrSqbHw8LnqLmYqIBdtIdWPFbxn2RvtWEfYrWL76Iqie582EbYGV78Ge_iX7xOb3-ImXFMImVmX6v4bhsq5H8aof3OzUWuneiCH5cCCxd_YbhOg5_PV17n0MyLg-l7oQmbZKw-dFuTtHtfipkmLUh9XtR7Ymu6_WnconqrpOWt5Ytl5AqkzHY21DmYTctYZGNtxWxUV2qkMKLR8spfsq5RUpxR-rkSUWPYNqpxr4LDxP5h71q14pDoSgWf-kGtOrRRTnN-NwbF-Vi1E7on75czHWbt-_ypHP6W1z--V4Yq?type=png)](https://mermaid.live/edit#pako:eNqdVM1u2zAMfhVC5_YFcthhaw8bug5Ye2rcA2PRthBZMiRqWVD03UfFipwtbVBMF1HkJ_Ljj_SiWq9JrVRn_a4dMDDc_Wxc40BWTJs-4DRAGwiZYP1l3jP2eYbkdUO_yPqJQpzt60YtqkY9w_U1aOqMowgjxYi9CMYBDwQ5-gYjCeYTfDa8Se2WuPqpGnEzBySnz-jRbw7YMqxvi_AuwQJ4i-GILqG1ewjJQWyDmRgYQ0-yDcjHIBdSQKchTdajjhAoJsvG9fDt4cc9sIc7v0VrSqbHw8LnqLmYqIBdtIdWPFbxn2RvtWEfYrWL76Iqie582EbYGV78Ge_iX7xOb3-ImXFMImVmX6v4bhsq5H8aof3OzUWuneiCH5cCCxd_YbhOg5_PV17n0MyLg-l7oQmbZKw-dFuTtHtfipkmLUh9XtR7Ymu6_WnconqrpOWt5Ytl5AqkzHY21DmYTctYZGNtxWxUV2qkMKLR8spfsq5RUpxR-rkSUWPYNqpxr4LDxP5h71q14pDoSgWf-kGtOrRRTnN-NwbF-Vi1E7on75czHWbt-_ypHP6W1z--V4Yq)
The following is a diagram showing how all the parts interact with each other. Note that interacting with Lokalise in any way does **NOT** trigger a build and deploy. A manual action by a developer is required to deploy the latest translations from Lokalise. (Once the manual process reaches maturity we might try and automate it)
[![](https://mermaid.ink/img/pako:eNqdUsFqwzAM_RXh8_IDYewwusvoOlhvIxc1VhITxwq2vCyU_vuchKVlLRvMF0uypaf3eEdVsiaVq8ryUDboBbZvhYN0nrQR9gGyDAb2bYDBSAPi0QWLYtgFuM-yB9hyi9YEWro29EGWe1oaNVXGUYCOQsA6BcaBNAQT6AEDwTTg0cghli3JrQkduojWjuCjg1B60wsI-prS1aAAfaaNSvkFAp2G2FtGHeB5_7oD4XVnuCZw8fQnuObBLYNX9Mpzd55hXAK7Inxm-B_GJXe9sZeifq9B-gf8DXXXdIISb-p6gj1EY_WslKYk1Th37kisqcalT92pjnyHRiezHKdaoRKxjgqVp1CjbwtVuFP6h1F4P7pS5eIj3SnPsW5UXqENKYu9RqGNwdpjt1Z7dO_M55xm870s3pwtevoCSUvs9A?type=png)](https://mermaid.live/edit#pako:eNqdUsFqwzAM_RXh8_IDYewwusvoOlhvIxc1VhITxwq2vCyU_vuchKVlLRvMF0uypaf3eEdVsiaVq8ryUDboBbZvhYN0nrQR9gGyDAb2bYDBSAPi0QWLYtgFuM-yB9hyi9YEWro29EGWe1oaNVXGUYCOQsA6BcaBNAQT6AEDwTTg0cghli3JrQkduojWjuCjg1B60wsI-prS1aAAfaaNSvkFAp2G2FtGHeB5_7oD4XVnuCZw8fQnuObBLYNX9Mpzd55hXAK7Inxm-B_GJXe9sZeifq9B-gf8DXXXdIISb-p6gj1EY_WslKYk1Th37kisqcalT92pjnyHRiezHKdaoRKxjgqVp1CjbwtVuFP6h1F4P7pS5eIj3SnPsW5UXqENKYu9RqGNwdpjt1Z7dO_M55xm870s3pwtevoCSUvs9A)
## Material Symbols
We download the font file from Google Fonts service and host it ourselves.
### Configuration
We use the following configuration:
- FILL axis: 0..1
- wght axis: 400
- GRAD axis: 0
- opsz axis: 24
More info at https://developers.google.com/fonts/docs/material_symbols#optimize_the_icon_font
### Optimization
We optimize the font size by only including the icons we use in the repository.
Read more at: https://developers.google.com/fonts/docs/material_symbols#use_in_web
### Modifying icons
1. Update the list of icons to include in `scripts/material-symbols-update.mjs`.
2. Run `yarn run icons:update` in monorepo root.
- [Icons](./docs/icons.md)
- [Translations (i18n)](./docs/translations.md)

View File

@@ -15,282 +15,3 @@ In order to not prop drill that all the way from a page we use React's `cache` i
For this to work we must set the language with `setLang` on the topmost layout
This was inspired by [server-only-context](https://github.com/manvalls/server-only-context)
## Translations a.k.a. UI labels a.k.a. Lokalise
### Quickstart: How to use react-intl in the codebase
> For more information read about [the workflow below](#markdown-header-the-workflow).
- **Do not destructure `formatMessage` from either `getIntl()` or `useIntl()`.**
❌ Do not do this:
```typescript
const { formatMessage } = useIntl()
const message = formatMessage(...)
```
✅ instead do this:
```typescript
const intl = useIntl()
const message = intl.formatMessage(...)
```
❌ Do not do this:
```typescript
const { formatMessage } = await getIntl()
const message = formatMessage(...)
```
✅ instead do this:
```typescript
const intl = await getIntl()
const message = intl.formatMessage(...)
```
- **Do not pass variables as defaultMessage.**
The `defaultMessage` needs to be a literal string so that the tooling can properly find and extract defined messages. Do not use template strings with variable interpolation either.
❌ Do not do this:
```typescript
const data = await getSomeData()
...
const message = intl.formatMessage({
defaultMessage: data.type,
})
...
const message = intl.formatMessage({
defaultMessage: `Certification: ${data.type}`,
})
```
✅ instead do this:
This is a hard one to give a general solution/rule for, but in essence it should use either use a "switch approach" or `defineMessage/defineMessages` ([docs](https://formatjs.github.io/docs/react-intl/api/#definemessagesdefinemessage)) in some way.
The most common reason for this scenario is that the data contains one or several words/sentences we want translated.
The preferred solution is to use a "switch approach" (or something equivalent), checking some property of the entity to decide what message to use.
- It is explicit about what cases are supported: this helps finding bugs by lowering complexity by making it easier to find where a string is used.
- The declaration is coupled with the usage: keeping our messages up to date and clutter free over time.
- The formatjs eslint plugin helps enforce proper usage.
- TypeScript will force us to keep the list up to date with the typings of `data.type`. Preferably ALL data points of this manner should be an enum or an array of strings `as const` (or equivalent) for the best TS support here.
```typescript
...
const data = await getSomeData()
...
let message = intl.formatMessage({defaultMessage: 'N/A'})) // or some other default/"no match" message
switch (data.type) {
case "Restaurant":
message = intl.formatMessage({defaultMessage: "Restaurant"})
break;
case "Bar":
message = intl.formatMessage({defaultMessage: "Bar"})
break;
case "Pool":
message = intl.formatMessage({defaultMessage: "Pool"})
break;
case "Some certification":
message = intl.formatMessage({defaultMessage: "Certification: {name}"}, { name: "Some certification"})
break;
default:
// TS will throw if it reaches here if typings for `data.type` are properly defined and the above cases are exhaustive.
// This will help us keep messages up to date.
const type: never = data.type
console.warn(`Unsupported type given: ${type}`)
}
```
If the above is not possible the escape hatch is using something like the following. Avoid using this because:
- This decouples the message declaration from the message consumption: causing stale messages to linger around and clutter up the codebase.
- It makes it a lot harder to find where a string is being used in the codebase.
- The formatjs eslint plugin is unable to enforce placeholders: this decreases confidence in our messages and potentially hiding bugs.
```typescript
import { defineMessages } from "react-intl"
...
const data = await getSomeData()
...
const restaurantMessage = defineMessage({
defaultMessage: "Restaurant",
})
const barMessage = defineMessage({
defaultMessage: "Bar",
})
const poolMessage = defineMessage({
defaultMessage: "Pool",
})
// OR
const messages = defineMessages({
restaurant: {
defaultMessage: "Restaurant",
},
bar: {
defaultMessage: "Bar",
},
pool: {
defaultMessage: "Pool",
}
})
...
return (
<p>{intl.formatMessage(messages.restaurant)}</p> // or .bar or .pool, etc.
)
...
// Since calls to defineMessage/defineMessages get their messages extracted,
// technically you can do the following instead of accessing the key like above.
// But it is not encouraged as this decoupling leads to had to track usage and decay over time:
const message = intl.formatMessage({
// eslint-disable-next-line formatjs/enforce-default-message
defaultMessage: data.type, // data.type === "Restaurant" | "Bar" | "Pool"
})
```
- **Do not use defaultMessage key as an alias.**
The `defaultMessage` should be the actual message you want to display (and send to Lokalise). It must be written in American/US English.
❌ Do not do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "some.alias",
})
```
✅ instead do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "The real message is here in US English",
})
```
- **Do not use conditionals when defining messages.**
❌ Do not do this:
```typescript
const message = intl.formatMessage({
defaultMessage: someVariable ? "Variable is truthy" : "Variables i falsey",
})
```
✅ instead do this:
```typescript
const truthyMessage = intl.formatMessage({
defaultMessage: "Variable is truthy",
})
const falseyMessage = intl.formatMessage({
defaultMessage: "Variable is falsey",
})
const message = someVariable ? truthyMessage : falseyMessage
```
- **Avoid using ICU special words as placeholder names**
Some words have meaning in ICU when dealing with placeholders and such. To avoid bugs and confusion do not use them as placehodler names.
Avoid (list is not exhaustive):
- `{number}`
- `{integer}`
- `{plural}`
- `{date}`
- `{time}`
- `{select}`
- `{choice}`
- `{other}`
Also do not use the current variable name in scope as the placeholder name (unless the variable is named following the below rules). It will confuse translators.
❌ Do not do this:
```typescript
const number = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {number}",
},
{
number,
}
)
```
nor this
```typescript
const goodNameForVarButNotForPlaceholder = getValueSomeWay()
...
const message = intl.formatMessage({
defaultMessage: "The number is {goodNameForVarButNotForPlaceholder}",
}, {
goodNameForVarButNotForPlaceholder
})
```
✅ instead do this:
Prefer a placeholder name that gives context to the message when reading it without the context of the code
```typescript
const goodNameForVarButNotForPlaceholder = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {membershipNumber}",
},
{
membershipNumber: goodNameForVarButNotForPlaceholder,
}
)
```
or if context is hard to give, use generic words like `value`, `count`, `amount`, etc.
```typescript
const number = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {value}",
},
{
value: number,
}
)
```
- Do not give id to messages.
The eslint plugin will automatically fix this (removes the id on save/fix).
❌ Do not do this:
```typescript
const message = intl.formatMessage({
id: "some-id",
defaultMessage: "This is a message",
})
```
✅ instead do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "This is a message",
})
```

25
docs/icons.md Normal file
View File

@@ -0,0 +1,25 @@
# Material Symbols
We download the font file from Google Fonts service and host it ourselves.
## Configuration
We use the following configuration:
- FILL axis: 0..1
- wght axis: 400
- GRAD axis: 0
- opsz axis: 24
More info at https://developers.google.com/fonts/docs/material_symbols#optimize_the_icon_font
## Optimization
We optimize the font size by only including the icons we use in the repository.
Read more at: https://developers.google.com/fonts/docs/material_symbols#use_in_web
## Modifying icons
1. Update the list of icons to include in `scripts/material-symbols-update.mjs`.
2. Run `yarn run icons:update` in monorepo root.

415
docs/translations.md Normal file
View File

@@ -0,0 +1,415 @@
# Translations
## Integration with Lokalise
We use Lokalise as our third party provider for managing translations.
For most cases you should be able to just run `yarn i18n:sync` to sync translations between Lokalise and our apps, but it might be beneficial to understand what happens behind the scenes:
### Message extraction from codebase
Extracts the messages from calls to `intl.formatMessage()` and other supported methods on `intl` across our apps and packages.
Running the following command will generate a JSON file at `./scripts/i18n/extracted.json`. The format of this file is for consumption by Lokalise. This JSON file is what gets uploaded to Lokalise.
```bash
yarn i18n:extract
```
### Checking for changes between codebase and Lokalise
> _NOTE_: Diff only considers the English language.
It is recommended to download the latest labels from Lokalise to make sure you have the latest before diffing. See below.
- Run the message extraction above.
- Run `yarn i18n:diff`
### Message upload to Lokalise
Set the environment variable `LOKALISE_API_KEY` to the API key for Lokalise in `.env.local`.
Running the following command will upload the JSON file, that was generated by extraction, to Lokalise.
It supports the different upload phases from Lokalise meaning that once this command completes the messages are available for translation in Lokalise.
```bash
yarn i18n:upload
```
### Message download from Lokalise
Set the environment variable `LOKALISE_API_KEY` to the API key for Lokalise in `.env.local`.
Running the following command will download the translated assets from Lokalise to your local working copy.
_DOCUMENTATION PENDING FOR FULL WORKFLOW._
```bash
yarn i18n:download
```
### Message compilation
Compiles the assets that were downloaded from Lokalise into the dictionaries used by the codebase.
_DOCUMENTATION PENDING FOR FULL WORKFLOW._
```bash
yarn i18n:compile
```
## Message distribution
Distributes the compiled dictionaries to the apps that need them.
```
yarn i18n:distribute
```
### Convenience script targets
Extract and upload: `yarn i18n:push`
Download, compile and distribute: `yarn i18n:pull`
Extract, upload, download and compile (push && pull): `yarn i18n:sync`
## The workflow
We use the following technical stack to handle translations of UI labels.
- [react-intl](https://formatjs.io/docs/getting-started/installation/): Library for handling translations in the codebase.
- [Lokalise](https://lokalise.com/): TMS (Translations Management System) for handling the translations from the editor side.
A translation is usually called a "message" in the context of i18n with react-intl.
In the codebase we use the [Imperative API](https://formatjs.github.io/docs/react-intl/api/) of react-intl. This allows us to use the same patterns and rules regardless of where we are formatting messages (JSX, data, utilities, etc). We do not use the [React components](https://formatjs.github.io/docs/react-intl/components/) of react-intl for the same reason, they would only work in JSX and would possibly differ in implementation and patterns with other parts of the code.
To define messages we primarily invoke `intl.formatMessage` (but `intl` has other methods for other purposes too!). We take care not to name the message, we do that by **not** passing the `id` attribute to `formatMessage`. The reason for this is that we also have implemented the [@formatjs/cli](https://formatjs.io/docs/tooling/cli) and the SWC plugin. Due to the SWC plugin being a fairly new project and also due to version mismatching reasons, we are using a pinned version of the SWC plugin. Once we upgrade to Next.js 15 we can upgrade the SWC plugin too and skip pinning it. Together, these two are responsible for allowing us to extract defined messages in our codebase. This optimizes the developer workflow by freeing up developers from having to name things and to not be wary of duplicates/collisions as they will be handled by the extraction tool and Lokalise.
Example of a simple message:
```typescript
const myMessage = intl.formatMessage({
defaultMessage: "Hello from the docs!",
})
```
In cases where extra information is helpful to the translators, e.g. short sentences which are hard to translate without context or we are dealing with homographs (words that are spelled the same but have different meanings), we can also specify a `description` key in the `formatMessage` call. This allows the tooling to extract all the different permutations of the declared message along with their respective descriptions. The same sentence/word will show up multiple times in Lokalise with different contexts, allowing them to be translated indivudually. The description is intended to assist translators using Lokalise by providing context or additional information. The value is an object with the following structure:
```typescript
description = string | {
context?: string // natural language string providing context for translators in Lokalise (optional)
limit?: number // character limit for the key enforced by Lokalise (optional)
tags?: string // comma separated string (optional)
}
```
Examples of a homograph with different context:
```typescript
const myMessage1 = intl.formatMessage({
defaultMessage: "Book",
description: "The action to reserve a room",
})
const myMessage2 = intl.formatMessage({
defaultMessage: "Book",
description: "A physical book that you can read",
})
```
Examples with a (contrived) sentence:
```typescript
const myMessage1 = intl.formatMessage({
defaultMessage: "He gave her a ring!",
description: "A man used a phone to call a woman",
})
const myMessage2 = intl.formatMessage({
defaultMessage: "He gave her a ring!",
description: "A man gave a woman a piece of jewelry",
})
```
### Diagram
A diagram showing the high level workflow of translations. It is currently a manual process syncing messages to and from Lokalise into the codebase.
[![](https://mermaid.ink/img/pako:eNqdVM1u2zAMfhVC5_YFcthhaw8bug5Ye2rcA2PRthBZMiRqWVD03UfFipwtbVBMF1HkJ_Ljj_SiWq9JrVRn_a4dMDDc_Wxc40BWTJs-4DRAGwiZYP1l3jP2eYbkdUO_yPqJQpzt60YtqkY9w_U1aOqMowgjxYi9CMYBDwQ5-gYjCeYTfDa8Se2WuPqpGnEzBySnz-jRbw7YMqxvi_AuwQJ4i-GILqG1ewjJQWyDmRgYQ0-yDcjHIBdSQKchTdajjhAoJsvG9fDt4cc9sIc7v0VrSqbHw8LnqLmYqIBdtIdWPFbxn2RvtWEfYrWL76Iqie582EbYGV78Ge_iX7xOb3-ImXFMImVmX6v4bhsq5H8aof3OzUWuneiCH5cCCxd_YbhOg5_PV17n0MyLg-l7oQmbZKw-dFuTtHtfipkmLUh9XtR7Ymu6_WnconqrpOWt5Ytl5AqkzHY21DmYTctYZGNtxWxUV2qkMKLR8spfsq5RUpxR-rkSUWPYNqpxr4LDxP5h71q14pDoSgWf-kGtOrRRTnN-NwbF-Vi1E7on75czHWbt-_ypHP6W1z--V4Yq?type=png)](https://mermaid.live/edit#pako:eNqdVM1u2zAMfhVC5_YFcthhaw8bug5Ye2rcA2PRthBZMiRqWVD03UfFipwtbVBMF1HkJ_Ljj_SiWq9JrVRn_a4dMDDc_Wxc40BWTJs-4DRAGwiZYP1l3jP2eYbkdUO_yPqJQpzt60YtqkY9w_U1aOqMowgjxYi9CMYBDwQ5-gYjCeYTfDa8Se2WuPqpGnEzBySnz-jRbw7YMqxvi_AuwQJ4i-GILqG1ewjJQWyDmRgYQ0-yDcjHIBdSQKchTdajjhAoJsvG9fDt4cc9sIc7v0VrSqbHw8LnqLmYqIBdtIdWPFbxn2RvtWEfYrWL76Iqie582EbYGV78Ge_iX7xOb3-ImXFMImVmX6v4bhsq5H8aof3OzUWuneiCH5cCCxd_YbhOg5_PV17n0MyLg-l7oQmbZKw-dFuTtHtfipkmLUh9XtR7Ymu6_WnconqrpOWt5Ytl5AqkzHY21DmYTctYZGNtxWxUV2qkMKLR8spfsq5RUpxR-rkSUWPYNqpxr4LDxP5h71q14pDoSgWf-kGtOrRRTnN-NwbF-Vi1E7on75czHWbt-_ypHP6W1z--V4Yq)
The following is a diagram showing how all the parts interact with each other. Note that interacting with Lokalise in any way does **NOT** trigger a build and deploy. A manual action by a developer is required to deploy the latest translations from Lokalise. (Once the manual process reaches maturity we might try and automate it)
[![](https://mermaid.ink/img/pako:eNqdUsFqwzAM_RXh8_IDYewwusvoOlhvIxc1VhITxwq2vCyU_vuchKVlLRvMF0uypaf3eEdVsiaVq8ryUDboBbZvhYN0nrQR9gGyDAb2bYDBSAPi0QWLYtgFuM-yB9hyi9YEWro29EGWe1oaNVXGUYCOQsA6BcaBNAQT6AEDwTTg0cghli3JrQkduojWjuCjg1B60wsI-prS1aAAfaaNSvkFAp2G2FtGHeB5_7oD4XVnuCZw8fQnuObBLYNX9Mpzd55hXAK7Inxm-B_GJXe9sZeifq9B-gf8DXXXdIISb-p6gj1EY_WslKYk1Th37kisqcalT92pjnyHRiezHKdaoRKxjgqVp1CjbwtVuFP6h1F4P7pS5eIj3SnPsW5UXqENKYu9RqGNwdpjt1Z7dO_M55xm870s3pwtevoCSUvs9A?type=png)](https://mermaid.live/edit#pako:eNqdUsFqwzAM_RXh8_IDYewwusvoOlhvIxc1VhITxwq2vCyU_vuchKVlLRvMF0uypaf3eEdVsiaVq8ryUDboBbZvhYN0nrQR9gGyDAb2bYDBSAPi0QWLYtgFuM-yB9hyi9YEWro29EGWe1oaNVXGUYCOQsA6BcaBNAQT6AEDwTTg0cghli3JrQkduojWjuCjg1B60wsI-prS1aAAfaaNSvkFAp2G2FtGHeB5_7oD4XVnuCZw8fQnuObBLYNX9Mpzd55hXAK7Inxm-B_GJXe9sZeifq9B-gf8DXXXdIISb-p6gj1EY_WslKYk1Th37kisqcalT92pjnyHRiezHKdaoRKxjgqVp1CjbwtVuFP6h1F4P7pS5eIj3SnPsW5UXqENKYu9RqGNwdpjt1Z7dO_M55xm870s3pwtevoCSUvs9A)
# Do's and Don'ts: How to use react-intl in the codebase
- **Do not destructure `formatMessage` from either `getIntl()` or `useIntl()`.**
❌ Do not do this:
```typescript
const { formatMessage } = useIntl()
const message = formatMessage(...)
```
✅ instead do this:
```typescript
const intl = useIntl()
const message = intl.formatMessage(...)
```
❌ Do not do this:
```typescript
const { formatMessage } = await getIntl()
const message = formatMessage(...)
```
✅ instead do this:
```typescript
const intl = await getIntl()
const message = intl.formatMessage(...)
```
- **Do not pass variables as defaultMessage.**
The `defaultMessage` needs to be a literal string so that the tooling can properly find and extract defined messages. Do not use template strings with variable interpolation either.
❌ Do not do this:
```typescript
const data = await getSomeData()
...
const message = intl.formatMessage({
defaultMessage: data.type,
})
...
const message = intl.formatMessage({
defaultMessage: `Certification: ${data.type}`,
})
```
✅ instead do this:
This is a hard one to give a general solution/rule for, but in essence it should use either use a "switch approach" or `defineMessage/defineMessages` ([docs](https://formatjs.github.io/docs/react-intl/api/#definemessagesdefinemessage)) in some way.
The most common reason for this scenario is that the data contains one or several words/sentences we want translated.
The preferred solution is to use a "switch approach" (or something equivalent), checking some property of the entity to decide what message to use.
- It is explicit about what cases are supported: this helps finding bugs by lowering complexity by making it easier to find where a string is used.
- The declaration is coupled with the usage: keeping our messages up to date and clutter free over time.
- The formatjs eslint plugin helps enforce proper usage.
- TypeScript will force us to keep the list up to date with the typings of `data.type`. Preferably ALL data points of this manner should be an enum or an array of strings `as const` (or equivalent) for the best TS support here.
```typescript
...
const data = await getSomeData()
...
let message = intl.formatMessage({defaultMessage: 'N/A'})) // or some other default/"no match" message
switch (data.type) {
case "Restaurant":
message = intl.formatMessage({defaultMessage: "Restaurant"})
break;
case "Bar":
message = intl.formatMessage({defaultMessage: "Bar"})
break;
case "Pool":
message = intl.formatMessage({defaultMessage: "Pool"})
break;
case "Some certification":
message = intl.formatMessage({defaultMessage: "Certification: {name}"}, { name: "Some certification"})
break;
default:
// TS will throw if it reaches here if typings for `data.type` are properly defined and the above cases are exhaustive.
// This will help us keep messages up to date.
const type: never = data.type
console.warn(`Unsupported type given: ${type}`)
}
```
If the above is not possible the escape hatch is using something like the following. Avoid using this because:
- This decouples the message declaration from the message consumption: causing stale messages to linger around and clutter up the codebase.
- It makes it a lot harder to find where a string is being used in the codebase.
- The formatjs eslint plugin is unable to enforce placeholders: this decreases confidence in our messages and potentially hiding bugs.
```typescript
import { defineMessages } from "react-intl"
...
const data = await getSomeData()
...
const restaurantMessage = defineMessage({
defaultMessage: "Restaurant",
})
const barMessage = defineMessage({
defaultMessage: "Bar",
})
const poolMessage = defineMessage({
defaultMessage: "Pool",
})
// OR
const messages = defineMessages({
restaurant: {
defaultMessage: "Restaurant",
},
bar: {
defaultMessage: "Bar",
},
pool: {
defaultMessage: "Pool",
}
})
...
return (
<p>{intl.formatMessage(messages.restaurant)}</p> // or .bar or .pool, etc.
)
...
// Since calls to defineMessage/defineMessages get their messages extracted,
// technically you can do the following instead of accessing the key like above.
// But it is not encouraged as this decoupling leads to had to track usage and decay over time:
const message = intl.formatMessage({
// eslint-disable-next-line formatjs/enforce-default-message
defaultMessage: data.type, // data.type === "Restaurant" | "Bar" | "Pool"
})
```
- **Do not use defaultMessage key as an alias.**
The `defaultMessage` should be the actual message you want to display (and send to Lokalise). It must be written in American/US English.
❌ Do not do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "some.alias",
})
```
✅ instead do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "The real message is here in US English",
})
```
- **Do not use conditionals when defining messages.**
❌ Do not do this:
```typescript
const message = intl.formatMessage({
defaultMessage: someVariable ? "Variable is truthy" : "Variables i falsey",
})
```
✅ instead do this:
```typescript
const truthyMessage = intl.formatMessage({
defaultMessage: "Variable is truthy",
})
const falseyMessage = intl.formatMessage({
defaultMessage: "Variable is falsey",
})
const message = someVariable ? truthyMessage : falseyMessage
```
- **Avoid using ICU special words as placeholder names**
Some words have meaning in ICU when dealing with placeholders and such. To avoid bugs and confusion do not use them as placehodler names.
Avoid (list is not exhaustive):
- `{number}`
- `{integer}`
- `{plural}`
- `{date}`
- `{time}`
- `{select}`
- `{choice}`
- `{other}`
Also do not use the current variable name in scope as the placeholder name (unless the variable is named following the below rules). It will confuse translators.
❌ Do not do this:
```typescript
const number = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {number}",
},
{
number,
}
)
```
nor this
```typescript
const goodNameForVarButNotForPlaceholder = getValueSomeWay()
...
const message = intl.formatMessage({
defaultMessage: "The number is {goodNameForVarButNotForPlaceholder}",
}, {
goodNameForVarButNotForPlaceholder
})
```
✅ instead do this:
Prefer a placeholder name that gives context to the message when reading it without the context of the code
```typescript
const goodNameForVarButNotForPlaceholder = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {membershipNumber}",
},
{
membershipNumber: goodNameForVarButNotForPlaceholder,
}
)
```
or if context is hard to give, use generic words like `value`, `count`, `amount`, etc.
```typescript
const number = getValueSomeWay()
...
const message = intl.formatMessage(
{
defaultMessage: "The number is {value}",
},
{
value: number,
}
)
```
- Do not give id to messages.
The eslint plugin will automatically fix this (removes the id on save/fix).
❌ Do not do this:
```typescript
const message = intl.formatMessage({
id: "some-id",
defaultMessage: "This is a message",
})
```
✅ instead do this:
```typescript
const message = intl.formatMessage({
defaultMessage: "This is a message",
})
```