create-lumen 2.0.0-alpha.1 → 2.0.0-beta.1

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