@developerankitkumar/create-reactforge 0.0.0-stage → 1.0.0
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 +87 -2
- package/bin/index.js +222 -0
- package/bin/template-downloader.js +168 -0
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,3 +1,88 @@
|
|
|
1
|
-
#
|
|
1
|
+
# React TypeScript Starter CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A reusable React + TypeScript starter template with Material UI, SCSS, authentication, API integration, dynamic themes, and ready-to-use components.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
Create a new project using:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx --yes @developerankitkumar/create-react-starter@latest my-new-app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd my-new-app
|
|
17
|
+
npm install
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Features
|
|
22
|
+
|
|
23
|
+
- React with TypeScript and Vite
|
|
24
|
+
- Material UI (MUI)
|
|
25
|
+
- SCSS architecture
|
|
26
|
+
- Dynamic Light, Dark, and System themes
|
|
27
|
+
- Authentication and protected routes
|
|
28
|
+
- Mock API and real backend API modes
|
|
29
|
+
- Configurable backend URL
|
|
30
|
+
- Reusable layout with sidebar and topbar
|
|
31
|
+
- Reusable form and table components
|
|
32
|
+
- User management example
|
|
33
|
+
- Snackbar notifications
|
|
34
|
+
- Excel import and file upload components
|
|
35
|
+
- Font Awesome Free icons
|
|
36
|
+
|
|
37
|
+
## Interactive Setup
|
|
38
|
+
|
|
39
|
+
The CLI allows you to configure:
|
|
40
|
+
|
|
41
|
+
1. Project name
|
|
42
|
+
2. Default theme: System, Light, or Dark
|
|
43
|
+
3. API mode: Mock API or Real API
|
|
44
|
+
4. Backend URL when Real API is selected
|
|
45
|
+
|
|
46
|
+
## Environment Configuration
|
|
47
|
+
|
|
48
|
+
Generated projects support environment variables such as:
|
|
49
|
+
|
|
50
|
+
```env
|
|
51
|
+
VITE_USE_MOCK=true
|
|
52
|
+
VITE_API_BASE_URL=http://localhost:8000
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Use Mock API mode for frontend development without a backend, or Real API mode to connect your application to an existing backend.
|
|
56
|
+
|
|
57
|
+
## Project Structure
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
src/
|
|
61
|
+
├── components/
|
|
62
|
+
├── context/
|
|
63
|
+
├── interface/
|
|
64
|
+
├── mock/
|
|
65
|
+
├── pages/
|
|
66
|
+
├── services/
|
|
67
|
+
├── styles/
|
|
68
|
+
├── types/
|
|
69
|
+
└── utils/
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Build for Production
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm run build
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## NPM Package
|
|
79
|
+
|
|
80
|
+
[@developerankitkumar/create-react-starter](https://www.npmjs.com/package/@developerankitkumar/create-react-starter)
|
|
81
|
+
|
|
82
|
+
## GitHub Repository
|
|
83
|
+
|
|
84
|
+
[developerankitkumar/create-react-starter](https://github.com/developerankitkumar/create-react-starter)
|
|
85
|
+
|
|
86
|
+
## Author
|
|
87
|
+
|
|
88
|
+
Developed by **developerankitkumar**.
|
package/bin/index.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import prompts from 'prompts';
|
|
7
|
+
import pc from 'picocolors';
|
|
8
|
+
import { downloadTemplate } from './template-downloader.js';
|
|
9
|
+
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
|
|
12
|
+
let projectName = process.argv[2];
|
|
13
|
+
|
|
14
|
+
if (!projectName) {
|
|
15
|
+
const response = await prompts({
|
|
16
|
+
type: 'text',
|
|
17
|
+
name: 'projectName',
|
|
18
|
+
message: 'Enter your project name:',
|
|
19
|
+
initial: 'my-react-app',
|
|
20
|
+
validate: (value) =>
|
|
21
|
+
/^[a-z0-9][a-z0-9._-]*$/.test(value)
|
|
22
|
+
? true
|
|
23
|
+
: 'Use a valid lowercase project name',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
projectName = response.projectName;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!projectName) {
|
|
30
|
+
console.log(pc.yellow('Project creation cancelled.'));
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
!/^[a-z0-9][a-z0-9._-]*$/.test(projectName) ||
|
|
36
|
+
projectName === '.' ||
|
|
37
|
+
projectName === '..'
|
|
38
|
+
) {
|
|
39
|
+
console.error(pc.red('Invalid project name.'));
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const themeResponse = await prompts({
|
|
44
|
+
type: 'select',
|
|
45
|
+
name: 'theme',
|
|
46
|
+
message: 'Choose default theme:',
|
|
47
|
+
choices: [
|
|
48
|
+
{ title: 'System (Recommended)', value: 'system' },
|
|
49
|
+
{ title: 'Light', value: 'light' },
|
|
50
|
+
{ title: 'Dark', value: 'dark' },
|
|
51
|
+
],
|
|
52
|
+
initial: 0,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (!themeResponse.theme) {
|
|
56
|
+
console.log(pc.yellow('Project creation cancelled.'));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const authResponse = await prompts({
|
|
61
|
+
type: 'select',
|
|
62
|
+
name: 'authMode',
|
|
63
|
+
message: 'Choose API / Authentication mode:',
|
|
64
|
+
choices: [
|
|
65
|
+
{ title: 'Mock API (Demo / Development)', value: 'mock' },
|
|
66
|
+
{ title: 'Real API (Backend Integration)', value: 'real' },
|
|
67
|
+
],
|
|
68
|
+
initial: 0,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!authResponse.authMode) {
|
|
72
|
+
console.log(pc.yellow('Project creation cancelled.'));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let apiBaseUrl = '';
|
|
77
|
+
|
|
78
|
+
if (authResponse.authMode === 'real') {
|
|
79
|
+
const apiResponse = await prompts({
|
|
80
|
+
type: 'text',
|
|
81
|
+
name: 'baseUrl',
|
|
82
|
+
message: 'Enter Backend API Base URL:',
|
|
83
|
+
initial: 'http://localhost:8000',
|
|
84
|
+
validate: (value) => {
|
|
85
|
+
try {
|
|
86
|
+
const url = new URL(value);
|
|
87
|
+
|
|
88
|
+
return ['http:', 'https:'].includes(url.protocol)
|
|
89
|
+
? true
|
|
90
|
+
: 'URL must start with http:// or https://';
|
|
91
|
+
} catch {
|
|
92
|
+
return 'Please enter a valid API URL';
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!apiResponse.baseUrl) {
|
|
98
|
+
console.log(pc.yellow('Project creation cancelled.'));
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
apiBaseUrl = apiResponse.baseUrl.trim().replace(/\/+$/, '');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const targetDir = path.resolve(process.cwd(), projectName);
|
|
106
|
+
const licenseResponse = await prompts({
|
|
107
|
+
type: 'password',
|
|
108
|
+
name: 'licenseKey',
|
|
109
|
+
message: 'Enter your React Starter license key:',
|
|
110
|
+
validate: (value) =>
|
|
111
|
+
/^AK-[A-F0-9]{8}(-[A-F0-9]{8}){3}$/i.test(value.trim())
|
|
112
|
+
? true
|
|
113
|
+
: 'Please enter a valid license key format',
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!licenseResponse.licenseKey) {
|
|
117
|
+
console.log(pc.yellow('Project creation cancelled.'));
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const licenseKey = licenseResponse.licenseKey.trim();
|
|
122
|
+
if (fs.existsSync(targetDir)) {
|
|
123
|
+
console.error(pc.red(`Folder "${projectName}" already exists.`));
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
console.log(pc.cyan('\nValidating license and downloading template...\n'));
|
|
129
|
+
|
|
130
|
+
await downloadTemplate({
|
|
131
|
+
licenseKey,
|
|
132
|
+
targetDir,
|
|
133
|
+
projectName,
|
|
134
|
+
serverUrl:
|
|
135
|
+
process.env.REACT_STARTER_LICENSE_SERVER_URL ||
|
|
136
|
+
'https://license.ankitdeveloper.site',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
console.log(pc.green('✔ License verified and template downloaded.\n'));
|
|
140
|
+
|
|
141
|
+
// Generate environment configuration
|
|
142
|
+
const envPath = path.join(targetDir, '.env');
|
|
143
|
+
|
|
144
|
+
const envContent = [
|
|
145
|
+
`VITE_USE_MOCK=${authResponse.authMode === 'mock'}`,
|
|
146
|
+
`VITE_API_BASE_URL=${apiBaseUrl}`,
|
|
147
|
+
'VITE_NGROK_SKIP=',
|
|
148
|
+
'',
|
|
149
|
+
].join('\n');
|
|
150
|
+
|
|
151
|
+
fs.writeFileSync(envPath, envContent);
|
|
152
|
+
|
|
153
|
+
// Apply selected default theme to generated project
|
|
154
|
+
const themeContextPath = path.join(
|
|
155
|
+
targetDir,
|
|
156
|
+
'src/context/ThemeModeContext.tsx'
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
let themeContext = fs.readFileSync(themeContextPath, 'utf8');
|
|
160
|
+
|
|
161
|
+
const themePlaceholder =
|
|
162
|
+
"export const DEFAULT_THEME_MODE: ThemeMode = 'system'";
|
|
163
|
+
|
|
164
|
+
if (!themeContext.includes(themePlaceholder)) {
|
|
165
|
+
throw new Error('Default theme configuration not found in template.');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
themeContext = themeContext.replace(
|
|
169
|
+
themePlaceholder,
|
|
170
|
+
`export const DEFAULT_THEME_MODE: ThemeMode = '${themeResponse.theme}'`
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
fs.writeFileSync(themeContextPath, themeContext);
|
|
174
|
+
|
|
175
|
+
// Create .gitignore in the generated project
|
|
176
|
+
const gitignorePath = path.join(targetDir, '.gitignore');
|
|
177
|
+
|
|
178
|
+
fs.writeFileSync(
|
|
179
|
+
gitignorePath,
|
|
180
|
+
[
|
|
181
|
+
'node_modules/',
|
|
182
|
+
'dist/',
|
|
183
|
+
'.env',
|
|
184
|
+
'.env.*',
|
|
185
|
+
'!.env.example',
|
|
186
|
+
'.DS_Store',
|
|
187
|
+
'.idea/',
|
|
188
|
+
'*.log',
|
|
189
|
+
'',
|
|
190
|
+
].join('\n')
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const packagePath = path.join(targetDir, 'package.json');
|
|
194
|
+
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
195
|
+
|
|
196
|
+
pkg.name = projectName;
|
|
197
|
+
pkg.version = '0.1.0';
|
|
198
|
+
pkg.private = true;
|
|
199
|
+
|
|
200
|
+
fs.writeFileSync(
|
|
201
|
+
packagePath,
|
|
202
|
+
JSON.stringify(pkg, null, 2) + '\n'
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// Regenerate lockfile during npm install
|
|
206
|
+
const lockPath = path.join(targetDir, 'package-lock.json');
|
|
207
|
+
|
|
208
|
+
if (fs.existsSync(lockPath)) {
|
|
209
|
+
fs.rmSync(lockPath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
console.log(pc.green('✔ Project created successfully!\n'));
|
|
213
|
+
|
|
214
|
+
console.log('Next steps:\n');
|
|
215
|
+
console.log(pc.cyan(` cd ${projectName}`));
|
|
216
|
+
console.log(pc.cyan(' npm install'));
|
|
217
|
+
console.log(pc.cyan(' npm run dev\n'));
|
|
218
|
+
|
|
219
|
+
} catch (error) {
|
|
220
|
+
console.error(pc.red(`Project creation failed: ${error.message}`));
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { unzipSync } from 'fflate';
|
|
6
|
+
|
|
7
|
+
const MAX_ZIP_SIZE = 20 * 1024 * 1024;
|
|
8
|
+
const MAX_FILE_SIZE = 30 * 1024 * 1024;
|
|
9
|
+
const MAX_TOTAL_SIZE = 100 * 1024 * 1024;
|
|
10
|
+
const MAX_FILES = 5000;
|
|
11
|
+
|
|
12
|
+
export async function downloadTemplate({
|
|
13
|
+
licenseKey,
|
|
14
|
+
targetDir,
|
|
15
|
+
projectName,
|
|
16
|
+
installationId = randomUUID(),
|
|
17
|
+
serverUrl = 'https://license.ankitdeveloper.site',
|
|
18
|
+
}) {
|
|
19
|
+
if (fs.existsSync(targetDir)) {
|
|
20
|
+
throw new Error('Target directory already exists.');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const url = new URL('/api/v1/templates/download', serverUrl);
|
|
24
|
+
|
|
25
|
+
// HTTP is allowed only for local development.
|
|
26
|
+
const isLocalhost = ['127.0.0.1', 'localhost', '::1']
|
|
27
|
+
.includes(url.hostname);
|
|
28
|
+
|
|
29
|
+
if (
|
|
30
|
+
url.protocol !== 'https:' &&
|
|
31
|
+
!(url.protocol === 'http:' && isLocalhost)
|
|
32
|
+
) {
|
|
33
|
+
throw new Error('License server must use HTTPS.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const response = await fetch(url, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
'Accept': 'application/zip',
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
license_key: licenseKey,
|
|
44
|
+
installation_id: installationId,
|
|
45
|
+
project_name: projectName,
|
|
46
|
+
}),
|
|
47
|
+
signal: AbortSignal.timeout(60000),
|
|
48
|
+
redirect: 'error',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
if (response.status === 403) {
|
|
53
|
+
throw new Error('Invalid, expired or revoked license.');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
throw new Error(
|
|
57
|
+
`Template download failed (HTTP ${response.status}).`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!response.body) {
|
|
62
|
+
throw new Error('Empty template response.');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const chunks = [];
|
|
66
|
+
let downloadedSize = 0;
|
|
67
|
+
|
|
68
|
+
for await (const chunk of response.body) {
|
|
69
|
+
downloadedSize += chunk.length;
|
|
70
|
+
|
|
71
|
+
if (downloadedSize > MAX_ZIP_SIZE) {
|
|
72
|
+
throw new Error('Template ZIP exceeds maximum size.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
chunks.push(chunk);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const zipBuffer = Buffer.concat(chunks);
|
|
79
|
+
|
|
80
|
+
// ZIP entries are checked before writing to disk.
|
|
81
|
+
const entries = unzipSync(
|
|
82
|
+
new Uint8Array(zipBuffer),
|
|
83
|
+
{
|
|
84
|
+
filter(file) {
|
|
85
|
+
if (file.originalSize > MAX_FILE_SIZE) {
|
|
86
|
+
throw new Error('Template contains an oversized file.');
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const filenames = Object.keys(entries);
|
|
94
|
+
|
|
95
|
+
if (filenames.length > MAX_FILES) {
|
|
96
|
+
throw new Error('Template contains too many files.');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let totalSize = 0;
|
|
100
|
+
|
|
101
|
+
for (const [filename, data] of Object.entries(entries)) {
|
|
102
|
+
if (
|
|
103
|
+
!filename ||
|
|
104
|
+
filename.includes('\\') ||
|
|
105
|
+
filename.includes('\0') ||
|
|
106
|
+
filename.startsWith('/') ||
|
|
107
|
+
/^[a-zA-Z]:/.test(filename)
|
|
108
|
+
) {
|
|
109
|
+
throw new Error('Unsafe ZIP entry detected.');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const segments = filename.split('/');
|
|
113
|
+
|
|
114
|
+
if (
|
|
115
|
+
segments.some(
|
|
116
|
+
(segment) => segment === '..' || segment === '.'
|
|
117
|
+
)
|
|
118
|
+
) {
|
|
119
|
+
throw new Error('ZIP path traversal detected.');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
totalSize += data.length;
|
|
123
|
+
|
|
124
|
+
if (
|
|
125
|
+
data.length > MAX_FILE_SIZE ||
|
|
126
|
+
totalSize > MAX_TOTAL_SIZE
|
|
127
|
+
) {
|
|
128
|
+
throw new Error('Template exceeds extraction size limits.');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
fs.mkdirSync(targetDir, { recursive: false });
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
for (const [filename, data] of Object.entries(entries)) {
|
|
136
|
+
const outputPath = path.resolve(targetDir, filename);
|
|
137
|
+
|
|
138
|
+
if (
|
|
139
|
+
outputPath !== targetDir &&
|
|
140
|
+
!outputPath.startsWith(targetDir + path.sep)
|
|
141
|
+
) {
|
|
142
|
+
throw new Error('Unsafe extraction path.');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (filename.endsWith('/')) {
|
|
146
|
+
fs.mkdirSync(outputPath, { recursive: true });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
fs.mkdirSync(path.dirname(outputPath), {
|
|
151
|
+
recursive: true,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
fs.writeFileSync(outputPath, data, {
|
|
155
|
+
flag: 'wx',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} catch (error) {
|
|
159
|
+
fs.rmSync(targetDir, {
|
|
160
|
+
recursive: true,
|
|
161
|
+
force: true,
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return targetDir;
|
|
168
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@developerankitkumar/create-reactforge",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [],
|
|
9
|
+
"author": "",
|
|
10
|
+
"license": "ISC",
|
|
11
|
+
"description": "ReactForge - React TypeScript Starter CLI with License Verification",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"fflate": "^0.8.3",
|
|
14
|
+
"picocolors": "^1.1.1",
|
|
15
|
+
"prompts": "^2.4.2"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"bin": {
|
|
19
|
+
"create-reactforge": "./bin/index.js"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin"
|
|
26
|
+
],
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/developerankitkumar/reactforge-platform.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/developerankitkumar/reactforge-platform#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/developerankitkumar/reactforge-platform/issues"
|
|
34
|
+
}
|
|
35
|
+
}
|