Skip to content
Open
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
91 changes: 91 additions & 0 deletions packages/shared/src/types/__tests__/localization.type.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { describe, expectTypeOf, test } from 'vitest';

import type { FlattenedLocalizationResource, LocalizationInput, LocalizationResource } from '../localization';

describe('FlattenedLocalizationResource', () => {
test('accepts valid flattened keys', () => {
const valid: FlattenedLocalizationResource = {
'signIn.start.title': 'Welcome',
};
expectTypeOf(valid).toMatchTypeOf<FlattenedLocalizationResource>();
});

test('accepts multiple valid flattened keys', () => {
const valid: FlattenedLocalizationResource = {
'signIn.start.title': 'Welcome',
'signIn.start.subtitle': 'Please sign in',
'signUp.start.title': 'Create account',
};
expectTypeOf(valid).toMatchTypeOf<FlattenedLocalizationResource>();
});

test('accepts top-level keys', () => {
const valid: FlattenedLocalizationResource = {
locale: 'en-US',
};
expectTypeOf(valid).toMatchTypeOf<FlattenedLocalizationResource>();
});

test('rejects invalid keys', () => {
const invalid: FlattenedLocalizationResource = {
// @ts-expect-error - 'invalid.key' is not a valid localization path
'invalid.key': 'test',
};
void invalid;
});

test('rejects nested structure in flattened type', () => {
// The type error occurs on the value level, not the property declaration
const invalid: FlattenedLocalizationResource = {
// @ts-expect-error - Nested objects are not valid in FlattenedLocalizationResource (values must be strings)
signIn: { start: { title: 'Welcome' } },
};
void invalid;
});
});

describe('LocalizationInput', () => {
test('accepts nested format', () => {
const nested: LocalizationInput = {
signIn: { start: { title: 'Welcome' } },
};
expectTypeOf(nested).toMatchTypeOf<LocalizationInput>();
});

test('accepts flattened format', () => {
const flat: LocalizationInput = {
'signIn.start.title': 'Welcome',
};
expectTypeOf(flat).toMatchTypeOf<LocalizationInput>();
});

test('accepts empty object', () => {
const empty: LocalizationInput = {};
expectTypeOf(empty).toMatchTypeOf<LocalizationInput>();
});
});

describe('LocalizationResource (nested)', () => {
test('accepts valid nested structure', () => {
const nested: LocalizationResource = {
signIn: {
start: {
title: 'Welcome',
subtitle: 'Please sign in',
},
},
};
expectTypeOf(nested).toMatchTypeOf<LocalizationResource>();
});

test('accepts partial nested structure', () => {
const partial: LocalizationResource = {
signIn: {
start: {
title: 'Welcome',
},
},
};
expectTypeOf(partial).toMatchTypeOf<LocalizationResource>();
});
});
8 changes: 6 additions & 2 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { CustomPage } from './customPages';
import type { ClerkAPIResponseError } from './errors';
import type { InstanceType } from './instance';
import type { DisplayThemeJSON } from './json';
import type { LocalizationResource } from './localization';
import type { LocalizationInput } from './localization';
import type { DomainOrProxyUrl, MultiDomainAndOrProxy } from './multiDomain';
import type { OAuthProvider, OAuthScope } from './oauth';
import type { OrganizationResource } from './organization';
Expand Down Expand Up @@ -1093,8 +1093,12 @@ export type ClerkOptions = ClerkOptionsNavigation &
appearance?: any;
/**
* Optional object to localize your components. Will only affect [Clerk Components](https://clerk.com/docs/reference/components/overview) and not [Account Portal](https://clerk.com/docs/guides/account-portal/overview) pages.
*
* Accepts either nested format (e.g., `{ signIn: { start: { title: "..." } } }`)
* or flattened format (e.g., `{ "signIn.start.title": "..." }`).
* Cannot mix both formats in the same object.
*/
localization?: LocalizationResource;
localization?: LocalizationInput;
polling?: boolean;
/**
* By default, the last signed-in session is used during client initialization. This option allows you to override that behavior, e.g. by selecting a specific session.
Expand Down
37 changes: 36 additions & 1 deletion packages/shared/src/types/localization.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { FieldId } from './elementIds';
import type { CamelToSnake, DeepPartial } from './utils';
import type { CamelToSnake, DeepPartial, RecordToPath } from './utils';

/**
* @internal
Expand Down Expand Up @@ -65,6 +65,41 @@ type DeepLocalizationWithoutObjects<T> = {
export interface LocalizationResource
extends DeepPartial<DeepLocalizationWithoutObjects<__internal_LocalizationResource>> {}

/**
* All valid dot-notation paths for localization keys.
* Generated from the internal resource type using existing RecordToPath.
*/
export type LocalizationPath = RecordToPath<__internal_LocalizationResource>;

