create-lumen 2.0.0-alpha.1 → 2.0.0-alpha.2
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 +15 -7
- package/bin/cli.js +1 -0
- package/package.json +2 -2
- package/schema/fixtures/v2/invalid/shadcn-without-tailwind.json +1 -1
- package/schema/fixtures/v2/valid/react-vite-type-based.json +1 -1
- package/schema/lumen.config.v2.json +0 -1
- package/src/cli-args.js +65 -3
- package/src/configure.js +4 -0
- package/src/css.js +14 -30
- package/src/dependencies.js +28 -18
- package/src/engine/capabilities.js +174 -4
- package/src/engine/composition.js +644 -0
- package/src/engine/index.js +1 -0
- package/src/injector.js +294 -446
- package/src/main.js +6 -7
- package/src/manifest/schema.js +1 -1
- package/src/prompts.js +459 -247
- package/src/readme.js +23 -2
- package/src/scaffold.js +17 -0
- package/templates/architectures/hybrid/main.jsx +10 -0
- package/templates/architectures/hybrid/main.tsx +10 -0
- package/templates/architectures/hybrid/src/App.jsx +20 -0
- package/templates/architectures/hybrid/src/App.tsx +20 -0
- package/templates/architectures/hybrid/src/shared/components/ui/Button.jsx +31 -0
- package/templates/architectures/hybrid/src/shared/components/ui/Button.tsx +36 -0
- package/templates/architectures/hybrid/src/shared/components/ui/index.js +1 -0
- package/templates/architectures/hybrid/src/shared/components/ui/index.ts +1 -0
- package/templates/architectures/hybrid/src/shared/hooks/useLocalStorage.jsx +28 -0
- package/templates/architectures/hybrid/src/shared/hooks/useLocalStorage.ts +28 -0
- package/templates/architectures/hybrid/src/shared/layouts/.gitkeep +0 -0
- package/templates/architectures/hybrid/src/shared/stores/.gitkeep +0 -0
- package/templates/architectures/hybrid/src/shared/styles/.gitkeep +0 -0
- package/templates/architectures/hybrid/src/shared/types/index.js +20 -0
- package/templates/architectures/hybrid/src/shared/types/index.ts +16 -0
- package/templates/architectures/hybrid/src/shared/utils/cn.jsx +3 -0
- package/templates/architectures/hybrid/src/shared/utils/cn.ts +5 -0
- package/templates/architectures/none/main.jsx +10 -0
- package/templates/architectures/none/main.tsx +10 -0
- package/templates/architectures/none/src/App.jsx +15 -0
- package/templates/architectures/none/src/App.tsx +15 -0
- package/templates/architectures/none/src/styles/.gitkeep +1 -0
- package/templates/architectures/none/src/types/index.js +20 -0
- package/templates/architectures/none/src/types/index.ts +16 -0
- package/templates/conditional/testing/jest/hybrid/src/__tests__/App.test.jsx +11 -0
- package/templates/conditional/testing/jest/hybrid/src/__tests__/App.test.tsx +11 -0
- package/templates/conditional/testing/jest/none/src/__tests__/App.test.jsx +11 -0
- package/templates/conditional/testing/jest/none/src/__tests__/App.test.tsx +11 -0
- package/templates/conditional/testing/vitest/hybrid/src/__tests__/App.test.jsx +10 -0
- package/templates/conditional/testing/vitest/hybrid/src/__tests__/App.test.tsx +10 -0
- package/templates/conditional/testing/vitest/none/src/__tests__/App.test.jsx +10 -0
- package/templates/conditional/testing/vitest/none/src/__tests__/App.test.tsx +10 -0
- package/templates/css/tailwind/src/globals.css +10 -0
- package/templates/css/tailwind/src/themes.css +86 -4
- package/templates/ui/shadcn/lib/utils.js +6 -0
- package/templates/ui/shadcn/lib/utils.ts +6 -0
- package/templates/ui/shadcn/primitives/Button.jsx +48 -0
- package/templates/ui/shadcn/primitives/Button.tsx +59 -0
- package/templates/ui/shadcn/primitives/Card.jsx +55 -0
- package/templates/ui/shadcn/primitives/Card.tsx +73 -0
- package/templates/ui/shadcn/primitives/index.js +9 -0
- package/templates/ui/shadcn/primitives/index.ts +9 -0
- package/templates/css/bootstrap/src/themes.css +0 -14
- /package/templates/{css/bootstrap/src/globals.css → architectures/hybrid/src/features/.gitkeep} +0 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { promises as fsp } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { copyDirRecursive, writeFileRecursive } from "@/utils/fs.js";
|
|
4
|
+
import {
|
|
5
|
+
CAPABILITIES,
|
|
6
|
+
hasCapability,
|
|
7
|
+
normalizeFramework,
|
|
8
|
+
} from "./capabilities.js";
|
|
9
|
+
import { getProvider } from "./providers/base-provider.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Capability-scoped fragment constants.
|
|
13
|
+
* Represents the 5 architectural layers of Lumen project composition.
|
|
14
|
+
*/
|
|
15
|
+
export const FRAGMENT_SCOPES = {
|
|
16
|
+
BASE: "base",
|
|
17
|
+
FRAMEWORK: "framework",
|
|
18
|
+
ARCH: "arch",
|
|
19
|
+
STYLING: "styling",
|
|
20
|
+
TOOLING: "tooling",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Documented deterministic precedence order.
|
|
25
|
+
* Earlier layers form the foundation; later layers overwrite on path collision.
|
|
26
|
+
* base -> framework -> arch -> styling -> tooling
|
|
27
|
+
*/
|
|
28
|
+
export const FRAGMENT_PRECEDENCE = [
|
|
29
|
+
FRAGMENT_SCOPES.BASE,
|
|
30
|
+
FRAGMENT_SCOPES.FRAMEWORK,
|
|
31
|
+
FRAGMENT_SCOPES.ARCH,
|
|
32
|
+
FRAGMENT_SCOPES.STYLING,
|
|
33
|
+
FRAGMENT_SCOPES.TOOLING,
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic execution order for tooling sub-categories.
|
|
38
|
+
*/
|
|
39
|
+
export const TOOLING_ORDER = [
|
|
40
|
+
"state",
|
|
41
|
+
"router",
|
|
42
|
+
"icons",
|
|
43
|
+
"api",
|
|
44
|
+
"testing",
|
|
45
|
+
"linter",
|
|
46
|
+
"feature-script",
|
|
47
|
+
"formatter",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Narrow a template tree copy to the selected language: .tsx/.ts for ts,
|
|
52
|
+
* .jsx/.js for js (keeps the opposite language's files out of the project).
|
|
53
|
+
* @param {string} language - "ts" | "js"
|
|
54
|
+
* @returns {(name: string) => boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function langFileFilter(language) {
|
|
57
|
+
return (name) => {
|
|
58
|
+
if (language === "ts") {
|
|
59
|
+
if (name.endsWith(".jsx")) return false;
|
|
60
|
+
if (name.endsWith(".js")) return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
if (language === "js") {
|
|
64
|
+
if (name.endsWith(".tsx")) return false;
|
|
65
|
+
if (name.endsWith(".ts")) return false;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove .gitkeep placeholders from src/ directories that now contain real
|
|
74
|
+
* files. A folder with nothing but its placeholder keeps it.
|
|
75
|
+
* @param {string} projectPath
|
|
76
|
+
*/
|
|
77
|
+
export async function pruneRedundantGitkeeps(projectPath) {
|
|
78
|
+
const walk = async (dir) => {
|
|
79
|
+
let entries;
|
|
80
|
+
try {
|
|
81
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
await walk(path.join(dir, entry.name));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (entries.some((e) => e.name === ".gitkeep")) {
|
|
91
|
+
const hasRealFiles = entries.some((e) => e.isFile() && e.name !== ".gitkeep");
|
|
92
|
+
if (hasRealFiles) {
|
|
93
|
+
await fsp.rm(path.join(dir, ".gitkeep"), { force: true });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
await walk(path.join(projectPath, "src"));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolve the complete, deterministic list of template fragments to apply
|
|
102
|
+
* for a given responses configuration.
|
|
103
|
+
*
|
|
104
|
+
* @param {object} responses - Generation options
|
|
105
|
+
* @param {string} templatesDir - Absolute path to templates root
|
|
106
|
+
* @returns {Array<object>} Ordered array of fragment descriptors
|
|
107
|
+
*/
|
|
108
|
+
export function resolveFragments(responses = {}, templatesDir) {
|
|
109
|
+
const fw = normalizeFramework(
|
|
110
|
+
responses.framework || {
|
|
111
|
+
name: responses.frameworkName,
|
|
112
|
+
variant: responses.frameworkVariant,
|
|
113
|
+
}
|
|
114
|
+
);
|
|
115
|
+
const language = responses.language || "ts";
|
|
116
|
+
const ext = language === "ts" ? "tsx" : "jsx";
|
|
117
|
+
const extConfig = language === "ts" ? "ts" : "js";
|
|
118
|
+
const architecture = responses.architecture || "feature-based";
|
|
119
|
+
const cssFramework = responses.cssFramework || "none";
|
|
120
|
+
|
|
121
|
+
const fragments = [];
|
|
122
|
+
|
|
123
|
+
// =========================================================================
|
|
124
|
+
// Layer 1: Base (base provider file plan / templates/bases/<provider>/<variant>/<lang>)
|
|
125
|
+
// =========================================================================
|
|
126
|
+
const provider = getProvider(fw.name, fw.variant);
|
|
127
|
+
const baseRel = provider ? provider.getFilePlan({ lang: language }) : null;
|
|
128
|
+
const baseDir = baseRel ? path.join(templatesDir, "..", baseRel) : null;
|
|
129
|
+
const packageSet = provider ? provider.getPackageSet({ options: responses }) : null;
|
|
130
|
+
|
|
131
|
+
fragments.push({
|
|
132
|
+
scope: FRAGMENT_SCOPES.BASE,
|
|
133
|
+
id: `base:${fw.name}:${fw.variant}`,
|
|
134
|
+
sourceDir: baseDir,
|
|
135
|
+
targetDir: "",
|
|
136
|
+
dependencies: packageSet?.dependencies || {},
|
|
137
|
+
devDependencies: packageSet?.devDependencies || {},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// =========================================================================
|
|
141
|
+
// Layer 2: Framework (entry points & default boilerplate cleanup)
|
|
142
|
+
// =========================================================================
|
|
143
|
+
fragments.push({
|
|
144
|
+
scope: FRAGMENT_SCOPES.FRAMEWORK,
|
|
145
|
+
id: `framework:${fw.name}:${fw.variant}`,
|
|
146
|
+
action: async ({ projectPath }) => {
|
|
147
|
+
// Remove Vite default App and CSS files before architecture overlays land
|
|
148
|
+
try { await fsp.rm(path.join(projectPath, "src", "App.tsx"), { force: true }); } catch {}
|
|
149
|
+
try { await fsp.rm(path.join(projectPath, "src", "App.jsx"), { force: true }); } catch {}
|
|
150
|
+
try { await fsp.rm(path.join(projectPath, "src", "index.css"), { force: true }); } catch {}
|
|
151
|
+
try { await fsp.rm(path.join(projectPath, "src", "App.css"), { force: true }); } catch {}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// =========================================================================
|
|
156
|
+
// Layer 3: Architecture (feature-based / type-based / hybrid / none)
|
|
157
|
+
// =========================================================================
|
|
158
|
+
const archSrcDir = path.join(templatesDir, "architectures", architecture, "src");
|
|
159
|
+
const archMainSrc = path.join(templatesDir, "architectures", architecture, `main.${ext}`);
|
|
160
|
+
|
|
161
|
+
fragments.push({
|
|
162
|
+
scope: FRAGMENT_SCOPES.ARCH,
|
|
163
|
+
id: `arch:${architecture}`,
|
|
164
|
+
sourceDir: archSrcDir,
|
|
165
|
+
targetDir: "src",
|
|
166
|
+
action: async ({ projectPath }) => {
|
|
167
|
+
// Copy architecture main entry
|
|
168
|
+
try {
|
|
169
|
+
await fsp.access(archMainSrc);
|
|
170
|
+
await fsp.copyFile(archMainSrc, path.join(projectPath, "src", `main.${ext}`));
|
|
171
|
+
} catch {}
|
|
172
|
+
// Remove opposite language main entry if present
|
|
173
|
+
const oppositeExt = language === "ts" ? "jsx" : "tsx";
|
|
174
|
+
try {
|
|
175
|
+
await fsp.rm(path.join(projectPath, "src", `main.${oppositeExt}`), { force: true });
|
|
176
|
+
} catch {}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// =========================================================================
|
|
181
|
+
// Layer 4: Styling (component styles overlay & global CSS)
|
|
182
|
+
// =========================================================================
|
|
183
|
+
if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
|
|
184
|
+
const componentStylesDir = path.join(
|
|
185
|
+
templatesDir,
|
|
186
|
+
"css",
|
|
187
|
+
"component-styles",
|
|
188
|
+
cssFramework,
|
|
189
|
+
architecture,
|
|
190
|
+
"src"
|
|
191
|
+
);
|
|
192
|
+
fragments.push({
|
|
193
|
+
scope: FRAGMENT_SCOPES.STYLING,
|
|
194
|
+
id: `styling:${cssFramework}:components`,
|
|
195
|
+
sourceDir: componentStylesDir,
|
|
196
|
+
targetDir: "src",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// =========================================================================
|
|
201
|
+
// Layer 5: Tooling (State, Router, Icons, API, Testing, Linter, Script, Formatter)
|
|
202
|
+
// =========================================================================
|
|
203
|
+
|
|
204
|
+
// 5.1 State Management
|
|
205
|
+
if (responses.stateManagement === "redux") {
|
|
206
|
+
fragments.push({
|
|
207
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
208
|
+
category: "state",
|
|
209
|
+
id: "tooling:state:redux",
|
|
210
|
+
sourceDir: path.join(templatesDir, "conditional", "state", "redux", architecture, "src"),
|
|
211
|
+
targetDir: "src",
|
|
212
|
+
action: async ({ projectPath }) => {
|
|
213
|
+
await updateMainWithProvider(projectPath, "redux", architecture, ext);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
} else if (responses.stateManagement === "zustand") {
|
|
217
|
+
fragments.push({
|
|
218
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
219
|
+
category: "state",
|
|
220
|
+
id: "tooling:state:zustand",
|
|
221
|
+
sourceDir: path.join(templatesDir, "conditional", "state", "zustand", architecture, "src"),
|
|
222
|
+
targetDir: "src",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// 5.2 Router (Gated by CLIENT_ROUTING capability; Next uses filesystem routing and skips)
|
|
227
|
+
if (responses.router && hasCapability(fw, CAPABILITIES.CLIENT_ROUTING)) {
|
|
228
|
+
const routerBaseDir = path.join(templatesDir, "conditional", "router", architecture, "src");
|
|
229
|
+
fragments.push({
|
|
230
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
231
|
+
category: "router",
|
|
232
|
+
id: "tooling:router:base",
|
|
233
|
+
sourceDir: routerBaseDir,
|
|
234
|
+
targetDir: "src",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
|
|
238
|
+
const routerVariantDir = path.join(
|
|
239
|
+
templatesDir,
|
|
240
|
+
"conditional",
|
|
241
|
+
"router",
|
|
242
|
+
architecture,
|
|
243
|
+
cssFramework,
|
|
244
|
+
"src"
|
|
245
|
+
);
|
|
246
|
+
fragments.push({
|
|
247
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
248
|
+
category: "router",
|
|
249
|
+
id: `tooling:router:${cssFramework}`,
|
|
250
|
+
sourceDir: routerVariantDir,
|
|
251
|
+
targetDir: "src",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 5.3 Icons
|
|
257
|
+
if (responses.iconLibrary && responses.iconLibrary !== "none") {
|
|
258
|
+
const iconsBaseDir = path.join(
|
|
259
|
+
templatesDir,
|
|
260
|
+
"conditional",
|
|
261
|
+
"icons",
|
|
262
|
+
responses.iconLibrary,
|
|
263
|
+
architecture,
|
|
264
|
+
"src"
|
|
265
|
+
);
|
|
266
|
+
fragments.push({
|
|
267
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
268
|
+
category: "icons",
|
|
269
|
+
id: `tooling:icons:${responses.iconLibrary}:base`,
|
|
270
|
+
sourceDir: iconsBaseDir,
|
|
271
|
+
targetDir: "src",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (cssFramework === "tailwind" || cssFramework === "bootstrap") {
|
|
275
|
+
const iconsVariantDir = path.join(
|
|
276
|
+
templatesDir,
|
|
277
|
+
"conditional",
|
|
278
|
+
"icons",
|
|
279
|
+
responses.iconLibrary,
|
|
280
|
+
architecture,
|
|
281
|
+
cssFramework,
|
|
282
|
+
"src"
|
|
283
|
+
);
|
|
284
|
+
fragments.push({
|
|
285
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
286
|
+
category: "icons",
|
|
287
|
+
id: `tooling:icons:${responses.iconLibrary}:${cssFramework}`,
|
|
288
|
+
sourceDir: iconsVariantDir,
|
|
289
|
+
targetDir: "src",
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 5.4 API Client
|
|
295
|
+
if (responses.apiClient === "axios" || responses.apiClient === "fetch") {
|
|
296
|
+
const apiDir = path.join(
|
|
297
|
+
templatesDir,
|
|
298
|
+
"conditional",
|
|
299
|
+
responses.apiClient,
|
|
300
|
+
architecture,
|
|
301
|
+
"src"
|
|
302
|
+
);
|
|
303
|
+
fragments.push({
|
|
304
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
305
|
+
category: "api",
|
|
306
|
+
id: `tooling:api:${responses.apiClient}`,
|
|
307
|
+
sourceDir: apiDir,
|
|
308
|
+
targetDir: "src",
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// 5.5 Testing
|
|
313
|
+
if (responses.testing === "vitest" || responses.testing === "jest") {
|
|
314
|
+
const testingFramework = responses.testing;
|
|
315
|
+
const testingDir = path.join(templatesDir, "conditional", "testing", testingFramework);
|
|
316
|
+
const testScripts =
|
|
317
|
+
testingFramework === "vitest"
|
|
318
|
+
? { test: "vitest", "test:run": "vitest run" }
|
|
319
|
+
: { test: "jest", "test:watch": "jest --watch" };
|
|
320
|
+
|
|
321
|
+
fragments.push({
|
|
322
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
323
|
+
category: "testing",
|
|
324
|
+
id: `tooling:testing:${testingFramework}`,
|
|
325
|
+
scripts: testScripts,
|
|
326
|
+
action: async ({ projectPath }) => {
|
|
327
|
+
// Config file to project root
|
|
328
|
+
const configSrc = path.join(testingDir, `${testingFramework}.config.${extConfig}`);
|
|
329
|
+
try {
|
|
330
|
+
await fsp.access(configSrc);
|
|
331
|
+
await fsp.copyFile(configSrc, path.join(projectPath, `${testingFramework}.config.${extConfig}`));
|
|
332
|
+
} catch {}
|
|
333
|
+
|
|
334
|
+
// Setup file to src/test/setup.*
|
|
335
|
+
const setupSrc = path.join(testingDir, `src/test/setup.${extConfig}`);
|
|
336
|
+
try {
|
|
337
|
+
await fsp.access(setupSrc);
|
|
338
|
+
const setupDest = path.join(projectPath, "src", "test", `setup.${extConfig}`);
|
|
339
|
+
await writeFileRecursive(setupDest, await fsp.readFile(setupSrc, "utf8"));
|
|
340
|
+
} catch {}
|
|
341
|
+
|
|
342
|
+
if (testingFramework === "jest") {
|
|
343
|
+
const jestSetupSrc = path.join(testingDir, `jest.setup.${extConfig}`);
|
|
344
|
+
try {
|
|
345
|
+
await fsp.access(jestSetupSrc);
|
|
346
|
+
await fsp.copyFile(jestSetupSrc, path.join(projectPath, `jest.setup.${extConfig}`));
|
|
347
|
+
} catch {}
|
|
348
|
+
const babelSrc = path.join(testingDir, "babel.config.cjs");
|
|
349
|
+
try {
|
|
350
|
+
await fsp.access(babelSrc);
|
|
351
|
+
await fsp.copyFile(babelSrc, path.join(projectPath, "babel.config.cjs"));
|
|
352
|
+
} catch {}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Architecture-scoped test file
|
|
356
|
+
const testSrc = path.join(testingDir, architecture, `src/__tests__/App.test.${ext}`);
|
|
357
|
+
try {
|
|
358
|
+
await fsp.access(testSrc);
|
|
359
|
+
const testDest = path.join(projectPath, "src", "__tests__", `App.test.${ext}`);
|
|
360
|
+
await writeFileRecursive(testDest, await fsp.readFile(testSrc, "utf8"));
|
|
361
|
+
} catch {}
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 5.6 Linter
|
|
367
|
+
if (responses.linter === "eslint") {
|
|
368
|
+
fragments.push({
|
|
369
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
370
|
+
category: "linter",
|
|
371
|
+
id: "tooling:linter:eslint",
|
|
372
|
+
scripts: {
|
|
373
|
+
lint: "eslint .",
|
|
374
|
+
"lint:fix": "eslint . --fix",
|
|
375
|
+
},
|
|
376
|
+
action: async ({ projectPath }) => {
|
|
377
|
+
const lintingDir = path.join(templatesDir, "conditional", "linting", "eslint");
|
|
378
|
+
if (fw.name === "next") {
|
|
379
|
+
const configSrc = path.join(lintingDir, "next", "eslint.config.mjs");
|
|
380
|
+
try {
|
|
381
|
+
await fsp.access(configSrc);
|
|
382
|
+
await fsp.copyFile(configSrc, path.join(projectPath, "eslint.config.mjs"));
|
|
383
|
+
} catch {}
|
|
384
|
+
} else {
|
|
385
|
+
const configSrc = path.join(lintingDir, `eslint.config.${extConfig}`);
|
|
386
|
+
try {
|
|
387
|
+
await fsp.access(configSrc);
|
|
388
|
+
await fsp.copyFile(configSrc, path.join(projectPath, `eslint.config.${extConfig}`));
|
|
389
|
+
} catch {}
|
|
390
|
+
}
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
} else if (responses.linter === "oxlint") {
|
|
394
|
+
fragments.push({
|
|
395
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
396
|
+
category: "linter",
|
|
397
|
+
id: "tooling:linter:oxlint",
|
|
398
|
+
scripts: {
|
|
399
|
+
lint: "oxlint .",
|
|
400
|
+
},
|
|
401
|
+
action: async ({ projectPath }) => {
|
|
402
|
+
const configSrc = path.join(templatesDir, "conditional", "linting", "oxlint", "oxlintrc.json");
|
|
403
|
+
try {
|
|
404
|
+
await fsp.access(configSrc);
|
|
405
|
+
await fsp.copyFile(configSrc, path.join(projectPath, "oxlintrc.json"));
|
|
406
|
+
} catch {}
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 5.7 Feature Script (Feature-based only)
|
|
412
|
+
if (architecture === "feature-based") {
|
|
413
|
+
fragments.push({
|
|
414
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
415
|
+
category: "feature-script",
|
|
416
|
+
id: "tooling:feature-script",
|
|
417
|
+
scripts: {
|
|
418
|
+
"create:feature": "node scripts/create-feature.mjs",
|
|
419
|
+
},
|
|
420
|
+
action: async ({ projectPath }) => {
|
|
421
|
+
const scriptSrc = path.join(templatesDir, "architectures", "feature-based", "scripts", "create-feature.mjs");
|
|
422
|
+
const scriptDest = path.join(projectPath, "scripts", "create-feature.mjs");
|
|
423
|
+
try {
|
|
424
|
+
await fsp.access(scriptSrc);
|
|
425
|
+
let content = await fsp.readFile(scriptSrc, "utf8");
|
|
426
|
+
if (language === "js") {
|
|
427
|
+
content = content
|
|
428
|
+
.replace(/services\/index\.ts/g, "services/index.js")
|
|
429
|
+
.replace(/store\/index\.ts/g, "store/index.js")
|
|
430
|
+
.replace(/hooks\/index\.ts/g, "hooks/index.js")
|
|
431
|
+
.replace(/pages\/index\.ts/g, "pages/index.js")
|
|
432
|
+
.replace(/types\/index\.ts/g, "types/index.js");
|
|
433
|
+
}
|
|
434
|
+
await writeFileRecursive(scriptDest, content);
|
|
435
|
+
} catch {}
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 5.8 Formatter
|
|
441
|
+
if (responses.formatter && responses.formatter !== "none") {
|
|
442
|
+
const isPrettier = responses.formatter === "prettier";
|
|
443
|
+
const configFile = isPrettier ? ".prettierrc" : ".oxfmtrc.json";
|
|
444
|
+
const formatScripts = isPrettier
|
|
445
|
+
? { format: "prettier --write .", "format:check": "prettier --check ." }
|
|
446
|
+
: { format: "oxfmt .", "format:check": "oxfmt --check ." };
|
|
447
|
+
|
|
448
|
+
fragments.push({
|
|
449
|
+
scope: FRAGMENT_SCOPES.TOOLING,
|
|
450
|
+
category: "formatter",
|
|
451
|
+
id: `tooling:formatter:${responses.formatter}`,
|
|
452
|
+
scripts: formatScripts,
|
|
453
|
+
action: async ({ projectPath }) => {
|
|
454
|
+
const configSrc = path.join(
|
|
455
|
+
templatesDir,
|
|
456
|
+
"conditional",
|
|
457
|
+
"formatter",
|
|
458
|
+
responses.formatter,
|
|
459
|
+
configFile
|
|
460
|
+
);
|
|
461
|
+
try {
|
|
462
|
+
await fsp.access(configSrc);
|
|
463
|
+
await fsp.copyFile(configSrc, path.join(projectPath, configFile));
|
|
464
|
+
} catch {}
|
|
465
|
+
|
|
466
|
+
if (responses.linter === "eslint" && responses.formatter === "prettier") {
|
|
467
|
+
await wireEslintPrettier(projectPath, language, fw.name);
|
|
468
|
+
}
|
|
469
|
+
},
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
return sortFragments(fragments);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Deterministically sort fragments by FRAGMENT_PRECEDENCE and TOOLING_ORDER.
|
|
478
|
+
* @param {Array<object>} fragments
|
|
479
|
+
* @returns {Array<object>}
|
|
480
|
+
*/
|
|
481
|
+
export function sortFragments(fragments) {
|
|
482
|
+
return [...fragments].sort((a, b) => {
|
|
483
|
+
const aScopeIdx = FRAGMENT_PRECEDENCE.indexOf(a.scope);
|
|
484
|
+
const bScopeIdx = FRAGMENT_PRECEDENCE.indexOf(b.scope);
|
|
485
|
+
if (aScopeIdx !== bScopeIdx) {
|
|
486
|
+
return aScopeIdx - bScopeIdx;
|
|
487
|
+
}
|
|
488
|
+
// Both are tooling: sort by TOOLING_ORDER
|
|
489
|
+
if (a.scope === FRAGMENT_SCOPES.TOOLING && b.scope === FRAGMENT_SCOPES.TOOLING) {
|
|
490
|
+
const aCatIdx = TOOLING_ORDER.indexOf(a.category || "");
|
|
491
|
+
const bCatIdx = TOOLING_ORDER.indexOf(b.category || "");
|
|
492
|
+
if (aCatIdx !== bCatIdx) {
|
|
493
|
+
return aCatIdx - bCatIdx;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return (a.id || "").localeCompare(b.id || "");
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Apply a list of fragments to a project in deterministic precedence order.
|
|
502
|
+
*
|
|
503
|
+
* @param {string} projectPath
|
|
504
|
+
* @param {Array<object>} fragments
|
|
505
|
+
* @param {object} options
|
|
506
|
+
*/
|
|
507
|
+
export async function applyFragments(projectPath, fragments, options = {}) {
|
|
508
|
+
const language = options.language || "ts";
|
|
509
|
+
const filter = langFileFilter(language);
|
|
510
|
+
|
|
511
|
+
// Collect scripts to merge into package.json in one deterministic pass
|
|
512
|
+
const pendingScripts = {};
|
|
513
|
+
|
|
514
|
+
for (const fragment of fragments) {
|
|
515
|
+
// 1. Copy directory contents if sourceDir is provided and exists
|
|
516
|
+
if (fragment.sourceDir) {
|
|
517
|
+
try {
|
|
518
|
+
await fsp.access(fragment.sourceDir);
|
|
519
|
+
const target = fragment.targetDir
|
|
520
|
+
? path.join(projectPath, fragment.targetDir)
|
|
521
|
+
: projectPath;
|
|
522
|
+
await copyDirRecursive(fragment.sourceDir, target, filter);
|
|
523
|
+
} catch {
|
|
524
|
+
// Source dir does not exist, continue
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 2. Execute fragment action if provided
|
|
529
|
+
if (typeof fragment.action === "function") {
|
|
530
|
+
await fragment.action({
|
|
531
|
+
projectPath,
|
|
532
|
+
templatesDir: options.templatesDir,
|
|
533
|
+
responses: options.responses,
|
|
534
|
+
ext: language === "ts" ? "tsx" : "jsx",
|
|
535
|
+
language,
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// 3. Queue scripts
|
|
540
|
+
if (fragment.scripts) {
|
|
541
|
+
Object.assign(pendingScripts, fragment.scripts);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 4. Merge scripts into package.json
|
|
546
|
+
if (Object.keys(pendingScripts).length > 0) {
|
|
547
|
+
const pkgPath = path.join(projectPath, "package.json");
|
|
548
|
+
try {
|
|
549
|
+
const content = await fsp.readFile(pkgPath, "utf8");
|
|
550
|
+
const pkg = JSON.parse(content);
|
|
551
|
+
if (!pkg.scripts) pkg.scripts = {};
|
|
552
|
+
for (const [key, val] of Object.entries(pendingScripts)) {
|
|
553
|
+
pkg.scripts[key] = val;
|
|
554
|
+
}
|
|
555
|
+
await fsp.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
556
|
+
} catch {}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// 5. Prune redundant .gitkeep placeholders
|
|
560
|
+
await pruneRedundantGitkeeps(projectPath);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Helper to update main entry with Redux StoreProvider.
|
|
565
|
+
*/
|
|
566
|
+
async function updateMainWithProvider(projectPath, stateType, architecture, ext) {
|
|
567
|
+
const mainPath = path.join(projectPath, "src", `main.${ext}`);
|
|
568
|
+
try {
|
|
569
|
+
let content = await fsp.readFile(mainPath, "utf8");
|
|
570
|
+
|
|
571
|
+
if (architecture === "feature-based") {
|
|
572
|
+
if (!content.includes("StoreProvider")) {
|
|
573
|
+
content = content.replace(
|
|
574
|
+
/import App from ['"]@\/app\/App['"]\n?/,
|
|
575
|
+
"import App from '@/app/App'\nimport { StoreProvider } from '@/app/providers/StoreProvider'\n"
|
|
576
|
+
);
|
|
577
|
+
content = content.replace(
|
|
578
|
+
/<App \/>/,
|
|
579
|
+
"<StoreProvider>\n <App />\n </StoreProvider>"
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
} else {
|
|
583
|
+
if (!content.includes("StoreProvider")) {
|
|
584
|
+
content = content.replace(
|
|
585
|
+
/import App from ['"]\.\/App['"]\n?/,
|
|
586
|
+
"import App from './App'\nimport { StoreProvider } from './providers/StoreProvider'\n"
|
|
587
|
+
);
|
|
588
|
+
content = content.replace(
|
|
589
|
+
/<App \/>/,
|
|
590
|
+
"<StoreProvider>\n <App />\n </StoreProvider>"
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
await fsp.writeFile(mainPath, content, "utf8");
|
|
596
|
+
} catch {}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Helper to wire eslint-config-prettier into eslint configuration.
|
|
601
|
+
*/
|
|
602
|
+
async function wireEslintPrettier(projectPath, language, framework) {
|
|
603
|
+
const ext = framework === "next" ? "mjs" : language === "ts" ? "ts" : "js";
|
|
604
|
+
const eslintConfigPath = path.join(projectPath, `eslint.config.${ext}`);
|
|
605
|
+
try {
|
|
606
|
+
let content = await fsp.readFile(eslintConfigPath, "utf8");
|
|
607
|
+
|
|
608
|
+
if (content.includes("eslint-config-prettier")) return;
|
|
609
|
+
|
|
610
|
+
const lines = content.split("\n");
|
|
611
|
+
let lastImportIdx = -1;
|
|
612
|
+
for (let i = 0; i < lines.length; i++) {
|
|
613
|
+
if (lines[i].startsWith("import ")) lastImportIdx = i;
|
|
614
|
+
}
|
|
615
|
+
lines.splice(
|
|
616
|
+
lastImportIdx + 1,
|
|
617
|
+
0,
|
|
618
|
+
'import prettier from "eslint-config-prettier";'
|
|
619
|
+
);
|
|
620
|
+
|
|
621
|
+
let result = lines.join("\n");
|
|
622
|
+
const pushLast = (_, head, tail) =>
|
|
623
|
+
head.replace(/,\s*$/, "") + ",\n prettier," + tail;
|
|
624
|
+
|
|
625
|
+
if (result.includes("tseslint.config(")) {
|
|
626
|
+
result = result.replace(
|
|
627
|
+
/(export default tseslint\.config\([\s\S]*?)(\n\s*\);)/,
|
|
628
|
+
pushLast
|
|
629
|
+
);
|
|
630
|
+
} else if (result.includes("const eslintConfig = [")) {
|
|
631
|
+
result = result.replace(
|
|
632
|
+
/(const eslintConfig = \[[\s\S]*?)(\n\s*\];)/,
|
|
633
|
+
pushLast
|
|
634
|
+
);
|
|
635
|
+
} else {
|
|
636
|
+
result = result.replace(
|
|
637
|
+
/(export default \[[\s\S]*?)(\n\s*\];)/,
|
|
638
|
+
pushLast
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
await fsp.writeFile(eslintConfigPath, result, "utf8");
|
|
643
|
+
} catch {}
|
|
644
|
+
}
|
package/src/engine/index.js
CHANGED