From 9cb26462a106cac74d3fd5b9570385e6881a981e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 30 Oct 2026 14:34:28 -0400 Subject: [PATCH 1/4] feat(mosaic): add localization terms for the organization noun --- .changeset/mosaic-organization-terms.md | 7 ++ packages/mosaic/ARCHITECTURE.md | 4 +- .../src/__tests__/localization.test.tsx | 94 ++++++++++++++++++- .../api-keys/api-keys-table.messages.ts | 2 +- ...ization-profile-danger-section.messages.ts | 22 ++--- .../invitations-table-tab.messages.ts | 4 +- .../members-table-tab.messages.ts | 8 +- .../requests-table-tab.messages.ts | 2 +- ...zation-profile-profile-section.messages.ts | 4 +- .../organization-profile.messages.ts | 2 +- .../organization-switcher.messages.ts | 12 +-- .../user-button/user-button.messages.ts | 8 +- .../user-profile/user-profile.messages.ts | 2 +- packages/mosaic/src/localization/context.tsx | 8 +- .../src/localization/errors.messages.ts | 20 ++-- .../src/localization/localization.test-d.ts | 10 ++ packages/mosaic/src/localization/messages.ts | 14 ++- packages/mosaic/src/localization/registry.ts | 26 ++++- .../mosaic/src/localization/terms.messages.ts | 5 + .../swingset/src/stories/localization.mdx | 20 ++++ 20 files changed, 223 insertions(+), 51 deletions(-) create mode 100644 .changeset/mosaic-organization-terms.md create mode 100644 packages/mosaic/src/localization/terms.messages.ts diff --git a/.changeset/mosaic-organization-terms.md b/.changeset/mosaic-organization-terms.md new file mode 100644 index 00000000000..39022da6362 --- /dev/null +++ b/.changeset/mosaic-organization-terms.md @@ -0,0 +1,7 @@ +--- +'@clerk/mosaic': minor +--- + +Add `terms` to Mosaic's localization catalog, so the word for an organization can be changed in one place. Set `terms.organization` to `{ one: 'workspace', other: 'workspaces' }` and every built-in string uses it, capitalized where needed. The default is still "organization", and a specific key override still wins over a term. + +Custom strings can use `{organization}`, `{organizations}`, `{Organization}` and `{Organizations}`. diff --git a/packages/mosaic/ARCHITECTURE.md b/packages/mosaic/ARCHITECTURE.md index 5e58c87a1af..223f8bb842c 100644 --- a/packages/mosaic/ARCHITECTURE.md +++ b/packages/mosaic/ARCHITECTURE.md @@ -225,7 +225,9 @@ helper takes the messages as a parameter rather than importing them. ``` `messages` is the catalog for `locale`, `overrides` the app's own changes on top, and -English sits under both. `locale` only feeds `Intl.PluralRules`. Strings are resolved +English sits under both. `locale` only feeds `Intl.PluralRules`. The `terms` namespace holds +words shared across strings: `{organization}` in any message resolves from it after the +layers merge, so a view never passes a term to `fill`. Strings are resolved with the helpers in `src/localization/messages.ts`, never with `.replace` or `.split` on the string: diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx index 31c7fa57bc7..462d75e5e7a 100644 --- a/packages/mosaic/src/__tests__/localization.test.tsx +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -16,7 +16,7 @@ describe('resolveLocalization', () => { const { messages } = resolveLocalization({ messages: { userButton: { trigger: { open: 'Menü für {name}' } } } }); expect(messages.userButton.trigger.open).toBe('Menü für {name}'); expect(messages.userButton.popup.label).toBe(mosaicMessages.userButton.popup.label); - expect(messages.userProfile).toBe(mosaicMessages.userProfile); + expect(messages.userProfile).toEqual(mosaicMessages.userProfile); }); it('expands dot paths', () => { @@ -70,6 +70,98 @@ describe('resolveLocalization', () => { }); }); +describe('terms', () => { + const workspace = { organization: { one: 'workspace', other: 'workspaces' } }; + + it('keeps the organization noun in English by default', () => { + const { messages } = resolveLocalization(undefined); + expect(messages.organizationProfile.label).toBe('Organization'); + expect(messages.organizationSwitcher.popup.label).toBe('Organizations'); + expect(messages.organizationSwitcher.organizations.loading).toBe('Loading organizations…'); + expect(messages.organizationProfile.danger.leave.label).toBe('Leave organization'); + }); + + it('derives all four placeholders from one term', () => { + const { messages } = resolveLocalization({ overrides: { terms: workspace } }); + expect(messages.organizationProfile.label).toBe('Workspace'); + expect(messages.organizationSwitcher.popup.label).toBe('Workspaces'); + expect(messages.organizationSwitcher.organizations.loading).toBe('Loading workspaces…'); + expect(messages.organizationProfile.danger.leave.label).toBe('Leave workspace'); + }); + + it('leaves no organization noun behind in any built-in string', () => { + const { messages } = resolveLocalization({ overrides: { terms: workspace } }); + const { terms: _terms, ...rest } = messages; + const strings: string[] = []; + const collect = (node: unknown) => { + if (typeof node === 'string') { + strings.push(node); + } else if (typeof node === 'object' && node !== null) { + Object.values(node).forEach(collect); + } + }; + collect(rest); + expect(strings.filter(value => /\borganizations?\b/i.test(value))).toEqual([]); + }); + + it('accepts a term from a catalog, by dot path, with overrides winning', () => { + const fromCatalog = resolveLocalization({ + locale: 'es-ES', + messages: { 'terms.organization': { one: 'organización', other: 'organizaciones' } }, + }); + expect(fromCatalog.messages.organizationProfile.label).toBe('Organización'); + + const overridden = resolveLocalization({ + locale: 'es-ES', + messages: { terms: { organization: { one: 'organización' } } }, + overrides: { terms: { organization: { one: 'espacio' } } }, + }); + expect(overridden.messages.organizationProfile.label).toBe('Espacio'); + }); + + it('uses the form the locale picks for one', () => { + const { messages } = resolveLocalization({ + locale: 'ja', + messages: { terms: { organization: { other: '組織' } } }, + }); + expect(messages.organizationProfile.label).toBe('組織'); + expect(messages.organizationSwitcher.popup.label).toBe('組織'); + }); + + it('capitalizes with the rules of the locale', () => { + const { messages } = resolveLocalization({ + locale: 'tr', + messages: { terms: { organization: { one: 'işyeri', other: 'işyerleri' } } }, + }); + expect(messages.organizationProfile.label).toBe('İşyeri'); + }); + + it('lets a specific key win over a term', () => { + const { messages } = resolveLocalization({ + overrides: { terms: workspace, 'organizationProfile.label': 'Team' }, + }); + expect(messages.organizationProfile.label).toBe('Team'); + expect(messages.organizationSwitcher.popup.label).toBe('Workspaces'); + }); + + it('fills terms in custom strings, plural forms and error messages', () => { + const { messages } = resolveLocalization({ + overrides: { terms: workspace, 'userButton.popup.label': 'Your {organization}' }, + }); + expect(messages.userButton.popup.label).toBe('Your workspace'); + expect(messages.organizationProfile.danger.delete.dialogDescription.other).toContain('all workspace data'); + expect(messages.errors.organization_changed).toBe('The active workspace changed. Please try again.'); + }); + + it('keeps other placeholders and markup for later', () => { + const { messages } = resolveLocalization({ + overrides: { terms: workspace, 'userButton.popup.label': '{#strong}{organization}{/strong} {name}' }, + }); + expect(messages.userButton.popup.label).toBe('{#strong}workspace{/strong} {name}'); + expect(messages.organizationSwitcher.trigger.open).toBe('Open workspace menu for {name}'); + }); +}); + describe('useMessages', () => { it('returns the base namespace without a provider', () => { const { result } = renderHook(() => useMessages('userButton')); diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts b/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts index 37e2f21d815..5977b04067e 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts +++ b/packages/mosaic/src/features/api-keys/api-keys-table.messages.ts @@ -12,7 +12,7 @@ export const apiKeysTableMessages = { noKeys: 'No API Keys created', noKeysDescription: { user: 'API keys allow apps and scripts to access your account without signing in.', - organization: 'API keys allow apps and scripts to access your organization without signing in.', + organization: 'API keys allow apps and scripts to access your {organization} without signing in.', }, clearSearch: 'Clear search', pagination: 'API keys pages', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages.ts index 380a11470e4..bffa14b96cf 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-danger-section/organization-profile-danger-section.messages.ts @@ -3,23 +3,23 @@ export const organizationProfileDangerSectionMessages = { fieldLabel: 'Type “{phrase}” below to continue', cancelLabel: 'Cancel', leave: { - label: 'Leave organization', - description: 'You will lose access to this organization and its applications.', - actionLabel: 'Leave organization', - dialogTitle: 'Leave organization', + label: 'Leave {organization}', + description: 'You will lose access to this {organization} and its applications.', + actionLabel: 'Leave {organization}', + dialogTitle: 'Leave {organization}', dialogDescription: - 'Are you sure you want to leave this organization? You will lose access to this organization and its applications.', + 'Are you sure you want to leave this {organization}? You will lose access to this {organization} and its applications.', }, delete: { - label: 'Delete organization', + label: 'Delete {organization}', description: - 'Permanently delete this organization and all its data. This cannot be undone. All members will lose access.', - actionLabel: 'Delete organization', - dialogTitle: 'Delete organization', + 'Permanently delete this {organization} and all its data. This cannot be undone. All members will lose access.', + actionLabel: 'Delete {organization}', + dialogTitle: 'Delete {organization}', dialogDescription: { - one: 'Are you sure you want to delete {name}? This removes {count} member and permanently deletes all organization data. This can’t be undone.', + one: 'Are you sure you want to delete {name}? This removes {count} member and permanently deletes all {organization} data. This can’t be undone.', other: - 'Are you sure you want to delete {name}? This removes {count} members and permanently deletes all organization data. This can’t be undone.', + 'Are you sure you want to delete {name}? This removes {count} members and permanently deletes all {organization} data. This can’t be undone.', }, }, } as const; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.messages.ts index 807e25c9954..2c41c44bcc1 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.messages.ts @@ -6,7 +6,7 @@ export const invitationsTableTabMessages = { actions: 'Actions', invite: 'Invite', noInvitations: 'No pending invitations', - noInvitationsDescription: 'Invite people to add them to this organization.', + noInvitationsDescription: 'Invite people to add them to this {organization}.', loading: 'Loading invitations', loaded: 'Invitations loaded', loadError: 'Unable to load invitations', @@ -16,7 +16,7 @@ export const invitationsTableTabMessages = { manage: 'Manage {name}', revoke: 'Revoke invitation', revokeTitle: 'Revoke {name}?', - revokeDescription: 'This invitation will no longer allow the recipient to join the organization.', + revokeDescription: 'This invitation will no longer allow the recipient to join the {organization}.', revokeError: 'Unable to revoke this invitation. Please try again.', cancel: 'Cancel', pagination: 'Invitations pagination', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.messages.ts index 77e4a9c5237..44546544ba0 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.messages.ts @@ -10,23 +10,23 @@ export const membersTableTabMessages = { empty: 'No members found', emptyDescription: 'Your search for "{query}" did not return any results.', noMembers: 'No members yet', - noMembersDescription: 'Invite people to add them to this organization.', + noMembersDescription: 'Invite people to add them to this {organization}.', loading: 'Loading members', loaded: 'Members loaded', loadError: 'Unable to load members', retry: 'Try again', roleChangeError: 'Unable to change this member’s role. Please try again.', - roleSetMigration: 'Roles cannot be changed while this organization’s role migration is in progress.', + roleSetMigration: 'Roles cannot be changed while this {organization}’s role migration is in progress.', selectAll: 'Select all members on this page', select: 'Select {name}', manage: 'Manage {name}', changeRole: 'Change role for {name}', - remove: 'Remove from organization', + remove: 'Remove from {organization}', you: 'You', deprovisioned: 'Deprovisioned', banned: 'Banned', removeTitle: 'Remove {name}?', - removeDescription: 'This member will lose access to the organization.', + removeDescription: 'This member will lose access to the {organization}.', removeError: 'Unable to remove this member. Please try again.', cancel: 'Cancel', pagination: 'Members pagination', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.messages.ts index 49ca1b4499d..42b1388f205 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.messages.ts @@ -13,7 +13,7 @@ export const requestsTableTabMessages = { declineRequest: 'Decline {name}', invite: 'Invite', noRequests: 'No pending requests', - noRequestsDescription: 'Requests to join this organization will show up here.', + noRequestsDescription: 'Requests to join this {organization} will show up here.', loading: 'Loading requests', loaded: 'Requests loaded', loadError: 'Unable to load requests', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts index e1b8bcc2b7e..3f20ad5fe36 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.messages.ts @@ -1,5 +1,5 @@ export const organizationProfileProfileSectionMessages = { - sectionTitle: 'Organization details', + sectionTitle: '{Organization} details', logo: { label: 'Logo', description: 'Recommended size 1:1, up to 10MB.', @@ -24,7 +24,7 @@ export const organizationProfileProfileSectionMessages = { copied: 'Copied', dialogTitle: 'Edit slug', - dialogDescription: 'A unique identifier used in organization URLs. Changing it may break existing links.', + dialogDescription: 'A unique identifier used in {organization} URLs. Changing it may break existing links.', fieldLabel: 'Slug', cancel: 'Cancel', save: 'Save changes', diff --git a/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts b/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts index 93b9dec729b..5b31ec86116 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile.messages.ts @@ -1,5 +1,5 @@ export const organizationProfileMessages = { - label: 'Organization', + label: '{Organization}', pages: { general: 'General', members: 'Members', diff --git a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts index 42dfee1fba7..9a91fa12012 100644 --- a/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts +++ b/packages/mosaic/src/features/organization-switcher/organization-switcher.messages.ts @@ -1,14 +1,14 @@ export const organizationSwitcherMessages = { trigger: { - open: 'Open organization menu for {name}', + open: 'Open {organization} menu for {name}', }, popup: { - label: 'Organizations', + label: '{Organizations}', }, organizations: { personal: 'Personal account', - notSelected: 'No organization selected', - loading: 'Loading organizations…', + notSelected: 'No {organization} selected', + loading: 'Loading {organizations}…', members: { one: '{count} member', other: '{count} members' }, accept: 'Accept', join: 'Join', @@ -19,7 +19,7 @@ export const organizationSwitcherMessages = { invite: 'Invite members', settings: 'Settings', profile: 'Manage profile', - organization: 'Manage organization', - createOrganization: 'Add organization', + organization: 'Manage {organization}', + createOrganization: 'Add {organization}', }, } as const; diff --git a/packages/mosaic/src/features/user-button/user-button.messages.ts b/packages/mosaic/src/features/user-button/user-button.messages.ts index d3353eff40a..8409cdf64d4 100644 --- a/packages/mosaic/src/features/user-button/user-button.messages.ts +++ b/packages/mosaic/src/features/user-button/user-button.messages.ts @@ -7,8 +7,8 @@ export const userButtonMessages = { }, organizations: { personal: 'Personal account', - notSelected: 'No organization selected', - loading: 'Loading organizations…', + notSelected: 'No {organization} selected', + loading: 'Loading {organizations}…', members: { one: '{count} member', other: '{count} members' }, accept: 'Accept', join: 'Join', @@ -26,7 +26,7 @@ export const userButtonMessages = { invite: 'Invite members', settings: 'Settings', profile: 'Manage profile', - organization: 'Manage organization', - createOrganization: 'Add organization', + organization: 'Manage {organization}', + createOrganization: 'Add {organization}', }, } as const; diff --git a/packages/mosaic/src/features/user-profile/user-profile.messages.ts b/packages/mosaic/src/features/user-profile/user-profile.messages.ts index fd1b9b141bf..210c893de7a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile.messages.ts +++ b/packages/mosaic/src/features/user-profile/user-profile.messages.ts @@ -4,7 +4,7 @@ export const userProfileMessages = { /** Labels the account's pages when the organization's are listed beside them. */ accountGroup: 'Account', /** Labels the organization's pages, listed after the account's. */ - organizationGroup: 'Organization', + organizationGroup: '{Organization}', pages: { account: 'Account', security: 'Security', diff --git a/packages/mosaic/src/localization/context.tsx b/packages/mosaic/src/localization/context.tsx index af7391d8d11..c0bd50bf56a 100644 --- a/packages/mosaic/src/localization/context.tsx +++ b/packages/mosaic/src/localization/context.tsx @@ -2,7 +2,7 @@ import React from 'react'; import type { MosaicLocalization } from './catalog'; import type { MosaicMessages } from './registry'; -import { mosaicMessages } from './registry'; +import { applyTerms, messageTemplates, mosaicMessages } from './registry'; interface MosaicLocalizationValue { messages: MosaicMessages; @@ -51,9 +51,11 @@ function resolveLocale(locale: string | undefined): string { export function resolveLocalization(localization: MosaicLocalization | undefined): MosaicLocalizationValue { const layers = [localization?.messages, localization?.overrides]; + const templates = layers.reduce(merge, messageTemplates) as MosaicMessages; + const locale = resolveLocale(localization?.locale); return { - messages: layers.reduce(merge, mosaicMessages) as MosaicMessages, - locale: resolveLocale(localization?.locale), + messages: templates === messageTemplates ? mosaicMessages : applyTerms(templates, locale), + locale, }; } diff --git a/packages/mosaic/src/localization/errors.messages.ts b/packages/mosaic/src/localization/errors.messages.ts index 1522b27442f..e5c5c8344cb 100644 --- a/packages/mosaic/src/localization/errors.messages.ts +++ b/packages/mosaic/src/localization/errors.messages.ts @@ -1,6 +1,6 @@ export const errorMessages: { readonly generic: string } & Readonly> = { generic: 'Something went wrong. Please try again.', - already_a_member_in_organization: 'Some of these email addresses already belong to members of this organization.', + already_a_member_in_organization: 'Some of these email addresses already belong to members of this {organization}.', action_blocked: "This action couldn't be completed. Please try again later or contact support if this persists.", active_device_unavailable: 'This device is no longer available. Please try again.', api_key_secret_unavailable: @@ -35,14 +35,14 @@ export const errorMessages: { readonly generic: string } & Readonly { void value; }); + test('accepts terms, nested or by dot path', () => { + const value: MosaicCatalog = { + terms: { organization: { one: 'workspace', other: 'workspaces' } }, + 'terms.organization': { few: 'x' }, + }; + void value; + }); + test('rejects unknown keys and paths', () => { const value: MosaicCatalog = { // @ts-expect-error typo in the path @@ -83,6 +92,7 @@ describe('MosaicLocalization', () => { describe('useMessages', () => { test('returns the namespace with its template types', () => { + fill(useMessages('organizationSwitcher').trigger.open, { name: 'Acme' }); expectTypeOf(useMessages('userButton')).toEqualTypeOf(); expectTypeOf(useMessages('userButton').trigger.open).toEqualTypeOf<'Open account menu for {name}'>(); // @ts-expect-error unknown namespace diff --git a/packages/mosaic/src/localization/messages.ts b/packages/mosaic/src/localization/messages.ts index 96fdf3cc7bf..5c028e3e049 100644 --- a/packages/mosaic/src/localization/messages.ts +++ b/packages/mosaic/src/localization/messages.ts @@ -1,6 +1,8 @@ import type { ReactNode } from 'react'; import { createElement, Fragment } from 'react'; +import type { TermPlaceholder } from './terms.messages'; + export type MessageValues = Record; export type PluralForms = Partial> & { other: string }; @@ -13,7 +15,7 @@ export interface RichOptions { } export type MessageParams = T extends `${string}{${infer Key}}${infer Rest}` - ? Key extends `#${string}` | `/${string}` + ? Key extends `#${string}` | `/${string}` | TermPlaceholder ? MessageParams : Key | MessageParams : never; @@ -57,6 +59,16 @@ export function fill(template: string, values: Partial): string { return template.replace(/\{(\w+)\}/g, (match, key: string) => String(own(values, key) ?? match)); } +export function fillAll(node: unknown, values: MessageValues): unknown { + if (typeof node === 'string') { + return fill(node, values); + } + if (typeof node === 'object' && node !== null) { + return Object.fromEntries(Object.entries(node).map(([key, value]) => [key, fillAll(value, values)])); + } + return node; +} + export function plural( forms: F, count: number, diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 420df1eba8e..41f81820255 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -31,9 +31,12 @@ import { userProfilePhoneSectionMessages } from '../features/user-profile/user-p import { userProfileProfileSectionMessages } from '../features/user-profile/user-profile-profile-section/user-profile-profile-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages'; import { errorMessages } from './errors.messages'; +import { fillAll, plural } from './messages'; import { roleMessages } from './roles.messages'; +import { termMessages } from './terms.messages'; -export const mosaicMessages = { +export const messageTemplates = { + terms: termMessages, userButton: userButtonMessages, organizationSwitcher: organizationSwitcherMessages, userProfile: { @@ -76,4 +79,23 @@ export const mosaicMessages = { roles: roleMessages, }; -export type MosaicMessages = typeof mosaicMessages; +export type MosaicMessages = typeof messageTemplates; + +export const mosaicMessages = applyTerms(messageTemplates, 'en'); + +export function applyTerms(templates: MosaicMessages, locale: string): MosaicMessages { + const { organization } = templates.terms; + const one = plural(organization, 1, locale); + const values = { + organization: one, + Organization: capitalize(one, locale), + organizations: organization.other, + Organizations: capitalize(organization.other, locale), + }; + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- The walk only replaces string leaves, so the shape is unchanged. + return fillAll(templates, values) as MosaicMessages; +} + +function capitalize(word: string, locale: string): string { + return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1); +} diff --git a/packages/mosaic/src/localization/terms.messages.ts b/packages/mosaic/src/localization/terms.messages.ts new file mode 100644 index 00000000000..69baa59edac --- /dev/null +++ b/packages/mosaic/src/localization/terms.messages.ts @@ -0,0 +1,5 @@ +export const termMessages = { + organization: { one: 'organization', other: 'organizations' }, +} as const; + +export type TermPlaceholder = 'organization' | 'Organization' | 'organizations' | 'Organizations'; diff --git a/packages/swingset/src/stories/localization.mdx b/packages/swingset/src/stories/localization.mdx index 81f512ea2c3..8430cf13c50 100644 --- a/packages/swingset/src/stories/localization.mdx +++ b/packages/swingset/src/stories/localization.mdx @@ -129,6 +129,26 @@ The Spanish catalog from the example above with one string overridden for this a storyModule={LocalizationStories} /> +## Terms + +`terms` holds words used across many strings. Mosaic has one today, the word for an organization: + +```tsx + + +; +``` + +The term fills four placeholders: `{organization}`, `{organizations}`, `{Organization}` and `{Organizations}`. The singular is the form the locale picks for one, the plural is `other`, and the capitalized versions follow the locale's rules. Other plural categories are accepted but no built-in string counts organizations, so they have no effect yet. + +Any string can use these placeholders, and a specific key still wins over a term. Messages Clerk sends from the server for error codes missing from the catalog are not changed. A term replaces one word, so a language whose surrounding words change with the noun needs its own strings for those keys. + ## Locale `locale` is a BCP 47 tag and does one thing: it is handed to `Intl.PluralRules`, which picks the From 7e2eb83a4e1853a92859f3e49a056c1b99298d79 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 12:43:47 -0500 Subject: [PATCH 2/4] fix(mosaic): capitalize terms that start outside the BMP --- packages/mosaic/src/__tests__/localization.test.tsx | 8 ++++++++ packages/mosaic/src/localization/registry.ts | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx index 462d75e5e7a..2ce061450fa 100644 --- a/packages/mosaic/src/__tests__/localization.test.tsx +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -136,6 +136,14 @@ describe('terms', () => { expect(messages.organizationProfile.label).toBe('İşyeri'); }); + it('capitalizes a first letter outside the basic multilingual plane', () => { + const { messages } = resolveLocalization({ + locale: 'ff-Adlm', + messages: { terms: { organization: { one: '𞤢𞤣', other: '𞤢𞤣' } } }, + }); + expect(messages.organizationProfile.label).toBe('𞤀𞤣'); + }); + it('lets a specific key win over a term', () => { const { messages } = resolveLocalization({ overrides: { terms: workspace, 'organizationProfile.label': 'Team' }, diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index 41f81820255..de00e849c73 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -97,5 +97,6 @@ export function applyTerms(templates: MosaicMessages, locale: string): MosaicMes } function capitalize(word: string, locale: string): string { - return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1); + const first = [...word][0]; + return first ? first.toLocaleUpperCase(locale) + word.slice(first.length) : word; } From 8d50626aeb29567c32e6824a75290fb084933edf Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 15:20:37 -0500 Subject: [PATCH 3/4] fix(mosaic): keep the English singular for an untranslated organization term --- packages/mosaic/src/__tests__/localization.test.tsx | 6 ++++++ packages/mosaic/src/localization/registry.ts | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/mosaic/src/__tests__/localization.test.tsx b/packages/mosaic/src/__tests__/localization.test.tsx index 2ce061450fa..4179a81911b 100644 --- a/packages/mosaic/src/__tests__/localization.test.tsx +++ b/packages/mosaic/src/__tests__/localization.test.tsx @@ -136,6 +136,12 @@ describe('terms', () => { expect(messages.organizationProfile.label).toBe('İşyeri'); }); + it('keeps the English singular when a catalog leaves the term untranslated', () => { + const { messages } = resolveLocalization({ locale: 'ja', messages: { 'userButton.popup.label': 'アカウント' } }); + expect(messages.organizationProfile.danger.leave.label).toBe('Leave organization'); + expect(messages.organizationProfile.label).toBe('Organization'); + }); + it('capitalizes a first letter outside the basic multilingual plane', () => { const { messages } = resolveLocalization({ locale: 'ff-Adlm', diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index de00e849c73..867a999109e 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -85,12 +85,13 @@ export const mosaicMessages = applyTerms(messageTemplates, 'en'); export function applyTerms(templates: MosaicMessages, locale: string): MosaicMessages { const { organization } = templates.terms; - const one = plural(organization, 1, locale); + const termLocale = organization === termMessages.organization ? 'en' : locale; + const one = plural(organization, 1, termLocale); const values = { organization: one, - Organization: capitalize(one, locale), + Organization: capitalize(one, termLocale), organizations: organization.other, - Organizations: capitalize(organization.other, locale), + Organizations: capitalize(organization.other, termLocale), }; // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- The walk only replaces string leaves, so the shape is unchanged. return fillAll(templates, values) as MosaicMessages; From 915bc7ab02f12a9da558043b11e47d0446eab5ef Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 3 Nov 2026 15:37:18 -0500 Subject: [PATCH 4/4] feat(mosaic): use the organization term in OrganizationList and CreateOrganization --- .../create-organization/create-organization.messages.ts | 8 ++++---- .../__tests__/organization-list.feature.test.tsx | 4 ++-- .../organization-list/organization-list.messages.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/mosaic/src/features/create-organization/create-organization.messages.ts b/packages/mosaic/src/features/create-organization/create-organization.messages.ts index 42a8168717f..a480237125f 100644 --- a/packages/mosaic/src/features/create-organization/create-organization.messages.ts +++ b/packages/mosaic/src/features/create-organization/create-organization.messages.ts @@ -1,18 +1,18 @@ export const createOrganizationMessages = { create: { - title: 'Create organization', + title: 'Create {organization}', logoLabel: 'Logo', logoDescription: 'Recommended size 1:1, up to 10MB.', uploadLogo: 'Upload', removeLogo: 'Remove', nameLabel: 'Name', - namePlaceholder: 'Organization name', + namePlaceholder: '{Organization} name', slugLabel: 'Slug', slugPlaceholder: 'my-org', slugInvalid: 'Use lowercase letters, numbers and hyphens only.', - submit: 'Create organization', + submit: 'Create {organization}', cancel: 'Cancel', - error: 'Unable to create the organization. Please try again.', + error: 'Unable to create the {organization}. Please try again.', }, invite: { title: 'Invite new members', diff --git a/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx b/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx index 53b8246ab8f..2ad597eb7a0 100644 --- a/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx +++ b/packages/mosaic/src/features/organization-list/__tests__/organization-list.feature.test.tsx @@ -102,7 +102,7 @@ describe('OrganizationList', () => { it('leaves the personal account out with hidePersonal', async () => { await renderOrganizationList(signedIn(), { hidePersonal: true }); - expect(screen.getByRole('heading', { name: 'Choose an organization' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Choose your organization' })).toBeVisible(); expect(screen.getByRole('button', { name: 'Acme' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull(); }); @@ -112,7 +112,7 @@ describe('OrganizationList', () => { seed.environment = fapiEnvironment({ organization_settings: { force_organization_selection: true } }); await renderOrganizationList(seed); - expect(screen.getByRole('heading', { name: 'Choose an organization' })).toBeVisible(); + expect(screen.getByRole('heading', { name: 'Choose your organization' })).toBeVisible(); expect(screen.queryByRole('button', { name: 'Personal account' })).toBeNull(); }); }); diff --git a/packages/mosaic/src/features/organization-list/organization-list.messages.ts b/packages/mosaic/src/features/organization-list/organization-list.messages.ts index 0ee174e3fd0..0994f2db21a 100644 --- a/packages/mosaic/src/features/organization-list/organization-list.messages.ts +++ b/packages/mosaic/src/features/organization-list/organization-list.messages.ts @@ -1,13 +1,13 @@ export const organizationListMessages = { title: 'Choose an account', - titleWithoutPersonal: 'Choose an organization', + titleWithoutPersonal: 'Choose your {organization}', subtitle: 'to continue to {applicationName}', personal: 'Personal account', - loading: 'Loading organizations…', + loading: 'Loading {organizations}…', pending: 'pending', acceptInvitation: 'Join', acceptSuggestion: 'Request to join', suggestionAccepted: 'Pending approval', - createOrganization: 'Create organization', + createOrganization: 'Create {organization}', error: 'Something went wrong. Please try again.', } as const;