facebook / facebook/astryx

docs: update Contributing-with-AI-Assistants wiki with i18n patterns

Open
#4,017 0 comments 0 reactions 1 assignee Claimed by @nynexman4464 View on GitHub
documentation
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

## Motivation

The i18n system is landing (RFC #3641, implementation #3765 → #4016). The [Contributing with AI Assistants](https://github.com/facebook/astryx/wiki/Contributing-with-AI-Assistants) wiki page should teach the correct patterns so agents produce right-first-try code, not just discover the rules via lint failure.

Two lint rules catch violations at author time:
- `@astryx/no-hardcoded-i18n-string` — flags hardcoded English on user-facing props
- `@astryx/i18n-key-format` — enforces camelCase and `@astryx.*` namespace on catalog keys

Those cover the mechanical mistakes. The wiki should cover the design decisions that don't show up in a lint error message — where to put keys, how to format defaultMessages, when to reuse a key vs. split it.

## Ownership

**We** (the i18n stack authors) will paste the patch below into the wiki after PRs #3765 → #4016 merge, so the wiki lands with the code that gives it teeth. Filed as a tracking issue because wikis don't take PRs.

## Proposed patch — new subsection under "Common Patterns from Closed PRs"

Insert after "Using raw values instead of tokens" (currently the last item):

```md
### Hardcoded English strings in component APIs

> "Add default `label='Close'` to the dialog dismiss button"

Closed. Astryx components are localized — every user-facing string flows through `useTranslator()` and lives in `packages/core/locales/en.json`. Two ESLint rules enforce the machinery: `@astryx/no-hardcoded-i18n-string` catches hardcoded strings on user-facing props; `@astryx/i18n-key-format` catches malformed catalog keys.

Here are the patterns that come up over and over — get them right the first time.

**1. Catalog entries have a specific shape.** Every entry in `packages/core/locales/en.json` is a React Intl JSON object:

```json
"@astryx.dialog.close": {
"defaultMessage": "Close",
"description": "Aria label on the Dialog dismiss button. Screen reader users hear this — keep it terse and imperative."
}
```

The `description` is context for translators (Crowdin surfaces it in the editor). It's not optional — a translator seeing "Close" with no context can't tell if you mean "close the window" or "close range." Write one sentence.

**2. Namespace is always `@astryx..` and keys are camelCase.** `@astryx.pagination.next`, not `@astryx.pagination.NEXT` or `@astryx.power_search.foo` or `pagination.next`. The `@astryx/i18n-key-format` rule enforces this.

**3. Do NOT prefix/suffix strings at the callsite.** The whole message must live in `defaultMessage`; use ICU MessageFormat `{var}` for interpolation.

```tsx
// ❌ Won't translate well — French/German word order breaks, plurals lose case.
aria-label={`Filter ${columnHeader}`}
aria-label={t('@astryx.tableFiltering.filter') + ' ' + columnHeader}

// ✅ Whole message is one translatable unit; translator can reorder words per locale.
aria-label={t('@astryx.tableFiltering.filterByColumn', {header: columnHeader})}
// en.json:
// "@astryx.tableFiltering.filterByColumn": {
// "defaultMessage": "Filter {header}",
// ...
// }
```

Same rule for ICU plurals and select forms — put the whole plural in `defaultMessage`, don't concatenate a `${count}` variable with a hardcoded "results" suffix.

**4. Two hardcoded shapes the lint rule catches.** Both need `t()`:

```tsx
// ❌
aria-label={isOpen ? 'Close menu' : 'Open menu'}
aria-label={`Clear ${label}`}

// ✅
aria-label={isOpen ? t('@astryx.menu.close') : t('@astryx.menu.open')}
aria-label={t('@astryx.textInput.clearLabel', {label})}
```

Ternaries get **two separate keys**, not one key with a conditional value — different locales phrase "open" and "close" completely differently and may not share a base form.

**5. Prop defaults use the alias-and-resolve pattern.** `t()` only runs in a component render, so you can't put it in a destructure default. Alias the prop, then resolve inside the body:

```tsx
// ❌ — hooks don't run in destructure position; lint rule catches it.
export function Dialog({label = 'Close', ...rest}) { ... }

// ✅
export function Dialog({label: labelFromProps, ...rest}) {
const t = useTranslator();
const label = labelFromProps ?? t('@astryx.dialog.label');
// ...use `label` normally in the render.
}
```

The `xFromProps` name is the convention. Never invent alternatives like `resolvedLabel` or `defaultLabel` — they diverge across the codebase and confuse readers.

**6. Per-component keys, not shared.** `@astryx.dialog.close` and `@astryx.alertDialog.close` are separate keys even if the English default is identical. Translation contexts diverge — Japanese, for example, uses different words for "close a dialog you opened" vs. "close a system dialog" — and shared keys prevent translators from getting that right.

**7. Static config maps use an `i18nKey` field, not a raw label.** For any config array/map that's declared at module scope (operator lists, status maps, filter presets), store the catalog key on the config and resolve at render:

```tsx
// ❌ — module-scope constant, no `t` in scope, string won't translate.
const OPERATORS = [{key: 'is_any_of', label: 'is any of'}];

// ✅
const OPERATORS = [{key: 'is_any_of', i18nKey: '@astryx.powersearch.operator.isAnyOf'}];
// Then at render: t(operator.i18nKey)
```

`PowerSearchOperator` uses this pattern — see #3922 for the discriminated-union type that makes `label` and `i18nKey` mutually exclusive.

**8. `aria-*` attributes: only some carry translatable text.** The rule flags `aria-label`, `aria-description`, `aria-placeholder`, `aria-roledescription`, `aria-valuetext`, `aria-braillelabel`, `aria-brailleroledescription`, `aria-keyshortcuts`. Everything else (`aria-controls`, `aria-expanded`, `aria-hidden`, `aria-orientation`, `aria-current`, …) takes IDs, booleans, or enum tokens — NOT translatable text. Don't try to route those through `t()`.

**9. Test files, stories, and doc files are exempt.** Tests intentionally assert on English strings (`getByLabelText('Close')`), so hardcoding is correct there — the lint rule ignores `*.test.*`, `__tests__/**`, `*.stories.*`, `*.doc.mjs`.

**10. Best debugging tool: pseudo locale.** Set `` in dev. Every astryx string becomes `⟦Cłósé⟧`-shaped. Any plain English still on screen is a string that skipped the translator. This is how ~50 hardcoded strings were caught during the migration that no code-only audit found. See [`npx astryx docs internationalization`](https://astryx.atmeta.com/docs/internationalization) for the full consumer guide.
```

## Why this shape

- Numbered lessons, each ~3-4 lines. Easy to scan, easy to link to a specific one from a PR review comment ("see #4 in the wiki").
- Every lesson has a ❌ / ✅ pair — matches the format of nearby entries and the "closed PR" tone.
- Points at the two lint rules by name so agents know which one caught them.
- Points at the pseudo locale as the debugging tool — real proof (~50 hardcoded strings caught during migration) so agents take it seriously.

Follow-up: this issue closes automatically when the wiki is updated (post-#4016 merge). No PR gets auto-linked because wikis are a separate repo.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.