@highbeek/create-rnstarterkit 1.0.2-beta.12 → 1.0.2-beta.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -154
- package/dist/bin/create-rnstarterkit.js +185 -7
- package/dist/src/generators/appGenerator.js +160 -4
- package/dist/src/generators/codeGenerator.js +288 -0
- package/dist/templates/optional/i18n/src/i18n/hooks/useAppTranslation.ts +28 -0
- package/dist/templates/optional/i18n/src/i18n/i18n.ts +30 -0
- package/dist/templates/optional/i18n/src/i18n/locales/en.json +32 -0
- package/dist/templates/optional/i18n/src/i18n/locales/es.json +32 -0
- package/dist/templates/optional/maestro/.maestro/flows/01_welcome.yaml +5 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/01_welcome.yaml +5 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/02_login.yaml +13 -0
- package/dist/templates/optional/maestro-auth/.maestro/flows/03_logout.yaml +16 -0
- package/dist/templates/optional/sentry/src/utils/sentry.ts +24 -0
- package/package.json +4 -1
|
@@ -8,7 +8,7 @@ const path_1 = __importDefault(require("path"));
|
|
|
8
8
|
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
9
|
const execa_1 = require("execa");
|
|
10
10
|
async function generateApp(options) {
|
|
11
|
-
const { platform, projectName, auth, apiClient, absoluteImports, state, dataFetching, validation, storage, typescript, apiClientType, husky, } = options;
|
|
11
|
+
const { platform, projectName, auth, apiClient, absoluteImports, state, dataFetching, validation, storage, typescript, apiClientType, husky, sentry, i18n, maestro, } = options;
|
|
12
12
|
const templateRoot = await resolveTemplateRoot();
|
|
13
13
|
const templateFolder = platform === "Expo" ? "expo-base" : "cli-base";
|
|
14
14
|
const templatePath = path_1.default.join(templateRoot, templateFolder);
|
|
@@ -73,7 +73,14 @@ async function generateApp(options) {
|
|
|
73
73
|
await configureTesting(targetPath);
|
|
74
74
|
await configureNavigationTypes(targetPath, { platform, auth, state });
|
|
75
75
|
await configureEnv(targetPath, platform);
|
|
76
|
-
await
|
|
76
|
+
await configureVSCode(targetPath);
|
|
77
|
+
if (sentry)
|
|
78
|
+
await configureSentry(targetPath, platform);
|
|
79
|
+
if (i18n)
|
|
80
|
+
await configureI18n(targetPath, platform);
|
|
81
|
+
if (maestro)
|
|
82
|
+
await configureMaestro(targetPath, { auth, ci: options.ci });
|
|
83
|
+
await stampVersion(targetPath, options);
|
|
77
84
|
if (options.ci)
|
|
78
85
|
await configureCi(targetPath);
|
|
79
86
|
if (husky)
|
|
@@ -2254,7 +2261,7 @@ async function configureTesting(targetPath) {
|
|
|
2254
2261
|
}
|
|
2255
2262
|
}
|
|
2256
2263
|
}
|
|
2257
|
-
async function stampVersion(targetPath) {
|
|
2264
|
+
async function stampVersion(targetPath, options) {
|
|
2258
2265
|
const packageJsonPath = path_1.default.join(targetPath, "package.json");
|
|
2259
2266
|
if (!(await fs_extra_1.default.pathExists(packageJsonPath)))
|
|
2260
2267
|
return;
|
|
@@ -2275,9 +2282,158 @@ async function stampVersion(targetPath) {
|
|
|
2275
2282
|
}
|
|
2276
2283
|
}
|
|
2277
2284
|
const projectPkg = await fs_extra_1.default.readJson(packageJsonPath);
|
|
2278
|
-
|
|
2285
|
+
// Save full config so `generate` subcommand can read project context
|
|
2286
|
+
projectPkg.rnstarterkitConfig = {
|
|
2287
|
+
version: cliVersion,
|
|
2288
|
+
platform: options.platform,
|
|
2289
|
+
state: options.state,
|
|
2290
|
+
auth: options.auth,
|
|
2291
|
+
dataFetching: options.dataFetching,
|
|
2292
|
+
storage: options.storage,
|
|
2293
|
+
apiClient: options.apiClient,
|
|
2294
|
+
sentry: options.sentry,
|
|
2295
|
+
i18n: options.i18n,
|
|
2296
|
+
maestro: options.maestro,
|
|
2297
|
+
};
|
|
2279
2298
|
await fs_extra_1.default.writeJson(packageJsonPath, projectPkg, { spaces: 2 });
|
|
2280
2299
|
}
|
|
2300
|
+
async function configureVSCode(targetPath) {
|
|
2301
|
+
await writeIfMissing(path_1.default.join(targetPath, ".vscode", "extensions.json"), JSON.stringify({
|
|
2302
|
+
recommendations: [
|
|
2303
|
+
"dbaeumer.vscode-eslint",
|
|
2304
|
+
"esbenp.prettier-vscode",
|
|
2305
|
+
"msjsdiag.vscode-react-native",
|
|
2306
|
+
"orta.vscode-jest",
|
|
2307
|
+
"pflannery.vscode-versionlens",
|
|
2308
|
+
],
|
|
2309
|
+
}, null, 2) + "\n");
|
|
2310
|
+
await writeIfMissing(path_1.default.join(targetPath, ".vscode", "settings.json"), JSON.stringify({
|
|
2311
|
+
"editor.formatOnSave": true,
|
|
2312
|
+
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
2313
|
+
"editor.codeActionsOnSave": {
|
|
2314
|
+
"source.fixAll.eslint": "explicit",
|
|
2315
|
+
},
|
|
2316
|
+
"typescript.preferences.importModuleSpecifier": "relative",
|
|
2317
|
+
"typescript.tsdk": "node_modules/typescript/lib",
|
|
2318
|
+
"emmet.includeLanguages": { typescript: "javascript" },
|
|
2319
|
+
"files.exclude": {
|
|
2320
|
+
"**/.git": true,
|
|
2321
|
+
"**/node_modules": true,
|
|
2322
|
+
"ios/Pods": true,
|
|
2323
|
+
"android/.gradle": true,
|
|
2324
|
+
},
|
|
2325
|
+
}, null, 2) + "\n");
|
|
2326
|
+
}
|
|
2327
|
+
async function configureSentry(targetPath, platform) {
|
|
2328
|
+
await ensureDependencies(targetPath, {
|
|
2329
|
+
dependencies: { "@sentry/react-native": "^6.4.0" },
|
|
2330
|
+
});
|
|
2331
|
+
// Write platform-aware sentry.ts — DSN is read from env, never hardcoded
|
|
2332
|
+
const isExpo = platform === "Expo";
|
|
2333
|
+
const dsnVar = isExpo ? "process.env.EXPO_PUBLIC_SENTRY_DSN" : "Config.SENTRY_DSN";
|
|
2334
|
+
const dsnImport = isExpo
|
|
2335
|
+
? ""
|
|
2336
|
+
: `import Config from 'react-native-config';\n`;
|
|
2337
|
+
const sentryContent = `${dsnImport}import * as Sentry from '@sentry/react-native';
|
|
2338
|
+
|
|
2339
|
+
export function initSentry() {
|
|
2340
|
+
const dsn = ${dsnVar};
|
|
2341
|
+
if (!dsn) {
|
|
2342
|
+
console.warn('[Sentry] DSN not set. Add ${isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN"} to your .env file.');
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
Sentry.init({
|
|
2346
|
+
dsn,
|
|
2347
|
+
tracesSampleRate: 1.0,
|
|
2348
|
+
debug: __DEV__,
|
|
2349
|
+
attachStacktrace: true,
|
|
2350
|
+
environment: __DEV__ ? 'development' : 'production',
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
/** Manually capture an exception (e.g. in a catch block) */
|
|
2355
|
+
export function captureException(error: unknown) {
|
|
2356
|
+
Sentry.captureException(error);
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
/** Add a breadcrumb for tracing user flows */
|
|
2360
|
+
export function addBreadcrumb(message: string, category = 'app') {
|
|
2361
|
+
Sentry.addBreadcrumb({ message, category });
|
|
2362
|
+
}
|
|
2363
|
+
`;
|
|
2364
|
+
await writeIfMissing(path_1.default.join(targetPath, "src", "utils", "sentry.ts"), sentryContent);
|
|
2365
|
+
// Add SENTRY_DSN to .env.example so devs know where to put it
|
|
2366
|
+
const envExamplePath = path_1.default.join(targetPath, ".env.example");
|
|
2367
|
+
if (await fs_extra_1.default.pathExists(envExamplePath)) {
|
|
2368
|
+
const envContent = await fs_extra_1.default.readFile(envExamplePath, "utf8");
|
|
2369
|
+
const sentryKey = isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN";
|
|
2370
|
+
if (!envContent.includes(sentryKey)) {
|
|
2371
|
+
await fs_extra_1.default.appendFile(envExamplePath, `\n# Sentry — get your DSN from https://sentry.io → Project Settings → Client Keys\n${sentryKey}=\n`);
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
// Inject initSentry into app entry point
|
|
2375
|
+
const entryFile = isExpo
|
|
2376
|
+
? path_1.default.join(targetPath, "app", "_layout.tsx")
|
|
2377
|
+
: path_1.default.join(targetPath, "App.tsx");
|
|
2378
|
+
if (await fs_extra_1.default.pathExists(entryFile)) {
|
|
2379
|
+
let content = await fs_extra_1.default.readFile(entryFile, "utf8");
|
|
2380
|
+
if (!content.includes("initSentry")) {
|
|
2381
|
+
content = content.replace(/^(import .+\n)+/m, (match) => `${match}import { initSentry } from '../src/utils/sentry';\n`);
|
|
2382
|
+
content = content.replace(/(export default|function )/, `initSentry();\n\n$1`);
|
|
2383
|
+
await fs_extra_1.default.writeFile(entryFile, content, "utf8");
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
console.log(`➕ Sentry configured. Add your DSN to .env:\n ${isExpo ? "EXPO_PUBLIC_SENTRY_DSN" : "SENTRY_DSN"}=https://...@sentry.io/...`);
|
|
2387
|
+
}
|
|
2388
|
+
async function configureI18n(targetPath, platform) {
|
|
2389
|
+
await copyOptionalModule("i18n", targetPath);
|
|
2390
|
+
await ensureDependencies(targetPath, {
|
|
2391
|
+
dependencies: {
|
|
2392
|
+
i18next: "^24.2.0",
|
|
2393
|
+
"react-i18next": "^15.4.0",
|
|
2394
|
+
"react-native-localize": "^3.4.0",
|
|
2395
|
+
},
|
|
2396
|
+
});
|
|
2397
|
+
// Inject i18n import into app entry so it initialises before first render
|
|
2398
|
+
const entryFile = platform === "Expo"
|
|
2399
|
+
? path_1.default.join(targetPath, "app", "_layout.tsx")
|
|
2400
|
+
: path_1.default.join(targetPath, "App.tsx");
|
|
2401
|
+
if (await fs_extra_1.default.pathExists(entryFile)) {
|
|
2402
|
+
let content = await fs_extra_1.default.readFile(entryFile, "utf8");
|
|
2403
|
+
if (!content.includes("i18n/i18n")) {
|
|
2404
|
+
// Add as first import so i18next is ready before any component renders
|
|
2405
|
+
content = `import '../src/i18n/i18n';\n${content}`;
|
|
2406
|
+
await fs_extra_1.default.writeFile(entryFile, content, "utf8");
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
async function configureMaestro(targetPath, options) {
|
|
2411
|
+
await copyOptionalModule(options.auth ? "maestro-auth" : "maestro", targetPath);
|
|
2412
|
+
// Extend CI workflow with a maestro job if CI was also selected
|
|
2413
|
+
if (options.ci) {
|
|
2414
|
+
const ciPath = path_1.default.join(targetPath, ".github", "workflows", "ci.yml");
|
|
2415
|
+
if (await fs_extra_1.default.pathExists(ciPath)) {
|
|
2416
|
+
const content = await fs_extra_1.default.readFile(ciPath, "utf8");
|
|
2417
|
+
if (!content.includes("maestro")) {
|
|
2418
|
+
await fs_extra_1.default.appendFile(ciPath, `
|
|
2419
|
+
e2e:
|
|
2420
|
+
name: E2E (Maestro)
|
|
2421
|
+
runs-on: macos-latest
|
|
2422
|
+
steps:
|
|
2423
|
+
- uses: actions/checkout@v4
|
|
2424
|
+
- uses: actions/setup-node@v4
|
|
2425
|
+
with:
|
|
2426
|
+
node-version: 20
|
|
2427
|
+
cache: npm
|
|
2428
|
+
- run: npm install
|
|
2429
|
+
- name: Install Maestro
|
|
2430
|
+
run: curl -Ls "https://get.maestro.mobile.dev" | bash
|
|
2431
|
+
- run: maestro test .maestro/flows/
|
|
2432
|
+
`);
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2281
2437
|
async function configureEnv(targetPath, platform) {
|
|
2282
2438
|
// Always create .env.example so devs know where to put secrets
|
|
2283
2439
|
await writeIfMissing(path_1.default.join(targetPath, ".env.example"), `# Environment variables — copy to .env and fill in values
|
|
@@ -0,0 +1,288 @@
|
|
|
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
|
+
exports.generateCode = generateCode;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Project context detection
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
async function detectProjectConfig() {
|
|
13
|
+
let dir = process.cwd();
|
|
14
|
+
const root = path_1.default.parse(dir).root;
|
|
15
|
+
while (dir !== root) {
|
|
16
|
+
const pkgPath = path_1.default.join(dir, "package.json");
|
|
17
|
+
if (await fs_extra_1.default.pathExists(pkgPath)) {
|
|
18
|
+
const pkg = await fs_extra_1.default.readJson(pkgPath);
|
|
19
|
+
if (pkg.rnstarterkitConfig) {
|
|
20
|
+
return { config: pkg.rnstarterkitConfig, projectRoot: dir };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
dir = path_1.default.dirname(dir);
|
|
24
|
+
}
|
|
25
|
+
throw new Error("❌ No rnstarterkitConfig found in package.json.\n" +
|
|
26
|
+
" Run this command from inside a project created with create-rnstarterkit.");
|
|
27
|
+
}
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
function toPascalCase(str) {
|
|
32
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
33
|
+
}
|
|
34
|
+
function toCamelCase(str) {
|
|
35
|
+
return str.charAt(0).toLowerCase() + str.slice(1);
|
|
36
|
+
}
|
|
37
|
+
async function writeAndLog(filePath, content) {
|
|
38
|
+
if (await fs_extra_1.default.pathExists(filePath)) {
|
|
39
|
+
console.error(`❌ File already exists: ${filePath}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
await fs_extra_1.default.ensureDir(path_1.default.dirname(filePath));
|
|
43
|
+
await fs_extra_1.default.writeFile(filePath, content, "utf8");
|
|
44
|
+
console.log(`✅ Created ${path_1.default.relative(process.cwd(), filePath)}`);
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// screen generator
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
function screenStateImports(config) {
|
|
50
|
+
if (!config.auth)
|
|
51
|
+
return "";
|
|
52
|
+
if (config.state === "Redux Toolkit") {
|
|
53
|
+
return `import { useAppSelector } from '../store/hooks';\n`;
|
|
54
|
+
}
|
|
55
|
+
if (config.state === "Zustand") {
|
|
56
|
+
return `import { useAuthStore } from '../store/authStore';\n`;
|
|
57
|
+
}
|
|
58
|
+
if (config.state === "Context API") {
|
|
59
|
+
return `import { useContext } from 'react';\nimport { AuthContext } from '../context/AuthContext';\n`;
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
function screenStateHook(config) {
|
|
64
|
+
if (!config.auth)
|
|
65
|
+
return "";
|
|
66
|
+
if (config.state === "Redux Toolkit") {
|
|
67
|
+
return `\n const token = useAppSelector((state) => state.auth.token);\n`;
|
|
68
|
+
}
|
|
69
|
+
if (config.state === "Zustand") {
|
|
70
|
+
return `\n const { token } = useAuthStore();\n`;
|
|
71
|
+
}
|
|
72
|
+
if (config.state === "Context API") {
|
|
73
|
+
return `\n const { token } = useContext(AuthContext);\n`;
|
|
74
|
+
}
|
|
75
|
+
return "";
|
|
76
|
+
}
|
|
77
|
+
async function generateScreen(name, config, projectRoot) {
|
|
78
|
+
const screenName = toPascalCase(name);
|
|
79
|
+
const fileName = `${screenName}Screen.tsx`;
|
|
80
|
+
const targetDir = config.platform === "Expo"
|
|
81
|
+
? path_1.default.join(projectRoot, "app")
|
|
82
|
+
: path_1.default.join(projectRoot, "src", "screens");
|
|
83
|
+
const filePath = path_1.default.join(targetDir, fileName);
|
|
84
|
+
const stateImports = screenStateImports(config);
|
|
85
|
+
const stateHook = screenStateHook(config);
|
|
86
|
+
const layoutImport = config.platform === "React Native CLI"
|
|
87
|
+
? `import ScreenLayout from '../components/layout/ScreenLayout';\n`
|
|
88
|
+
: "";
|
|
89
|
+
const wrapper = config.platform === "React Native CLI"
|
|
90
|
+
? ["<ScreenLayout>", " </ScreenLayout>"]
|
|
91
|
+
: ["<View style={styles.container}>", " </View>"];
|
|
92
|
+
const content = config.platform === "Expo"
|
|
93
|
+
? `import React from 'react';
|
|
94
|
+
import { View, Text, StyleSheet } from 'react-native';
|
|
95
|
+
${stateImports}
|
|
96
|
+
export default function ${screenName}Screen() {${stateHook}
|
|
97
|
+
return (
|
|
98
|
+
${wrapper[0]}
|
|
99
|
+
<Text style={styles.title}>${screenName}</Text>
|
|
100
|
+
${wrapper[1]}
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const styles = StyleSheet.create({
|
|
105
|
+
container: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 20 },
|
|
106
|
+
title: { fontSize: 24, fontWeight: 'bold', color: '#111827' },
|
|
107
|
+
});
|
|
108
|
+
`
|
|
109
|
+
: `import React from 'react';
|
|
110
|
+
import { Text, StyleSheet } from 'react-native';
|
|
111
|
+
${layoutImport}${stateImports}
|
|
112
|
+
export default function ${screenName}Screen() {${stateHook}
|
|
113
|
+
return (
|
|
114
|
+
${wrapper[0]}
|
|
115
|
+
<Text style={styles.title}>${screenName}</Text>
|
|
116
|
+
${wrapper[1]}
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const styles = StyleSheet.create({
|
|
121
|
+
title: { fontSize: 24, fontWeight: 'bold', color: '#111827' },
|
|
122
|
+
});
|
|
123
|
+
`;
|
|
124
|
+
await writeAndLog(filePath, content);
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// component generator
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
async function generateComponent(name, projectRoot) {
|
|
130
|
+
const componentName = toPascalCase(name);
|
|
131
|
+
const filePath = path_1.default.join(projectRoot, "src", "components", `${componentName}.tsx`);
|
|
132
|
+
const content = `import React from 'react';
|
|
133
|
+
import { View, Text, StyleSheet, type ViewStyle } from 'react-native';
|
|
134
|
+
|
|
135
|
+
type ${componentName}Props = {
|
|
136
|
+
label?: string;
|
|
137
|
+
style?: ViewStyle;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export function ${componentName}({ label = '${componentName}', style }: ${componentName}Props) {
|
|
141
|
+
return (
|
|
142
|
+
<View style={[styles.container, style]}>
|
|
143
|
+
<Text style={styles.label}>{label}</Text>
|
|
144
|
+
</View>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const styles = StyleSheet.create({
|
|
149
|
+
container: { padding: 12 },
|
|
150
|
+
label: { fontSize: 14, color: '#374151' },
|
|
151
|
+
});
|
|
152
|
+
`;
|
|
153
|
+
await writeAndLog(filePath, content);
|
|
154
|
+
}
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// hook generator
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
async function generateHook(name, projectRoot) {
|
|
159
|
+
const hookName = `use${toPascalCase(name)}`;
|
|
160
|
+
const filePath = path_1.default.join(projectRoot, "src", "hooks", `${hookName}.ts`);
|
|
161
|
+
const content = `import { useState, useCallback } from 'react';
|
|
162
|
+
|
|
163
|
+
type ${toPascalCase(name)}State = {
|
|
164
|
+
data: null;
|
|
165
|
+
isLoading: boolean;
|
|
166
|
+
error: string | null;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
export function ${hookName}() {
|
|
170
|
+
const [state, setState] = useState<${toPascalCase(name)}State>({
|
|
171
|
+
data: null,
|
|
172
|
+
isLoading: false,
|
|
173
|
+
error: null,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const execute = useCallback(async () => {
|
|
177
|
+
setState((prev) => ({ ...prev, isLoading: true, error: null }));
|
|
178
|
+
try {
|
|
179
|
+
// TODO: implement hook logic
|
|
180
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
181
|
+
} catch (err) {
|
|
182
|
+
setState((prev) => ({
|
|
183
|
+
...prev,
|
|
184
|
+
isLoading: false,
|
|
185
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
return { ...state, execute };
|
|
191
|
+
}
|
|
192
|
+
`;
|
|
193
|
+
await writeAndLog(filePath, content);
|
|
194
|
+
}
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// slice generator
|
|
197
|
+
// ---------------------------------------------------------------------------
|
|
198
|
+
async function generateSlice(name, config, projectRoot) {
|
|
199
|
+
if (config.state !== "Redux Toolkit") {
|
|
200
|
+
console.error(`❌ Slice generation requires Redux Toolkit. This project uses: ${config.state || "None"}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const sliceName = toCamelCase(name);
|
|
204
|
+
const SliceName = toPascalCase(name);
|
|
205
|
+
const filePath = path_1.default.join(projectRoot, "src", "store", `${sliceName}Slice.ts`);
|
|
206
|
+
// --- write the slice file ---
|
|
207
|
+
const sliceContent = `import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
|
208
|
+
|
|
209
|
+
interface ${SliceName}State {
|
|
210
|
+
// TODO: define your state shape
|
|
211
|
+
data: null;
|
|
212
|
+
isLoading: boolean;
|
|
213
|
+
error: string | null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const initialState: ${SliceName}State = {
|
|
217
|
+
data: null,
|
|
218
|
+
isLoading: false,
|
|
219
|
+
error: null,
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const ${sliceName}Slice = createSlice({
|
|
223
|
+
name: '${sliceName}',
|
|
224
|
+
initialState,
|
|
225
|
+
reducers: {
|
|
226
|
+
setLoading(state, _action: PayloadAction<boolean>) {
|
|
227
|
+
state.isLoading = _action.payload;
|
|
228
|
+
},
|
|
229
|
+
setError(state, action: PayloadAction<string | null>) {
|
|
230
|
+
state.error = action.payload;
|
|
231
|
+
},
|
|
232
|
+
reset() {
|
|
233
|
+
return initialState;
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
export const { setLoading, setError, reset } = ${sliceName}Slice.actions;
|
|
239
|
+
export default ${sliceName}Slice.reducer;
|
|
240
|
+
`;
|
|
241
|
+
await writeAndLog(filePath, sliceContent);
|
|
242
|
+
// --- patch store.ts to register the new reducer ---
|
|
243
|
+
const storePath = path_1.default.join(projectRoot, "src", "store", "store.ts");
|
|
244
|
+
if (await fs_extra_1.default.pathExists(storePath)) {
|
|
245
|
+
let storeContent = await fs_extra_1.default.readFile(storePath, "utf8");
|
|
246
|
+
const importLine = `import ${sliceName}Reducer from './${sliceName}Slice';\n`;
|
|
247
|
+
if (!storeContent.includes(importLine)) {
|
|
248
|
+
// Add import after last existing import
|
|
249
|
+
storeContent = storeContent.replace(/^(import .+\n)+/m, (match) => `${match}${importLine}`);
|
|
250
|
+
}
|
|
251
|
+
// Add reducer key inside configureStore reducer object
|
|
252
|
+
if (!storeContent.includes(`${sliceName}:`)) {
|
|
253
|
+
storeContent = storeContent.replace(/reducer:\s*\{/, `reducer: {\n ${sliceName}: ${sliceName}Reducer,`);
|
|
254
|
+
}
|
|
255
|
+
await fs_extra_1.default.writeFile(storePath, storeContent, "utf8");
|
|
256
|
+
console.log(`🔗 Registered ${sliceName} in src/store/store.ts`);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
console.warn(`⚠️ src/store/store.ts not found — add the reducer manually:\n` +
|
|
260
|
+
` import ${sliceName}Reducer from './${sliceName}Slice';\n` +
|
|
261
|
+
` // add ${sliceName}: ${sliceName}Reducer to your store`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Public entry point
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
async function generateCode(type, name) {
|
|
268
|
+
const { config, projectRoot } = await detectProjectConfig();
|
|
269
|
+
const validTypes = ["screen", "component", "hook", "slice"];
|
|
270
|
+
if (!validTypes.includes(type)) {
|
|
271
|
+
console.error(`❌ Unknown type "${type}". Valid types: ${validTypes.join(" | ")}`);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
switch (type) {
|
|
275
|
+
case "screen":
|
|
276
|
+
await generateScreen(name, config, projectRoot);
|
|
277
|
+
break;
|
|
278
|
+
case "component":
|
|
279
|
+
await generateComponent(name, projectRoot);
|
|
280
|
+
break;
|
|
281
|
+
case "hook":
|
|
282
|
+
await generateHook(name, projectRoot);
|
|
283
|
+
break;
|
|
284
|
+
case "slice":
|
|
285
|
+
await generateSlice(name, config, projectRoot);
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import type en from '../locales/en.json';
|
|
3
|
+
|
|
4
|
+
// Derive the full key union from the English locale so translations are type-safe
|
|
5
|
+
type TranslationKeys = typeof en;
|
|
6
|
+
type DotPrefix<T extends string, K extends string> = `${T}.${K}`;
|
|
7
|
+
|
|
8
|
+
type LeafKeys<T, Prefix extends string = ''> = {
|
|
9
|
+
[K in keyof T]: T[K] extends object
|
|
10
|
+
? LeafKeys<T[K], Prefix extends '' ? Extract<K, string> : DotPrefix<Prefix, Extract<K, string>>>
|
|
11
|
+
: Prefix extends ''
|
|
12
|
+
? Extract<K, string>
|
|
13
|
+
: DotPrefix<Prefix, Extract<K, string>>;
|
|
14
|
+
}[keyof T];
|
|
15
|
+
|
|
16
|
+
export type AppTranslationKey = LeafKeys<TranslationKeys>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Typed wrapper around react-i18next's useTranslation.
|
|
20
|
+
* Keys are inferred from en.json so you get autocompletion + compile-time safety.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* const { t } = useAppTranslation();
|
|
24
|
+
* <Text>{t('auth.signIn')}</Text>
|
|
25
|
+
*/
|
|
26
|
+
export function useAppTranslation() {
|
|
27
|
+
return useTranslation();
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
import { initReactI18next } from 'react-i18next';
|
|
3
|
+
import * as RNLocalize from 'react-native-localize';
|
|
4
|
+
|
|
5
|
+
import en from './locales/en.json';
|
|
6
|
+
import es from './locales/es.json';
|
|
7
|
+
|
|
8
|
+
const resources = {
|
|
9
|
+
en: { translation: en },
|
|
10
|
+
es: { translation: es },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const fallbackLocale = 'en';
|
|
14
|
+
const deviceLocales = RNLocalize.getLocales();
|
|
15
|
+
const bestMatch = deviceLocales[0]?.languageCode ?? fallbackLocale;
|
|
16
|
+
|
|
17
|
+
void i18n
|
|
18
|
+
.use(initReactI18next)
|
|
19
|
+
.init({
|
|
20
|
+
resources,
|
|
21
|
+
lng: bestMatch,
|
|
22
|
+
fallbackLng: fallbackLocale,
|
|
23
|
+
interpolation: {
|
|
24
|
+
// React already escapes values
|
|
25
|
+
escapeValue: false,
|
|
26
|
+
},
|
|
27
|
+
compatibilityJSON: 'v4',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export default i18n;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"welcome": {
|
|
3
|
+
"title": "Welcome",
|
|
4
|
+
"subtitle": "Get started with your app",
|
|
5
|
+
"getStarted": "Get Started"
|
|
6
|
+
},
|
|
7
|
+
"auth": {
|
|
8
|
+
"signIn": "Sign In",
|
|
9
|
+
"signUp": "Sign Up",
|
|
10
|
+
"signOut": "Sign Out",
|
|
11
|
+
"email": "Email",
|
|
12
|
+
"password": "Password",
|
|
13
|
+
"confirmPassword": "Confirm Password",
|
|
14
|
+
"forgotPassword": "Forgot password?",
|
|
15
|
+
"noAccount": "Don't have an account?",
|
|
16
|
+
"haveAccount": "Already have an account?"
|
|
17
|
+
},
|
|
18
|
+
"common": {
|
|
19
|
+
"loading": "Loading...",
|
|
20
|
+
"error": "Something went wrong",
|
|
21
|
+
"retry": "Try again",
|
|
22
|
+
"cancel": "Cancel",
|
|
23
|
+
"save": "Save",
|
|
24
|
+
"delete": "Delete",
|
|
25
|
+
"confirm": "Confirm"
|
|
26
|
+
},
|
|
27
|
+
"screens": {
|
|
28
|
+
"home": "Home",
|
|
29
|
+
"profile": "Profile",
|
|
30
|
+
"settings": "Settings"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"welcome": {
|
|
3
|
+
"title": "Bienvenido",
|
|
4
|
+
"subtitle": "Empieza con tu aplicación",
|
|
5
|
+
"getStarted": "Comenzar"
|
|
6
|
+
},
|
|
7
|
+
"auth": {
|
|
8
|
+
"signIn": "Iniciar sesión",
|
|
9
|
+
"signUp": "Registrarse",
|
|
10
|
+
"signOut": "Cerrar sesión",
|
|
11
|
+
"email": "Correo electrónico",
|
|
12
|
+
"password": "Contraseña",
|
|
13
|
+
"confirmPassword": "Confirmar contraseña",
|
|
14
|
+
"forgotPassword": "¿Olvidaste tu contraseña?",
|
|
15
|
+
"noAccount": "¿No tienes cuenta?",
|
|
16
|
+
"haveAccount": "¿Ya tienes cuenta?"
|
|
17
|
+
},
|
|
18
|
+
"common": {
|
|
19
|
+
"loading": "Cargando...",
|
|
20
|
+
"error": "Algo salió mal",
|
|
21
|
+
"retry": "Intentar de nuevo",
|
|
22
|
+
"cancel": "Cancelar",
|
|
23
|
+
"save": "Guardar",
|
|
24
|
+
"delete": "Eliminar",
|
|
25
|
+
"confirm": "Confirmar"
|
|
26
|
+
},
|
|
27
|
+
"screens": {
|
|
28
|
+
"home": "Inicio",
|
|
29
|
+
"profile": "Perfil",
|
|
30
|
+
"settings": "Configuración"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
appId: com.rnstarterkit.app
|
|
2
|
+
---
|
|
3
|
+
- launchApp
|
|
4
|
+
- tapOn: "Get Started"
|
|
5
|
+
- tapOn: "Sign In"
|
|
6
|
+
- tapOn:
|
|
7
|
+
id: "email-input"
|
|
8
|
+
- inputText: "test@example.com"
|
|
9
|
+
- tapOn:
|
|
10
|
+
id: "password-input"
|
|
11
|
+
- inputText: "password123"
|
|
12
|
+
- tapOn: "Sign In"
|
|
13
|
+
- assertVisible: "Home"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
appId: com.rnstarterkit.app
|
|
2
|
+
---
|
|
3
|
+
- launchApp
|
|
4
|
+
- tapOn: "Get Started"
|
|
5
|
+
- tapOn: "Sign In"
|
|
6
|
+
- tapOn:
|
|
7
|
+
id: "email-input"
|
|
8
|
+
- inputText: "test@example.com"
|
|
9
|
+
- tapOn:
|
|
10
|
+
id: "password-input"
|
|
11
|
+
- inputText: "password123"
|
|
12
|
+
- tapOn: "Sign In"
|
|
13
|
+
- assertVisible: "Home"
|
|
14
|
+
- tapOn: "Profile"
|
|
15
|
+
- tapOn: "Sign Out"
|
|
16
|
+
- assertVisible: "Welcome"
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as Sentry from '@sentry/react-native';
|
|
2
|
+
|
|
3
|
+
export function initSentry() {
|
|
4
|
+
Sentry.init({
|
|
5
|
+
// Replace with your Sentry DSN from https://sentry.io
|
|
6
|
+
dsn: '__YOUR_DSN__',
|
|
7
|
+
tracesSampleRate: 1.0,
|
|
8
|
+
// Disable verbose logging in production
|
|
9
|
+
debug: __DEV__,
|
|
10
|
+
// Attach JS/native stack traces to all events
|
|
11
|
+
attachStacktrace: true,
|
|
12
|
+
environment: __DEV__ ? 'development' : 'production',
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Manually capture an exception (e.g. in a catch block) */
|
|
17
|
+
export function captureException(error: unknown) {
|
|
18
|
+
Sentry.captureException(error);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Add a breadcrumb for tracing user flows */
|
|
22
|
+
export function addBreadcrumb(message: string, category = 'app') {
|
|
23
|
+
Sentry.addBreadcrumb({ message, category });
|
|
24
|
+
}
|