Skip to content

Commit 1531c0f

Browse files
committed
Add expo config plugin
1 parent e77bd8d commit 1531c0f

File tree

13 files changed

+4406
-97
lines changed

13 files changed

+4406
-97
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ yarn-error.log
4848
# Bob
4949
dist/
5050

51+
# Expo plugin
52+
!plugin/build
53+
5154
# mock
5255
mock/*.d.ts
5356
mock/*.js

README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ Opens the app language settings.
340340
> [!WARNING]
341341
>
342342
> This feature is available only on Android 13+ and require [configuring your app's supported locales](https://developer.android.com/guide/topics/resources/app-languages#use-localeconfig).
343+
> If you're using Expo, you can do this with [the config plugin](#usage-with-expo).
343344
344345
#### Method type
345346

@@ -397,6 +398,8 @@ You can add / remove supported localizations in your Xcode project infos:
397398

398399
![](./docs/xcode-adding-locales.png)
399400

401+
If you're using Expo, you can do this with [the config plugin](#usage-with-expo).
402+
400403
## How to test your code
401404

402405
Because it's a native module, you need to mock this package.<br />
@@ -407,6 +410,55 @@ The package provides a default mock you may import in your `__mocks__` directory
407410
export * from "react-native-localize/mock"; // or "react-native-localize/mock/jest"
408411
```
409412

413+
## <a name="usage-with-expo"></a>Usage with Expo
414+
415+
If you're using Expo, you can specify the supported locales in your app.json or app.config.js using the config plugin.
416+
This enables Android 13+ and iOS to display the available locales in the system settings, allowing users to select their preferred language for your app.
417+
418+
```js
419+
{
420+
"expo": {
421+
"plugins": [
422+
[
423+
"react-native-localize",
424+
{
425+
"supportedLocales": [
426+
"en",
427+
"fr"
428+
]
429+
}
430+
]
431+
]
432+
}
433+
}
434+
```
435+
436+
Alternatively, if you want to define different locales for iOS and Android, you can use:
437+
438+
```js
439+
{
440+
"expo": {
441+
"plugins": [
442+
[
443+
"react-native-localize",
444+
{
445+
"supportedLocales": {
446+
ios: [
447+
"en",
448+
"fr"
449+
],
450+
android: [
451+
"en",
452+
"fr"
453+
]
454+
}
455+
}
456+
]
457+
]
458+
}
459+
}
460+
```
461+
410462
## Sponsors
411463

412464
This module is provided **as is**, I work on it in my free time.

app.plugin.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
module.exports = require('./plugin/build');

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"/ios",
1515
"/mock",
1616
"/src",
17+
"/plugin/build",
1718
"RNLocalize.podspec",
1819
"package.json"
1920
],
@@ -34,6 +35,7 @@
3435
"format": "prettier '**/*' -u -w",
3536
"typecheck": "tsc --noEmit",
3637
"build": "yarn clean && bob build && rm -rf dist/*/package.json && tsc mock/{index,jest}.ts -d -m commonjs -t es2015 --skipLibCheck",
38+
"build:plugin": "expo-module build plugin",
3739
"prepack": "prettier '**/*' -u -c && yarn typecheck && yarn build"
3840
},
3941
"react-native-builder-bob": {
@@ -65,6 +67,8 @@
6567
"@babel/preset-env": "^7.25.3",
6668
"@types/jest": "^29.5.13",
6769
"@types/react": "^19.1.0",
70+
"expo": "^53.0.15",
71+
"expo-module-scripts": "^4.1.8",
6872
"prettier": "^3.6.2",
6973
"prettier-plugin-organize-imports": "^4.1.0",
7074
"react": "19.1.0",

plugin/build/index.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import type { ExpoConfig } from 'expo/config';
2+
type ConfigPluginProps = {
3+
supportedLocales: string[] | {
4+
ios?: string[];
5+
android?: string[];
6+
};
7+
};
8+
declare function withAppLanguageSetting(config: ExpoConfig, data: ConfigPluginProps): ExpoConfig;
9+
export default withAppLanguageSetting;

plugin/build/index.js

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"use strict";
2+
var __importDefault = (this && this.__importDefault) || function (mod) {
3+
return (mod && mod.__esModule) ? mod : { "default": mod };
4+
};
5+
Object.defineProperty(exports, "__esModule", { value: true });
6+
const config_plugins_1 = require("expo/config-plugins");
7+
const fs_1 = __importDefault(require("fs"));
8+
const path_1 = __importDefault(require("path"));
9+
const utils_1 = require("./utils");
10+
function withAppLanguageSettingAndroid(config, data) {
11+
const { supportedLocales } = data;
12+
const androidLocales = Array.isArray(supportedLocales) ? supportedLocales : supportedLocales.android;
13+
if (androidLocales) {
14+
config = (0, config_plugins_1.withDangerousMod)(config, [
15+
'android',
16+
(config) => {
17+
const projectRootPath = path_1.default.join(config.modRequest.platformProjectRoot);
18+
const folder = path_1.default.join(projectRootPath, 'app/src/main/res/xml');
19+
fs_1.default.mkdirSync(folder, { recursive: true });
20+
fs_1.default.writeFileSync(path_1.default.join(folder, 'locales_config.xml'), [
21+
'<?xml version="1.0" encoding="utf-8"?>',
22+
'<locale-config xmlns:android="http://schemas.android.com/apk/res/android">',
23+
...androidLocales.map((locale) => ` <locale android:name="${locale}"/>`),
24+
'</locale-config>',
25+
].join('\n'));
26+
return config;
27+
},
28+
]);
29+
config = (0, config_plugins_1.withAndroidManifest)(config, (config) => {
30+
const mainApplication = config_plugins_1.AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
31+
mainApplication.$ = {
32+
...mainApplication.$,
33+
'android:localeConfig': '@xml/locales_config',
34+
};
35+
return config;
36+
});
37+
config = (0, config_plugins_1.withAppBuildGradle)(config, (config) => {
38+
if (config.modResults.language === 'groovy') {
39+
config.modResults.contents = (0, utils_1.appendContentsInsideDeclarationBlock)(config.modResults.contents, 'defaultConfig', ` resourceConfigurations += [${androidLocales.map((lang) => `"${lang}"`).join(', ')}]\n `);
40+
}
41+
else {
42+
config_plugins_1.WarningAggregator.addWarningAndroid('react-native-localize languages', `Cannot automatically configure app build.gradle if it's not groovy`);
43+
}
44+
return config;
45+
});
46+
}
47+
return config;
48+
}
49+
function withAppLanguageSettingIos(config, data) {
50+
if (!config.ios) {
51+
config.ios = {};
52+
}
53+
if (!config.ios.infoPlist) {
54+
config.ios.infoPlist = {};
55+
}
56+
const { supportedLocales } = data;
57+
const iosLocales = Array.isArray(supportedLocales) ? supportedLocales : supportedLocales.ios;
58+
config.ios.infoPlist.CFBundleLocalizations = iosLocales;
59+
return config;
60+
}
61+
function withAppLanguageSetting(config, data) {
62+
return (0, config_plugins_1.withPlugins)(config, [
63+
[withAppLanguageSettingIos, data],
64+
[withAppLanguageSettingAndroid, data],
65+
]);
66+
}
67+
exports.default = withAppLanguageSetting;

plugin/build/utils.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export declare function appendContentsInsideDeclarationBlock(srcContents: string, declaration: string, insertion: string): string;
2+
type LeftBrackets = ['(', '{'];
3+
type RightBrackets = [')', '}'];
4+
type LeftBracket = LeftBrackets[number];
5+
type RightBracket = RightBrackets[number];
6+
type Bracket = LeftBracket | RightBracket;
7+
export declare function findMatchingBracketPosition(contents: string, bracket: Bracket, offset?: number): number;
8+
export {};

plugin/build/utils.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use strict";
2+
Object.defineProperty(exports, "__esModule", { value: true });
3+
exports.appendContentsInsideDeclarationBlock = appendContentsInsideDeclarationBlock;
4+
exports.findMatchingBracketPosition = findMatchingBracketPosition;
5+
function appendContentsInsideDeclarationBlock(srcContents, declaration, insertion) {
6+
const start = srcContents.search(new RegExp(`\\s*${declaration}.*?[\\(\\{]`));
7+
if (start < 0) {
8+
throw new Error(`Unable to find code block - declaration[${declaration}]`);
9+
}
10+
const end = findMatchingBracketPosition(srcContents, '{', start);
11+
return insertContentsAtOffset(srcContents, insertion, end);
12+
}
13+
function insertContentsAtOffset(srcContents, insertion, offset) {
14+
const srcContentsLength = srcContents.length;
15+
if (offset < 0 || offset > srcContentsLength) {
16+
throw new Error('Invalid parameters.');
17+
}
18+
if (offset === 0) {
19+
return `${insertion}${srcContents}`;
20+
}
21+
else if (offset === srcContentsLength) {
22+
return `${srcContents}${insertion}`;
23+
}
24+
const prefix = srcContents.substring(0, offset);
25+
const suffix = srcContents.substring(offset);
26+
return `${prefix}${insertion}${suffix}`;
27+
}
28+
function findMatchingBracketPosition(contents, bracket, offset = 0) {
29+
// search first occurrence of `bracket`
30+
const firstBracketPos = contents.indexOf(bracket, offset);
31+
if (firstBracketPos < 0) {
32+
return -1;
33+
}
34+
let stackCounter = 0;
35+
const matchingBracket = getMatchingBracket(bracket);
36+
if (isLeftBracket(bracket)) {
37+
const contentsLength = contents.length;
38+
// search forward
39+
for (let i = firstBracketPos + 1; i < contentsLength; ++i) {
40+
const c = contents[i];
41+
if (c === bracket) {
42+
stackCounter += 1;
43+
}
44+
else if (c === matchingBracket) {
45+
if (stackCounter === 0) {
46+
return i;
47+
}
48+
stackCounter -= 1;
49+
}
50+
}
51+
}
52+
else {
53+
// search backward
54+
for (let i = firstBracketPos - 1; i >= 0; --i) {
55+
const c = contents[i];
56+
if (c === bracket) {
57+
stackCounter += 1;
58+
}
59+
else if (c === matchingBracket) {
60+
if (stackCounter === 0) {
61+
return i;
62+
}
63+
stackCounter -= 1;
64+
}
65+
}
66+
}
67+
return -1;
68+
}
69+
function isLeftBracket(bracket) {
70+
const leftBracketList = ['(', '{'];
71+
return leftBracketList.includes(bracket);
72+
}
73+
function getMatchingBracket(bracket) {
74+
switch (bracket) {
75+
case '(':
76+
return ')';
77+
case ')':
78+
return '(';
79+
case '{':
80+
return '}';
81+
case '}':
82+
return '{';
83+
default:
84+
throw new Error(`Unsupported bracket - ${bracket}`);
85+
}
86+
}

plugin/src/index.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import type { ExpoConfig } from 'expo/config';
2+
import {
3+
WarningAggregator,
4+
withAndroidManifest,
5+
withAppBuildGradle,
6+
withDangerousMod,
7+
withPlugins,
8+
AndroidConfig,
9+
createRunOncePlugin,
10+
} from 'expo/config-plugins';
11+
12+
import fs from 'fs';
13+
import path from 'path';
14+
15+
import { appendContentsInsideDeclarationBlock } from './utils';
16+
17+
const pkg = require('react-native-localize/package.json');
18+
19+
type ConfigPluginProps = {
20+
supportedLocales: string[] | {
21+
ios?: string[];
22+
android?: string[];
23+
};
24+
};
25+
26+
function withAppLanguageSettingAndroid(config: ExpoConfig, data: ConfigPluginProps) {
27+
const { supportedLocales } = data;
28+
const androidLocales = Array.isArray(supportedLocales) ? supportedLocales : supportedLocales.android;
29+
30+
if (androidLocales) {
31+
config = withDangerousMod(config, [
32+
'android',
33+
(config) => {
34+
const projectRootPath = path.join(config.modRequest.platformProjectRoot);
35+
const folder = path.join(projectRootPath, 'app/src/main/res/xml');
36+
37+
fs.mkdirSync(folder, { recursive: true });
38+
fs.writeFileSync(
39+
path.join(folder, 'locales_config.xml'),
40+
[
41+
'<?xml version="1.0" encoding="utf-8"?>',
42+
'<locale-config xmlns:android="http://schemas.android.com/apk/res/android">',
43+
...androidLocales.map((locale) => ` <locale android:name="${locale}"/>`),
44+
'</locale-config>',
45+
].join('\n')
46+
);
47+
48+
return config;
49+
},
50+
]);
51+
config = withAndroidManifest(config, (config) => {
52+
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(config.modResults);
53+
54+
mainApplication.$ = {
55+
...mainApplication.$,
56+
'android:localeConfig': '@xml/locales_config',
57+
};
58+
59+
return config;
60+
});
61+
config = withAppBuildGradle(config, (config) => {
62+
if (config.modResults.language === 'groovy') {
63+
config.modResults.contents = appendContentsInsideDeclarationBlock(
64+
config.modResults.contents,
65+
'defaultConfig',
66+
` resourceConfigurations += [${androidLocales.map((lang) => `"${lang}"`).join(', ')}]\n `
67+
);
68+
} else {
69+
WarningAggregator.addWarningAndroid(
70+
'react-native-localize languages',
71+
`Cannot automatically configure app build.gradle if it's not groovy`
72+
);
73+
}
74+
75+
return config;
76+
});
77+
}
78+
79+
return config;
80+
}
81+
82+
function withAppLanguageSettingIos(
83+
config: ExpoConfig,
84+
data: ConfigPluginProps
85+
) {
86+
if (!config.ios) {
87+
config.ios = {};
88+
}
89+
90+
if (!config.ios.infoPlist) {
91+
config.ios.infoPlist = {};
92+
}
93+
94+
const { supportedLocales } = data;
95+
const iosLocales = Array.isArray(supportedLocales) ? supportedLocales : supportedLocales.ios;
96+
config.ios.infoPlist.CFBundleLocalizations = iosLocales;
97+
98+
return config;
99+
}
100+
101+
102+
function withAppLanguageSetting(
103+
config: ExpoConfig,
104+
data: ConfigPluginProps
105+
) {
106+
return withPlugins(config, [
107+
[withAppLanguageSettingIos, data],
108+
[withAppLanguageSettingAndroid, data],
109+
]);
110+
}
111+
112+
export default createRunOncePlugin(withAppLanguageSetting, pkg.name, pkg.version);

0 commit comments

Comments
 (0)