@highbeek/create-rnstarterkit 1.0.2-beta.11 → 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 +170 -13
- 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);
|
|
@@ -21,7 +21,7 @@ async function generateApp(options) {
|
|
|
21
21
|
if (platform === "React Native CLI") {
|
|
22
22
|
await configureCliNativeProjectNames(targetPath, projectName);
|
|
23
23
|
}
|
|
24
|
-
await createStandardStructure(targetPath, platform);
|
|
24
|
+
await createStandardStructure(targetPath, platform, state);
|
|
25
25
|
if (auth) {
|
|
26
26
|
const authFolder = state === "Redux Toolkit"
|
|
27
27
|
? "auth-redux"
|
|
@@ -73,18 +73,23 @@ 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)
|
|
80
87
|
await configureHusky(targetPath);
|
|
81
88
|
if (typescript) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
87
|
-
}
|
|
89
|
+
// Rename App.js -> App.tsx for projects generated without a .tsx entry point.
|
|
90
|
+
// Both cli-base and expo-base templates already ship App.tsx and correct
|
|
91
|
+
// tsconfigs (extending @react-native/typescript-config / expo/tsconfig.base),
|
|
92
|
+
// so we must NOT overwrite those configs here.
|
|
88
93
|
const appJs = path_1.default.join(targetPath, "App.js");
|
|
89
94
|
const appTsx = path_1.default.join(targetPath, "App.tsx");
|
|
90
95
|
if (await fs_extra_1.default.pathExists(appJs)) {
|
|
@@ -231,7 +236,7 @@ async function ensureCliApiEnvSupport(targetPath) {
|
|
|
231
236
|
},
|
|
232
237
|
});
|
|
233
238
|
}
|
|
234
|
-
async function createStandardStructure(targetPath, platform) {
|
|
239
|
+
async function createStandardStructure(targetPath, platform, state) {
|
|
235
240
|
const commonDirectories = [
|
|
236
241
|
"assets",
|
|
237
242
|
"assets/icons",
|
|
@@ -243,11 +248,14 @@ async function createStandardStructure(targetPath, platform) {
|
|
|
243
248
|
"services",
|
|
244
249
|
"api",
|
|
245
250
|
"config",
|
|
246
|
-
"context",
|
|
247
251
|
"theme",
|
|
248
252
|
"providers",
|
|
249
253
|
"types",
|
|
250
254
|
];
|
|
255
|
+
// Only scaffold a context/ dir when Context API is the chosen state solution
|
|
256
|
+
if (state === "Context API" || state === "None") {
|
|
257
|
+
commonDirectories.push("context");
|
|
258
|
+
}
|
|
251
259
|
const cliOnlyDirectories = ["navigation", "screens"];
|
|
252
260
|
const directories = platform === "Expo"
|
|
253
261
|
? commonDirectories
|
|
@@ -2253,7 +2261,7 @@ async function configureTesting(targetPath) {
|
|
|
2253
2261
|
}
|
|
2254
2262
|
}
|
|
2255
2263
|
}
|
|
2256
|
-
async function stampVersion(targetPath) {
|
|
2264
|
+
async function stampVersion(targetPath, options) {
|
|
2257
2265
|
const packageJsonPath = path_1.default.join(targetPath, "package.json");
|
|
2258
2266
|
if (!(await fs_extra_1.default.pathExists(packageJsonPath)))
|
|
2259
2267
|
return;
|
|
@@ -2274,9 +2282,158 @@ async function stampVersion(targetPath) {
|
|
|
2274
2282
|
}
|
|
2275
2283
|
}
|
|
2276
2284
|
const projectPkg = await fs_extra_1.default.readJson(packageJsonPath);
|
|
2277
|
-
|
|
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
|
+
};
|
|
2278
2298
|
await fs_extra_1.default.writeJson(packageJsonPath, projectPkg, { spaces: 2 });
|
|
2279
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
|
+
}
|
|
2280
2437
|
async function configureEnv(targetPath, platform) {
|
|
2281
2438
|
// Always create .env.example so devs know where to put secrets
|
|
2282
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"
|