/**
* Flattened version of LocalizationResource using dot-notation keys.
* Example: "signIn.start.title" instead of { signIn: { start: { title: ... } } }
*
* Cannot be mixed with nested format - must use one or the other.
*/
export type FlattenedLocalizationResource = {
[K in LocalizationPath]?: string;
};

/**
* Accepts either nested OR flattened localization format (not mixed).
* TypeScript will enforce that you use one format consistently.
*
* @example
* // Nested format (existing):
* const nested: LocalizationInput = {
* signIn: { start: { title: "Welcome back" } }
* };
*
* @example
* // Flattened format (new):
* const flat: LocalizationInput = {
* "signIn.start.title": "Welcome back",
* "signIn.start.subtitle": "Please sign in to continue"
* };
*/
export type LocalizationInput = LocalizationResource | FlattenedLocalizationResource;

export type __internal_LocalizationResource = {
locale: string;
maintenanceMode: LocalizationValue;
Expand Down
224 changes: 224 additions & 0 deletions packages/shared/src/utils/__tests__/unflattenObject.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { describe, expect, test } from 'vitest';

import { isFlattenedObject, isNestedObject, unflattenObject, validateLocalizationFormat } from '../unflattenObject';

describe('isFlattenedObject', () => {
test('returns true for objects with dot-notation keys', () => {
expect(isFlattenedObject({ 'a.b.c': 'value' })).toBe(true);
});

test('returns false for objects without dot-notation keys', () => {
expect(isFlattenedObject({ a: { b: 'value' } })).toBe(false);
});

test('returns false for empty objects', () => {
expect(isFlattenedObject({})).toBe(false);
});

test('returns true for mixed objects with at least one dot key', () => {
expect(isFlattenedObject({ simple: 'value', 'a.b': 'other' })).toBe(true);
});
});

describe('isNestedObject', () => {
test('returns true for objects with nested object values', () => {
expect(isNestedObject({ a: { b: 'value' } })).toBe(true);
});

test('returns false for flat objects with primitive values', () => {
expect(isNestedObject({ 'a.b': 'value' })).toBe(false);
});

test('returns false for empty objects', () => {
expect(isNestedObject({})).toBe(false);
});

test('returns false for objects with only primitive values', () => {
expect(isNestedObject({ a: 'string', b: 123, c: true })).toBe(false);
});

test('returns false for objects with array values', () => {
expect(isNestedObject({ a: [1, 2, 3] })).toBe(false);
});

test('returns false for objects with null values', () => {
expect(isNestedObject({ a: null })).toBe(false);
});
});

describe('validateLocalizationFormat', () => {
test('does not throw for pure nested format', () => {
expect(() => validateLocalizationFormat({ a: { b: 'value' } })).not.toThrow();
});

test('does not throw for pure flattened format', () => {
expect(() => validateLocalizationFormat({ 'a.b': 'value' })).not.toThrow();
});

test('throws for mixed format', () => {
expect(() =>
validateLocalizationFormat({
a: { b: 'nested' },
'c.d': 'flat',
}),
).toThrow('cannot mix nested and flattened formats');
});

test('does not throw for empty object', () => {
expect(() => validateLocalizationFormat({})).not.toThrow();
});

test('does not throw for objects with only primitive values (no nesting)', () => {
expect(() => validateLocalizationFormat({ a: 'string', b: 'other' })).not.toThrow();
});
});

describe('unflattenObject', () => {
test('converts flat keys to nested structure', () => {
const flat = { 'a.b.c': 'value' };
expect(unflattenObject(flat)).toEqual({ a: { b: { c: 'value' } } });
});

test('handles multiple keys at same level', () => {
const flat = { 'a.b': '1', 'a.c': '2' };
expect(unflattenObject(flat)).toEqual({ a: { b: '1', c: '2' } });
});

test('handles top-level keys without dots', () => {
const flat = { simple: 'value' };
expect(unflattenObject(flat)).toEqual({ simple: 'value' });
});

test('handles empty object', () => {
expect(unflattenObject({})).toEqual({});
});

test('handles deeply nested paths', () => {
const flat = { 'a.b.c.d.e': 'deep' };
expect(unflattenObject(flat)).toEqual({ a: { b: { c: { d: { e: 'deep' } } } } });
});

test('handles real-world localization keys', () => {
const flat = {
'signIn.start.title': 'Welcome back',
'signIn.start.subtitle': 'Please sign in to continue',
'signUp.start.title': 'Create an account',
};
expect(unflattenObject(flat)).toEqual({
signIn: {
start: {
title: 'Welcome back',
subtitle: 'Please sign in to continue',
},
},
signUp: {
start: {
title: 'Create an account',
},
},
});
});

test('handles mixed simple and nested keys', () => {
const flat = {
locale: 'en',
'signIn.start.title': 'Welcome',
};
expect(unflattenObject(flat)).toEqual({
locale: 'en',
signIn: {
start: {
title: 'Welcome',
},
},
});
});

describe('conflict detection', () => {
test('throws error when primitive value blocks nested path', () => {
const flat = { a: 'x', 'a.b': 'y' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a'/);
expect(() => unflattenObject(flat)).toThrow(/cannot set 'a\.b'/);
});

test('throws error when nested path blocks primitive value', () => {
const flat = { 'a.b': 'x', a: 'y' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a'/);
});

test('throws error when nested structure blocks primitive assignment', () => {
const flat = { 'a.b.c': 'x', 'a.b': 'y' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/);
});

test('throws error when primitive value blocks nested structure', () => {
const flat = { 'a.b': 'x', 'a.b.c': 'y' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/);
});

test('throws error for deep nested conflict', () => {
const flat = { 'a.b.c.d': 'x', 'a.b': 'y' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a\.b'/);
});

test('successfully merges keys at same level (no conflict)', () => {
const flat = { 'a.b': 'x', 'a.c': 'y' };
expect(unflattenObject(flat)).toEqual({ a: { b: 'x', c: 'y' } });
});

test('throws error when null value blocks nested path', () => {
const flat = { a: null, 'a.b': 'x' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a'/);
expect(() => unflattenObject(flat)).toThrow(/null/);
});

test('throws error when array value blocks nested path', () => {
const flat = { a: [1, 2], 'a.b': 'x' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/Localization key conflict/);
expect(() => unflattenObject(flat)).toThrow(/path 'a'/);
expect(() => unflattenObject(flat)).toThrow(/array/);
});
});

describe('edge cases', () => {
test('throws error for empty string segments (consecutive dots)', () => {
const flat = { 'a..b': 'value' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/empty segments/);
expect(() => unflattenObject(flat)).toThrow(/key 'a\.\.b'/);
});

test('throws error for leading dot', () => {
const flat = { '.a': 'value' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/empty segments/);
expect(() => unflattenObject(flat)).toThrow(/key '\.a'/);
});

test('throws error for trailing dot', () => {
const flat = { 'a.': 'value' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/empty segments/);
expect(() => unflattenObject(flat)).toThrow(/key 'a\.'/);
});

test('throws error for empty key', () => {
const flat = { '': 'value' };
expect(() => unflattenObject(flat)).toThrow();
expect(() => unflattenObject(flat)).toThrow(/empty segments/);
});
});
});
1 change: 1 addition & 0 deletions packages/shared/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './runtimeEnvironment';
export { handleValueOrFn } from './handleValueOrFn';
export { runIfFunctionOrReturn } from './runIfFunctionOrReturn';
export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge';
export { isFlattenedObject, isNestedObject, validateLocalizationFormat, unflattenObject } from './unflattenObject';
Loading
Loading