diff --git a/packages/javascript/src/utils/__tests__/resolveFlowTemplateLiterals.test.ts b/packages/javascript/src/utils/__tests__/resolveFlowTemplateLiterals.test.ts new file mode 100644 index 00000000..18bce2d7 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/resolveFlowTemplateLiterals.test.ts @@ -0,0 +1,45 @@ +// Copyright 2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, expect, it, vi} from 'vitest'; +import {FlowMetadataResponse} from '../../models/flow-meta'; +import resolveFlowTemplateLiterals from '../resolveFlowTemplateLiterals'; + +describe('resolveFlowTemplateLiterals', () => { + const t = vi.fn((key: string): string => `translated:${key}`); + + it('returns empty string for undefined input', () => { + expect(resolveFlowTemplateLiterals(undefined, {t})).toBe(''); + }); + + it('leaves plain strings unchanged', () => { + expect(resolveFlowTemplateLiterals('hello world', {t})).toBe('hello world'); + }); + + it('resolves a translation literal, converting namespace colon to dot', () => { + expect(resolveFlowTemplateLiterals('{{ t(signin:heading) }}', {t})).toBe('translated:signin.heading'); + }); + + it('resolves a meta literal via dot-path lookup', () => { + const meta = {application: {name: 'My App'}} as FlowMetadataResponse; + expect(resolveFlowTemplateLiterals('Login to {{ meta(application.name) }}', {meta, t})).toBe('Login to My App'); + }); + + it('leaves unrecognized expressions unchanged', () => { + expect(resolveFlowTemplateLiterals('{{ unknown(x) }}', {t})).toBe('{{ unknown(x) }}'); + }); + + it('resolves a meta value that is itself a translation template (nested resolution)', () => { + const meta = {application: {name: '{{t(client-001:client.name)}}'}} as FlowMetadataResponse; + expect(resolveFlowTemplateLiterals('{{ meta(application.name) }}', {meta, t})).toBe( + 'translated:client-001.client.name', + ); + }); + + it('does not treat a meta value with embedded (non-exact) template text as a translation ref', () => { + // isTranslationFlowTemplateLiteral requires the *entire* value to be the template, so a + // value that merely contains one is returned as plain text, not translated. + const meta = {application: {name: 'Say {{t(x:y)}} literally'}} as FlowMetadataResponse; + expect(resolveFlowTemplateLiterals('{{ meta(application.name) }}', {meta, t})).toBe('Say {{t(x:y)}} literally'); + }); +}); diff --git a/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts b/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts index 715f749e..bfa86dcc 100644 --- a/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts +++ b/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts @@ -1,6 +1,9 @@ // Copyright 2026 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 +import isTranslationFlowTemplateLiteral, { + TRANSLATION_FLOW_TEMPLATE_LITERAL_KEY_PATTERN, +} from './isTranslationFlowTemplateLiteral'; import parseFlowTemplateLiteral, { FLOW_TEMPLATE_LITERAL_REGEX, FlowTemplateLiteralResult, @@ -15,6 +18,14 @@ import {ResolveFlowTemplateLiteralsOptions} from '../models/vars'; */ const FLOW_TEMPLATE_LITERAL_REGEX_GLOBAL = new RegExp(FLOW_TEMPLATE_LITERAL_REGEX.source, 'g'); +/** + * Resolves a `{{ t(key) }}` translation key, converting its colon-separated namespace to dots + * e.g. "signin:fields.password.label" → "signin.fields.password.label". + */ +function resolveTranslation(key: string, t: TFn): string { + return t(key.replace(/:/g, '.')); +} + /** * Resolves all flow template literal expressions in a string. * @@ -28,6 +39,9 @@ const FLOW_TEMPLATE_LITERAL_REGEX_GLOBAL = new RegExp(FLOW_TEMPLATE_LITERAL_REGE * Flow template literals can be embedded inside larger strings: * `"Login using {{ meta(application.name) }}"` → `"Login using My App"` * + * A meta field can itself hold a translation reference instead of final display text — that case is + * detected and resolved via `t()` too, so the raw template never reaches the screen. + * * Unrecognized expressions are left unchanged. * * @template TFn - The concrete translation function type. @@ -48,13 +62,18 @@ export default function resolveFlowTemplateLiterals> = ({ enabled = true, fetchMeta, initialMeta = null, + namespace, }: PropsWithChildren): ReactElement => { const {baseUrl, endpoints, applicationId, isInitialized} = useThunderID(); const i18nContext: I18nContextValue = useI18n(); @@ -112,6 +116,7 @@ const FlowMetaProvider: FC> = ({ baseUrl, url: resolveResourceEndpoint('flowMeta', {endpoints}), ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + ...(namespace ? {namespace} : {}), language: i18nContext?.currentLanguage, }); setMeta(result); @@ -120,7 +125,7 @@ const FlowMetaProvider: FC> = ({ } finally { setIsLoading(false); } - }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, fetchMeta]); + }, [enabled, baseUrl, endpoints, applicationId, isInitialized, i18nContext?.currentLanguage, fetchMeta, namespace]); const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { @@ -136,6 +141,7 @@ const FlowMetaProvider: FC> = ({ baseUrl, url: resolveResourceEndpoint('flowMeta', {endpoints}), ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), + ...(namespace ? {namespace} : {}), language, }); @@ -161,7 +167,7 @@ const FlowMetaProvider: FC> = ({ setIsLoading(false); } }, - [enabled, baseUrl, endpoints, applicationId, i18nContext, fetchMeta], + [enabled, baseUrl, endpoints, applicationId, i18nContext, fetchMeta, namespace], ); // After injectBundles + setPendingLanguage are batched and committed, this diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index 392d3ba9..928446f1 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -61,6 +61,7 @@ const ThunderIDProvider: FC> = ({ organizationChain, cspNonce, vendor, + namespace, ...rest }: PropsWithChildren): ReactElement => { // Must run synchronously here, in the render body, before any descendant's css()/cx()/ @@ -568,7 +569,7 @@ const ThunderIDProvider: FC> = ({ return ( - +