diff --git a/.changeset/thirty-breads-strive.md b/.changeset/thirty-breads-strive.md
new file mode 100644
index 00000000000..27ec89a28f5
--- /dev/null
+++ b/.changeset/thirty-breads-strive.md
@@ -0,0 +1,24 @@
+---
+'@clerk/localizations': patch
+'@clerk/shared': patch
+---
+
+A utility function that converts flattened localization objects (with dot-notation keys) to nested `LocalizationResource` objects for use with Clerk's `localization` prop.
+
+```typescript
+import { ClerkProvider } from '@clerk/nextjs';
+import { flatLocalization } from '@clerk/localizations/utils';
+
+const localization = flatLocalization({
+ 'formFieldLabel__emailAddress': 'Email address',
+ 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long',
+});
+
+export default function App() {
+ return (
+
+ {/* Your app */}
+
+ );
+}
+```
diff --git a/packages/localizations/package.json b/packages/localizations/package.json
index 217e7af2d4d..0de642e4d47 100644
--- a/packages/localizations/package.json
+++ b/packages/localizations/package.json
@@ -36,6 +36,16 @@
"default": "./dist/index.js"
}
},
+ "./utils": {
+ "import": {
+ "types": "./dist/utils/index.d.mts",
+ "default": "./dist/utils/index.mjs"
+ },
+ "require": {
+ "types": "./dist/utils/index.d.ts",
+ "default": "./dist/utils/index.js"
+ }
+ },
"./*": {
"import": {
"types": "./dist/*.d.mts",
@@ -61,12 +71,16 @@
"format:check": "node ../../scripts/format-package.mjs --check",
"generate": "tsc src/utils/generate.ts && node src/utils/generate.js && prettier --write src/*.ts",
"lint": "eslint src",
- "lint:attw": "attw --pack . --profile node16"
+ "lint:attw": "attw --pack . --profile node16",
+ "test": "vitest --run"
},
"dependencies": {
"@clerk/shared": "workspace:^"
},
- "devDependencies": {},
+ "devDependencies": {
+ "@vitest/ui": "3.2.4",
+ "vitest": "3.2.4"
+ },
"engines": {
"node": ">=20.9.0"
},
diff --git a/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts b/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts
new file mode 100644
index 00000000000..c03dd15b96f
--- /dev/null
+++ b/packages/localizations/src/utils/__tests__/flatLocalization.spec.ts
@@ -0,0 +1,367 @@
+import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types';
+import { describe, expect, it } from 'vitest';
+
+import { flatLocalization } from '../flatLocalization';
+
+describe('flatLocalization', () => {
+ describe('runtime tests', () => {
+ it('converts valid flattened keys to nested structure', () => {
+ const flattened: FlattenedLocalizationResource = {
+ formFieldLabel__emailAddress: 'Email address',
+ formFieldLabel__password: 'Password',
+ };
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ formFieldLabel__emailAddress: 'Email address',
+ formFieldLabel__password: 'Password',
+ });
+ });
+
+ it('converts multiple keys at same level', () => {
+ const flattened = {
+ 'a.b.c': 'value1',
+ 'a.b.d': 'value2',
+ 'a.e': 'value3',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: {
+ c: 'value1',
+ d: 'value2',
+ },
+ e: 'value3',
+ },
+ });
+ });
+
+ it('converts deeply nested paths', () => {
+ const flattened: FlattenedLocalizationResource = {
+ 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long',
+ 'unstable__errors.passwordComplexity.minimumLength': 'Password is too short',
+ 'unstable__errors.passwordComplexity.requireNumbers': 'Password must contain numbers',
+ };
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ unstable__errors: {
+ passwordComplexity: {
+ maximumLength: 'Password is too long',
+ minimumLength: 'Password is too short',
+ requireNumbers: 'Password must contain numbers',
+ },
+ },
+ });
+ });
+
+ it('handles top-level keys', () => {
+ const flattened: FlattenedLocalizationResource = {
+ locale: 'en-US',
+ formFieldLabel__emailAddress: 'Email address',
+ };
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ locale: 'en-US',
+ formFieldLabel__emailAddress: 'Email address',
+ });
+ });
+
+ it('handles empty object', () => {
+ const flattened: FlattenedLocalizationResource = {};
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({});
+ });
+
+ it('handles real-world localization examples', () => {
+ const flattened: FlattenedLocalizationResource = {
+ locale: 'en-US',
+ formFieldLabel__emailAddress: 'Email address',
+ formFieldLabel__password: 'Password',
+ 'unstable__errors.passwordComplexity.maximumLength': 'Password is too long',
+ 'unstable__errors.passwordComplexity.minimumLength': 'Password is too short',
+ socialButtonsBlockButton: 'Continue with {{provider}}',
+ };
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ locale: 'en-US',
+ formFieldLabel__emailAddress: 'Email address',
+ formFieldLabel__password: 'Password',
+ unstable__errors: {
+ passwordComplexity: {
+ maximumLength: 'Password is too long',
+ minimumLength: 'Password is too short',
+ },
+ },
+ socialButtonsBlockButton: 'Continue with {{provider}}',
+ });
+ });
+
+ it('handles single key', () => {
+ const flattened: FlattenedLocalizationResource = {
+ locale: 'en-US',
+ };
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ locale: 'en-US',
+ });
+ });
+
+ it('handles all keys at root level', () => {
+ const flattened = {
+ a: 'value1',
+ b: 'value2',
+ c: 'value3',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: 'value1',
+ b: 'value2',
+ c: 'value3',
+ });
+ });
+
+ it('handles maximum nesting depth', () => {
+ const flattened = {
+ 'a.b.c.d.e.f.g.h.i.j': 'deep value',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: {
+ c: {
+ d: {
+ e: {
+ f: {
+ g: {
+ h: {
+ i: {
+ j: 'deep value',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ });
+
+ it('handles special characters in values', () => {
+ const flattened = {
+ 'a.b': 'Value with "quotes" and \'apostrophes\'',
+ 'a.c': 'Value with\nnewlines\tand\ttabs',
+ 'a.d': 'Value with {{interpolation}}',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: 'Value with "quotes" and \'apostrophes\'',
+ c: 'Value with\nnewlines\tand\ttabs',
+ d: 'Value with {{interpolation}}',
+ },
+ });
+ });
+ });
+
+ describe('validation tests', () => {
+ it('throws error when mixing flattened keys with nested values', () => {
+ const invalid = {
+ 'a.b': 'value1',
+ nested: {
+ key: 'value2',
+ },
+ } as unknown as FlattenedLocalizationResource;
+
+ expect(() => flatLocalization(invalid)).toThrow(/cannot mix.*flattened|cannot mix.*nested/);
+ });
+
+ it('throws error for keys starting with dot', () => {
+ const invalid = {
+ '.a.b': 'value',
+ 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation
+ } as FlattenedLocalizationResource;
+
+ expect(() => flatLocalization(invalid)).toThrow(
+ /Invalid flattened key format.*Keys cannot start or end with dots/,
+ );
+ });
+
+ it('throws error for keys ending with dot', () => {
+ const invalid = {
+ 'a.b.': 'value',
+ 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation
+ } as FlattenedLocalizationResource;
+
+ expect(() => flatLocalization(invalid)).toThrow(
+ /Invalid flattened key format.*Keys cannot start or end with dots/,
+ );
+ });
+
+ it('throws error for consecutive dots', () => {
+ const invalid = {
+ 'a..b': 'value',
+ 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation
+ } as FlattenedLocalizationResource;
+
+ expect(() => flatLocalization(invalid)).toThrow(
+ /Invalid flattened key format.*Keys cannot start or end with dots, or contain consecutive dots/,
+ );
+ });
+
+ it('throws error for __proto__ in key segments', () => {
+ const invalid = {
+ '__proto__.polluted': 'value',
+ 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation
+ } as FlattenedLocalizationResource;
+
+ expect(() => flatLocalization(invalid)).toThrow(
+ /Invalid flattened key format.*Keys cannot contain "__proto__" or "constructor" segments/,
+ );
+ });
+
+ it('throws error for constructor in key segments', () => {
+ const invalid = {
+ 'constructor.prototype.polluted': 'value',
+ 'valid.key': 'value2', // Need at least one valid flattened key to trigger validation
+ } as FlattenedLocalizationResource;
+
+ // Constructor.prototype is a read-only property, so it throws a different error
+ expect(() => flatLocalization(invalid)).toThrow();
+ });
+
+ it('throws error for null input', () => {
+ expect(() => flatLocalization(null as unknown as FlattenedLocalizationResource)).toThrow(
+ /Localization object must be a non-null object/,
+ );
+ });
+
+ it('throws error for array input', () => {
+ expect(() => flatLocalization([] as unknown as FlattenedLocalizationResource)).toThrow(
+ /Localization object must be a non-null object/,
+ );
+ });
+ });
+
+ describe('type tests', () => {
+ it('accepts valid FlattenedLocalizationResource', () => {
+ const flattened = {
+ 'a.b': 'value',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ // Type check: result should be LocalizationResource
+ const _typeCheck: LocalizationResource = result;
+ expect(_typeCheck).toBeDefined();
+ });
+
+ it('return type is LocalizationResource', () => {
+ const flattened: FlattenedLocalizationResource = {
+ locale: 'en-US',
+ };
+
+ const result = flatLocalization(flattened);
+
+ // Type check: result should be LocalizationResource
+ const _typeCheck: LocalizationResource = result;
+ expect(_typeCheck).toBeDefined();
+ });
+
+ it('accepts FlattenedLocalizationResource type', () => {
+ const flattened = {
+ 'a.b': 'value',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+ expect(result).toBeDefined();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles keys with underscores', () => {
+ const flattened = {
+ formFieldLabel__emailAddress: 'Email',
+ 'unstable__errors.passwordComplexity.maximumLength': 'Too long',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ formFieldLabel__emailAddress: 'Email',
+ unstable__errors: {
+ passwordComplexity: {
+ maximumLength: 'Too long',
+ },
+ },
+ });
+ });
+
+ it('handles empty string values', () => {
+ const flattened = {
+ 'a.b': '',
+ 'a.c': 'value',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: '',
+ c: 'value',
+ },
+ });
+ });
+
+ it('skips __proto__ keys silently', () => {
+ const flattened = {
+ 'a.b': 'value',
+ __proto__: 'should be skipped',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: 'value',
+ },
+ });
+ });
+
+ it('skips constructor keys silently', () => {
+ const flattened = {
+ 'a.b': 'value',
+ // Note: constructor is a special property that can't be easily set in an object literal
+ // This test verifies that unflattenObject handles it correctly
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+
+ expect(result).toEqual({
+ a: {
+ b: 'value',
+ },
+ });
+ });
+ });
+});
diff --git a/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts b/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts
new file mode 100644
index 00000000000..a120da61733
--- /dev/null
+++ b/packages/localizations/src/utils/__tests__/flatLocalization.type.spec.ts
@@ -0,0 +1,99 @@
+import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types';
+import { describe, expectTypeOf, it } from 'vitest';
+
+import { flatLocalization } from '../flatLocalization';
+
+type FlatLocalizationParameters = Parameters;
+type FlatLocalizationReturn = ReturnType;
+
+describe('flatLocalization type tests', () => {
+ describe('parameters', () => {
+ it('accepts FlattenedLocalizationResource', () => {
+ type ValidInput = FlattenedLocalizationResource;
+ expectTypeOf().toExtend();
+ });
+
+ it('accepts valid flattened keys', () => {
+ type ValidInput = {
+ locale: string;
+ formFieldLabel__emailAddress: string;
+ 'unstable__errors.passwordComplexity.maximumLength': string;
+ };
+ expectTypeOf().toExtend();
+ expectTypeOf().toExtend();
+ });
+
+ it('rejects non-string values', () => {
+ // @ts-expect-error - number is not assignable to string
+ const _test: FlattenedLocalizationResource = { 'a.b': 123 };
+ });
+
+ it('rejects nested objects', () => {
+ flatLocalization({
+ // @ts-expect-error - nested objects are not allowed in FlattenedLocalizationResource
+ nested: {
+ key: 'value',
+ },
+ });
+ });
+ });
+
+ describe('return type', () => {
+ it('returns LocalizationResource', () => {
+ expectTypeOf().toEqualTypeOf();
+ });
+
+ it('return type can be assigned to localization prop', () => {
+ type LocalizationProp = LocalizationResource;
+ expectTypeOf().toExtend();
+ });
+
+ it('preserves nested structure from flattened keys', () => {
+ type Result = ReturnType;
+ expectTypeOf().toExtend();
+ });
+ });
+
+ describe('type inference', () => {
+ it('infers correct types from function call', () => {
+ const flattened = {
+ locale: 'en-US',
+ formFieldLabel__emailAddress: 'Email address',
+ } as FlattenedLocalizationResource;
+
+ const result = flatLocalization(flattened);
+ expectTypeOf(result).toEqualTypeOf();
+ });
+
+ it('handles empty object', () => {
+ const empty = {} as FlattenedLocalizationResource;
+ const result = flatLocalization(empty);
+ expectTypeOf(result).toEqualTypeOf();
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles keys with underscores', () => {
+ type InputWithUnderscores = {
+ formFieldLabel__emailAddress: string;
+ 'unstable__errors.passwordComplexity.maximumLength': string;
+ };
+ expectTypeOf().toExtend();
+ });
+
+ it('handles top-level keys', () => {
+ type TopLevelKeys = {
+ locale: string;
+ maintenanceMode: string;
+ };
+ expectTypeOf().toExtend();
+ });
+
+ it('handles deeply nested paths', () => {
+ type DeepNesting = {
+ 'a.b.c.d.e.f.g': string;
+ };
+ expectTypeOf().toExtend();
+ });
+ });
+});
diff --git a/packages/localizations/src/utils/flatLocalization.ts b/packages/localizations/src/utils/flatLocalization.ts
new file mode 100644
index 00000000000..16a77ea55c9
--- /dev/null
+++ b/packages/localizations/src/utils/flatLocalization.ts
@@ -0,0 +1,44 @@
+import type { FlattenedLocalizationResource, LocalizationResource } from '@clerk/shared/types';
+import { unflattenObject, validateLocalizationFormat } from '@clerk/shared/utils';
+
+/**
+ * Converts a flattened localization object (with dot-notation keys) to a nested LocalizationResource.
+ *
+ * This utility function validates the input format and converts flattened keys like
+ * `"unstable__errors.passwordComplexity.maximumLength"` into nested objects that can be used
+ * with the `localization` prop in Clerk components.
+ *
+ * @example
+ * ```typescript
+ * const flattened = {
+ * "locale": "en-US",
+ * "formFieldLabel__emailAddress": "Email address",
+ * "unstable__errors.passwordComplexity.maximumLength": "Password is too long"
+ * };
+ *
+ * const nested = flatLocalization(flattened);
+ * // Result:
+ * // {
+ * // locale: "en-US",
+ * // formFieldLabel__emailAddress: "Email address",
+ * // unstable__errors: {
+ * // passwordComplexity: {
+ * // maximumLength: "Password is too long"
+ * // }
+ * // }
+ * // }
+ * ```
+ *
+ * @param input - A flattened localization object with dot-notation keys
+ * @returns A nested LocalizationResource that can be used with the `localization` prop
+ * @throws {Error} If the input format is invalid or mixes flattened and nested formats
+ */
+export function flatLocalization(input: FlattenedLocalizationResource): LocalizationResource {
+ validateLocalizationFormat(input as Record);
+
+ if (!input || Object.keys(input).length === 0) {
+ return {} as LocalizationResource;
+ }
+
+ return unflattenObject(input as Record);
+}
diff --git a/packages/localizations/src/utils/index.ts b/packages/localizations/src/utils/index.ts
new file mode 100644
index 00000000000..0a487b48623
--- /dev/null
+++ b/packages/localizations/src/utils/index.ts
@@ -0,0 +1 @@
+export { flatLocalization } from './flatLocalization';
diff --git a/packages/localizations/tsconfig.test.json b/packages/localizations/tsconfig.test.json
new file mode 100644
index 00000000000..155b9a1ef8b
--- /dev/null
+++ b/packages/localizations/tsconfig.test.json
@@ -0,0 +1,8 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "sourceMap": true,
+ "noImplicitAny": false
+ },
+ "include": ["src/**/*"]
+}
diff --git a/packages/localizations/tsup.config.ts b/packages/localizations/tsup.config.ts
index 7e69e5b33c6..a83c3f3daa3 100644
--- a/packages/localizations/tsup.config.ts
+++ b/packages/localizations/tsup.config.ts
@@ -2,7 +2,7 @@ import { defineConfig } from 'tsup';
export default defineConfig(_overrideOptions => {
return {
- entry: ['src/*.ts'],
+ entry: ['src/*.ts', 'src/utils/*.ts'],
format: ['cjs', 'esm'],
bundle: true,
clean: true,
diff --git a/packages/localizations/vitest.config.mts b/packages/localizations/vitest.config.mts
new file mode 100644
index 00000000000..a5da3abb1d2
--- /dev/null
+++ b/packages/localizations/vitest.config.mts
@@ -0,0 +1,20 @@
+import * as path from 'node:path';
+
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ test: {
+ watch: false,
+ typecheck: {
+ enabled: true,
+ tsconfig: './tsconfig.test.json',
+ include: ['**/*.type.{test,spec}.{ts,tsx}'],
+ },
+ include: ['**/*.{test,spec}.{ts,tsx}'],
+ },
+});
diff --git a/packages/shared/src/types/localization.ts b/packages/shared/src/types/localization.ts
index 7c3b5ae0fc0..8fefd7bedce 100644
--- a/packages/shared/src/types/localization.ts
+++ b/packages/shared/src/types/localization.ts
@@ -1,5 +1,5 @@
import type { FieldId } from './elementIds';
-import type { CamelToSnake, DeepPartial } from './utils';
+import type { CamelToSnake, DeepPartial, RecordToPath } from './utils';
/**
* @internal
@@ -65,6 +65,26 @@ type DeepLocalizationWithoutObjects = {
export interface LocalizationResource
extends DeepPartial> {}
+/**
+ * A flattened representation of LocalizationResource where nested keys are represented
+ * using dot notation (e.g., "formFieldLabel__emailAddress" or "unstable__errors.passwordComplexity.maximumLength").
+ *
+ * @example
+ * ```typescript
+ * const flattened: FlattenedLocalizationResource = {
+ * "locale": "en-US",
+ * "formFieldLabel__emailAddress": "Email address",
+ * "unstable__errors.passwordComplexity.maximumLength": "Password is too long"
+ * };
+ * ```
+ *
+ * This type provides type safety and autocomplete by restricting keys to valid localization paths.
+ * Note: This generates a large union type which may impact TypeScript performance in some cases.
+ */
+export type FlattenedLocalizationResource = {
+ [K in RecordToPath<__internal_LocalizationResource>]?: string;
+};
+
export type __internal_LocalizationResource = {
locale: string;
maintenanceMode: LocalizationValue;
diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts
index 4c1e6ec6bef..2b6c01e4ba1 100644
--- a/packages/shared/src/utils/index.ts
+++ b/packages/shared/src/utils/index.ts
@@ -7,3 +7,5 @@ export * from './runtimeEnvironment';
export { handleValueOrFn } from './handleValueOrFn';
export { runIfFunctionOrReturn } from './runIfFunctionOrReturn';
export { fastDeepMergeAndReplace, fastDeepMergeAndKeep } from './fastDeepMerge';
+export { unflattenObject } from './unflattenObject';
+export { validateLocalizationFormat } from './validateLocalizationFormat';
diff --git a/packages/shared/src/utils/unflattenObject.ts b/packages/shared/src/utils/unflattenObject.ts
new file mode 100644
index 00000000000..a4da3ec8d31
--- /dev/null
+++ b/packages/shared/src/utils/unflattenObject.ts
@@ -0,0 +1,56 @@
+const DISALLOWED_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
+
+function isDisallowedKey(key: string): boolean {
+ return DISALLOWED_KEYS.has(key);
+}
+
+/**
+ * Converts a flattened object with dot-notation keys into a nested object structure.
+ *
+ * @example
+ * ```typescript
+ * const flattened = {
+ * "a.b.c": "value1",
+ * "a.b.d": "value2",
+ * "e": "value3"
+ * };
+ * const nested = unflattenObject(flattened);
+ * // Result: { a: { b: { c: "value1", d: "value2" } }, e: "value3" }
+ * ```
+ *
+ * @param obj - The flattened object with dot-notation keys
+ * @returns A nested object structure
+ */
+export function unflattenObject>(obj: Record): T {
+ const result = {} as Record;
+
+ for (const [key, value] of Object.entries(obj)) {
+ if (isDisallowedKey(key)) {
+ continue;
+ }
+
+ const segments = key.split('.');
+ let current: Record = result;
+
+ for (let i = 0; i < segments.length - 1; i++) {
+ const segment = segments[i];
+
+ if (!segment || isDisallowedKey(segment)) {
+ continue;
+ }
+
+ if (!(segment in current) || typeof current[segment] !== 'object' || current[segment] === null) {
+ current[segment] = {};
+ }
+
+ current = current[segment] as Record;
+ }
+
+ const lastKey = segments[segments.length - 1];
+ if (lastKey && !isDisallowedKey(lastKey)) {
+ current[lastKey] = value;
+ }
+ }
+
+ return result as T;
+}
diff --git a/packages/shared/src/utils/validateLocalizationFormat.ts b/packages/shared/src/utils/validateLocalizationFormat.ts
new file mode 100644
index 00000000000..6f042fc88e9
--- /dev/null
+++ b/packages/shared/src/utils/validateLocalizationFormat.ts
@@ -0,0 +1,65 @@
+/**
+ * Validates that a localization object uses a consistent format (either all flattened or all nested).
+ * Throws an error if the format is mixed or invalid.
+ *
+ * @param obj - The localization object to validate
+ * @throws {Error} If the object has mixed flattened and nested keys, or invalid key formats
+ */
+export function validateLocalizationFormat(obj: Record): void {
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
+ throw new Error('Localization object must be a non-null object');
+ }
+
+ const keys = Object.keys(obj);
+ if (keys.length === 0) {
+ return; // Empty object is valid
+ }
+
+ // Check if any keys contain dots (flattened format)
+ const hasFlattenedKeys = keys.some(key => key.includes('.'));
+
+ // Check if any values are objects (nested format)
+ const hasNestedValues = keys.some(key => {
+ const value = obj[key];
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+ });
+
+ // If we have both flattened keys and nested values, that's a conflict
+ if (hasFlattenedKeys && hasNestedValues) {
+ throw new Error(
+ 'Localization object cannot mix flattened (dot-notation) keys with nested object values. Use either all flattened keys or all nested objects.',
+ );
+ }
+
+ // Validate flattened key format
+ if (hasFlattenedKeys) {
+ for (const key of keys) {
+ // Only validate keys that contain dots (flattened format)
+ if (key.includes('.')) {
+ // Check for empty segments (consecutive dots, leading/trailing dots)
+ if (key.startsWith('.') || key.endsWith('.') || key.includes('..')) {
+ throw new Error(
+ `Invalid flattened key format: "${key}". Keys cannot start or end with dots, or contain consecutive dots.`,
+ );
+ }
+
+ // Check for prototype pollution attempts
+ const segments = key.split('.');
+ if (segments.includes('__proto__') || segments.includes('constructor')) {
+ throw new Error(
+ `Invalid flattened key format: "${key}". Keys cannot contain "__proto__" or "constructor" segments.`,
+ );
+ }
+ }
+ }
+ }
+
+ // Validate nested format recursively
+ if (hasNestedValues && !hasFlattenedKeys) {
+ for (const value of Object.values(obj)) {
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
+ validateLocalizationFormat(value as Record);
+ }
+ }
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 10b45161c90..d43903bb061 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -154,7 +154,7 @@ importers:
version: 4.7.0(vite@7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
'@vitest/coverage-v8':
specifier: 3.2.4
- version: 3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
+ version: 3.2.4(vitest@3.2.4)
chalk:
specifier: 4.1.2
version: 4.1.2
@@ -331,7 +331,7 @@ importers:
version: 6.1.6(typanion@3.14.0)
vitest:
specifier: 3.2.4
- version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
+ version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
yalc:
specifier: 1.0.0-pre.53
version: 1.0.0-pre.53
@@ -418,7 +418,7 @@ importers:
version: 9.0.2
vitest-environment-miniflare:
specifier: 2.14.4
- version: 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))
+ version: 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4)
packages/chrome-extension:
dependencies:
@@ -698,6 +698,13 @@ importers:
'@clerk/shared':
specifier: workspace:^
version: link:../shared
+ devDependencies:
+ '@vitest/ui':
+ specifier: 3.2.4
+ version: 3.2.4(vitest@3.2.4)
+ vitest:
+ specifier: 3.2.4
+ version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
packages/msw:
dependencies:
@@ -5715,6 +5722,11 @@ packages:
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+ '@vitest/ui@3.2.4':
+ resolution: {integrity: sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==}
+ peerDependencies:
+ vitest: 3.2.4
+
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
@@ -21441,7 +21453,7 @@ snapshots:
vite: 7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
vue: 3.5.24(typescript@5.8.3)
- '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1))':
+ '@vitest/coverage-v8@3.2.4(vitest@3.2.4)':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -21456,7 +21468,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
- vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
+ vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@@ -21497,6 +21509,17 @@ snapshots:
dependencies:
tinyspy: 4.0.4
+ '@vitest/ui@3.2.4(vitest@3.2.4)':
+ dependencies:
+ '@vitest/utils': 3.2.4
+ fflate: 0.8.2
+ flatted: 3.3.3
+ pathe: 2.0.3
+ sirv: 3.0.2
+ tinyglobby: 0.2.15
+ tinyrainbow: 2.0.0
+ vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
+
'@vitest/utils@3.2.4':
dependencies:
'@vitest/pretty-format': 3.2.4
@@ -32561,19 +32584,19 @@ snapshots:
optionalDependencies:
vite: 7.2.2(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
- vitest-environment-miniflare@2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)):
+ vitest-environment-miniflare@2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)(vitest@3.2.4):
dependencies:
'@miniflare/queues': 2.14.4
'@miniflare/runner-vm': 2.14.4
'@miniflare/shared': 2.14.4
'@miniflare/shared-test-environment': 2.14.4(bufferutil@4.0.9)(utf-8-validate@5.0.10)
undici: 5.28.4
- vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
+ vitest: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- bufferutil
- utf-8-validate
- vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1):
+ vitest@3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@22.19.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10))(lightningcss@1.30.2)(msw@2.11.6(@types/node@22.19.0)(typescript@5.8.3))(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.4
@@ -32602,6 +32625,7 @@ snapshots:
'@edge-runtime/vm': 5.0.0
'@types/debug': 4.1.12
'@types/node': 22.19.0
+ '@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 27.0.0(bufferutil@4.0.9)(postcss@8.5.6)(utf-8-validate@5.0.10)
transitivePeerDependencies:
- jiti