Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
});
27 changes: 23 additions & 4 deletions packages/javascript/src/utils/resolveFlowTemplateLiterals.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<TFn extends TranslationFn>(key: string, t: TFn): string {
return t(key.replace(/:/g, '.'));
}

/**
* Resolves all flow template literal expressions in a string.
*
Expand All @@ -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.
Expand All @@ -48,13 +62,18 @@ export default function resolveFlowTemplateLiterals<TFn extends TranslationFn =
const parsed: FlowTemplateLiteralResult = parseFlowTemplateLiteral(content.trim());

if (parsed.type === FlowTemplateLiteralType.TRANSLATION && parsed.key) {
// Convert colon-separated namespace to dot-separated key
// e.g. "signin:fields.password.label" → "signin.fields.password.label"
return t(parsed.key.replace(/:/g, '.'));
return resolveTranslation(parsed.key, t);
}

if (parsed.type === FlowTemplateLiteralType.META && parsed.key && meta) {
return resolveMeta(parsed.key, meta);
const value: string = resolveMeta(parsed.key, meta);

if (isTranslationFlowTemplateLiteral(value)) {
const innerKey: string = value.trim().match(TRANSLATION_FLOW_TEMPLATE_LITERAL_KEY_PATTERN)![1];
return resolveTranslation(innerKey, t);
}

return value;
}

return match;
Expand Down
10 changes: 8 additions & 2 deletions packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface FlowMetaProviderProps {
* flight — but still fetches normally on subsequent changes (e.g. an explicit language switch).
*/
initialMeta?: FlowMetadataResponse | null;

/** Sent as the flow-meta request's `namespace`, e.g. to scope namespace-keyed i18n data. */
namespace?: string;
}

/**
Expand Down Expand Up @@ -70,6 +73,7 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
enabled = true,
fetchMeta,
initialMeta = null,
namespace,
}: PropsWithChildren<FlowMetaProviderProps>): ReactElement => {
const {baseUrl, endpoints, applicationId, isInitialized} = useThunderID();
const i18nContext: I18nContextValue = useI18n();
Expand Down Expand Up @@ -112,6 +116,7 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
baseUrl,
url: resolveResourceEndpoint('flowMeta', {endpoints}),
...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}),
...(namespace ? {namespace} : {}),
language: i18nContext?.currentLanguage,
});
setMeta(result);
Expand All @@ -120,7 +125,7 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
} 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<void> = useCallback(
async (language: string): Promise<void> => {
Expand All @@ -136,6 +141,7 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
baseUrl,
url: resolveResourceEndpoint('flowMeta', {endpoints}),
...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}),
...(namespace ? {namespace} : {}),
language,
});

Expand All @@ -161,7 +167,7 @@ const FlowMetaProvider: FC<PropsWithChildren<FlowMetaProviderProps>> = ({
setIsLoading(false);
}
},
[enabled, baseUrl, endpoints, applicationId, i18nContext, fetchMeta],
[enabled, baseUrl, endpoints, applicationId, i18nContext, fetchMeta, namespace],
);

// After injectBundles + setPendingLanguage are batched and committed, this
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const ThunderIDProvider: FC<PropsWithChildren<ThunderIDProviderProps>> = ({
organizationChain,
cspNonce,
vendor,
namespace,
...rest
}: PropsWithChildren<ThunderIDProviderProps>): ReactElement => {
// Must run synchronously here, in the render body, before any descendant's css()/cx()/
Expand Down Expand Up @@ -568,7 +569,7 @@ const ThunderIDProvider: FC<PropsWithChildren<ThunderIDProviderProps>> = ({
return (
<ThunderIDContext.Provider value={value}>
<I18nProvider preferences={preferences?.i18n} vendor={getVendorPrefix(config.vendor)}>
<FlowMetaProvider enabled={preferences?.resolveFromMeta !== false}>
<FlowMetaProvider enabled={preferences?.resolveFromMeta !== false} namespace={namespace}>
<ThemeProvider
theme={{
...preferences?.theme?.overrides,
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/models/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ export type ThunderIDReactConfig = ThunderIDBrowserConfig & {
* own CSP header/meta tag issues for the current request.
*/
cspNonce?: string;

/** Forwarded to `FlowMetaProvider`'s `namespace` prop. See its doc for details. */
namespace?: string;
};
Loading