@anaemia/cli 0.0.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.
@@ -0,0 +1,369 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { toCamelCase, toKebabCase, toPascalCase } from "./utils/casing.js";
4
+ export function scaffoldFeature(rawName, appRoot) {
5
+ const folderName = toKebabCase(rawName);
6
+ const componentName = toPascalCase(rawName);
7
+ const isTypeScript = fs.existsSync(path.join(appRoot, "tsconfig.json"));
8
+ const ext = isTypeScript ? "tsx" : "jsx";
9
+ const scriptExt = isTypeScript ? "ts" : "js";
10
+ const featureDir = path.resolve(appRoot, `./src/features/${folderName}`);
11
+ const directories = [path.join(featureDir, "components"), path.join(featureDir, "hooks"), path.join(featureDir, "server")];
12
+ directories.forEach((dir) => fs.mkdirSync(dir, { recursive: true }));
13
+ const componentContent = isTypeScript
14
+ ? `import { children, JSX } from "solid-js";
15
+ import styles from "./${componentName}.module.scss";
16
+
17
+ interface ${componentName}Props {
18
+ children?: JSX.Element;
19
+ }
20
+
21
+ export function ${componentName}(props: ${componentName}Props) {
22
+ const resolved = children(() => props.children);
23
+
24
+ return (
25
+ <div class={styles.wrapper}>
26
+ Welcome to ${componentName}
27
+ {resolved()}
28
+ </div>
29
+ );
30
+ }
31
+ `
32
+ : `import { children } from "solid-js";
33
+ import styles from "./${componentName}.module.scss";
34
+
35
+ export function ${componentName}(props) {
36
+ const resolved = children(() => props.children);
37
+
38
+ return (
39
+ <div class={styles.wrapper}>
40
+ Welcome to ${componentName}
41
+ {resolved()}
42
+ </div>
43
+ );
44
+ }
45
+ `;
46
+ const actionsContent = isTypeScript
47
+ ? `import { runOnServer } from "@anaemia/core";
48
+
49
+ export const ${toCamelCase(componentName)}Action = runOnServer(async (input: unknown) => {
50
+ // TODO: implement server-side logic
51
+ return { ok: true };
52
+ });
53
+ `
54
+ : `import { runOnServer } from "@anaemia/core";
55
+
56
+ export const ${toCamelCase(componentName)}Action = runOnServer(async (input) => {
57
+ // TODO: implement server-side logic
58
+ return { ok: true };
59
+ });
60
+ `;
61
+ const hookContent = isTypeScript
62
+ ? `import { createSignal } from "solid-js";
63
+ import { ${toCamelCase(componentName)}Action } from "../server/actions";
64
+
65
+ export function use${componentName}() {
66
+ const [data, setData] = createSignal<unknown>(null);
67
+ const [error, setError] = createSignal<string | null>(null);
68
+ const [loading, setLoading] = createSignal(false);
69
+
70
+ const execute = async (input: unknown) => {
71
+ setLoading(true);
72
+ setError(null);
73
+ try {
74
+ const result = await ${toCamelCase(componentName)}Action(input);
75
+ setData(result);
76
+ return result;
77
+ } catch (err: any) {
78
+ setError(err.message ?? "Unknown error");
79
+ } finally {
80
+ setLoading(false);
81
+ }
82
+ };
83
+
84
+ return { data, error, loading, execute };
85
+ }
86
+ `
87
+ : `import { createSignal } from "solid-js";
88
+ import { ${toCamelCase(componentName)}Action } from "../server/actions";
89
+
90
+ export function use${componentName}() {
91
+ const [data, setData] = createSignal(null);
92
+ const [error, setError] = createSignal(null);
93
+ const [loading, setLoading] = createSignal(false);
94
+
95
+ const execute = async (input) => {
96
+ setLoading(true);
97
+ setError(null);
98
+ try {
99
+ const result = await ${toCamelCase(componentName)}Action(input);
100
+ setData(result);
101
+ return result;
102
+ } catch (err) {
103
+ setError(err.message ?? "Unknown error");
104
+ } finally {
105
+ setLoading(false);
106
+ }
107
+ };
108
+
109
+ return { data, error, loading, execute };
110
+ }
111
+ `;
112
+ const indexContent = `export { ${componentName} } from "./components/${componentName}";
113
+ export { use${componentName} } from "./hooks/use${componentName}";
114
+ `;
115
+ fs.writeFileSync(path.join(featureDir, `components/${componentName}.${ext}`), componentContent, "utf8");
116
+ fs.writeFileSync(path.join(featureDir, `components/${componentName}.module.scss`), `.wrapper {\n display: block;\n}\n`, "utf8");
117
+ fs.writeFileSync(path.join(featureDir, `server/actions.${scriptExt}`), actionsContent, "utf8");
118
+ fs.writeFileSync(path.join(featureDir, `hooks/use${componentName}.${scriptExt}`), hookContent, "utf8");
119
+ fs.writeFileSync(path.join(featureDir, `index.${scriptExt}`), indexContent, "utf8");
120
+ console.log("\nšŸŽÆ successfully generated feature domain template structure:");
121
+ console.log(` └─ src/features/${folderName}/`);
122
+ console.log(` ā”œā”€ā”€ components/`);
123
+ console.log(` │ ā”œā”€ā”€ ${componentName}.${ext}`);
124
+ console.log(` │ └── ${componentName}.module.scss`);
125
+ console.log(` ā”œā”€ā”€ hooks/`);
126
+ console.log(` └── server/`);
127
+ console.log(` └── actions.${scriptExt}\n`);
128
+ }
129
+ export function generateSharedComponent(appRoot, componentName, { logger, pc }) {
130
+ const kebabFolder = toKebabCase(componentName);
131
+ const pascalName = toPascalCase(componentName);
132
+ const isTypeScript = fs.existsSync(path.join(appRoot, "tsconfig.json"));
133
+ const ext = isTypeScript ? "tsx" : "jsx";
134
+ const compDir = path.resolve(appRoot, `./src/shared/components/${kebabFolder}`);
135
+ if (fs.existsSync(compDir)) {
136
+ logger.error(`generation halted: shared UI component folder "${kebabFolder}" already exists.`);
137
+ return false;
138
+ }
139
+ fs.mkdirSync(compDir, { recursive: true });
140
+ const componentContent = isTypeScript
141
+ ? `import { children, JSX } from "solid-js";
142
+ import styles from "./${pascalName}.module.scss";
143
+
144
+ interface ${pascalName}Props {
145
+ children?: JSX.Element;
146
+ }
147
+
148
+ export function ${pascalName}(props: ${pascalName}Props) {
149
+ // Safe evaluation wrapper preserves fine-grained reactive updates
150
+ const resolved = children(() => props.children);
151
+
152
+ return (
153
+ <div class={styles.base}>
154
+ {resolved()}
155
+ </div>
156
+ );
157
+ }
158
+ `
159
+ : `import { children } from "solid-js";
160
+ import styles from "./${pascalName}.module.scss";
161
+
162
+ export function ${pascalName}(props) {
163
+ const resolved = children(() => props.children);
164
+
165
+ return (
166
+ <div class={styles.base}>
167
+ {resolved()}
168
+ </div>
169
+ );
170
+ }
171
+ `;
172
+ const cssContent = `.base {
173
+ display: inline-block;
174
+ }
175
+ `;
176
+ fs.writeFileSync(path.join(compDir, `${pascalName}.${ext}`), componentContent, "utf8");
177
+ fs.writeFileSync(path.join(compDir, `${pascalName}.module.scss`), cssContent, "utf8");
178
+ logger.success(`\nšŸŒ successfully generated global shared component capsule:`);
179
+ console.log(pc.dim(` └─ src/shared/components/${kebabFolder}/`));
180
+ console.log(` ā”œā”€ā”€ ${pc.cyan(`${pascalName}.${ext}`)}`);
181
+ console.log(` └── ${pc.cyan(`${pascalName}.module.scss`)}\n`);
182
+ return true;
183
+ }
184
+ export function scaffoldPage(rawName, appRoot) {
185
+ const isTypeScript = fs.existsSync(path.join(appRoot, "tsconfig.json"));
186
+ const ext = isTypeScript ? "tsx" : "jsx";
187
+ const scriptExt = isTypeScript ? "ts" : "js";
188
+ // rawName can be nested: "dashboard/settings" -> src/routes/dashboard/settings.tsx
189
+ // or dynamic: "blog/[slug]" -> src/routes/blog/[slug].tsx
190
+ const segments = rawName.replace(/\\/g, "/").split("/");
191
+ const fileName = segments[segments.length - 1];
192
+ const dirSegments = segments.slice(0, -1);
193
+ const routesDir = path.resolve(appRoot, "./src/routes");
194
+ const pageDir = dirSegments.length > 0 ? path.join(routesDir, ...dirSegments) : routesDir;
195
+ const pagePath = path.join(pageDir, `${fileName}.${ext}`);
196
+ const loaderTypePath = path.join(pageDir, `${fileName}.types.${scriptExt}`);
197
+ if (fs.existsSync(pagePath)) {
198
+ console.error(`[anaemia] generation halted: page "${rawName}" already exists at ${pagePath}`);
199
+ process.exit(1);
200
+ }
201
+ fs.mkdirSync(pageDir, { recursive: true });
202
+ // derive a component name from the file name
203
+ const componentName = toPascalCase(fileName
204
+ .replace(/^\[\.\.\./, "") // strip [...
205
+ .replace(/^\[/, "") // strip [
206
+ .replace(/\]$/, "") // strip ]
207
+ ) + "Page";
208
+ // derive the URL pattern for the comment header
209
+ const urlPattern = "/" +
210
+ segments
211
+ .map((s) => {
212
+ if (s.startsWith("[...") && s.endsWith("]"))
213
+ return `*${s.slice(4, -1)}`;
214
+ if (s.startsWith("[") && s.endsWith("]"))
215
+ return `:${s.slice(1, -1)}`;
216
+ return s;
217
+ })
218
+ .join("/");
219
+ const isCatchAll = fileName.startsWith("[...");
220
+ const isDynamic = fileName.startsWith("[") && !isCatchAll;
221
+ const paramName = isDynamic ? fileName.slice(1, -1) : isCatchAll ? fileName.slice(4, -1) : null;
222
+ const typesContent = isTypeScript
223
+ ? `export interface ${componentName}LoaderData {
224
+ // TODO: define your loader return shape
225
+ }
226
+ `
227
+ : null;
228
+ const componentContent = isTypeScript
229
+ ? `import type { ${componentName}LoaderData } from "./${fileName}.types";
230
+ import { useLoaderData } from "@anaemia/core";
231
+
232
+ // route: ${urlPattern}
233
+ ${paramName ? `// param: ${paramName}` : ""}
234
+
235
+ export async function loader({ params${paramName ? `, request` : ""} }: { params: Record<string, string>; request: Request }) {
236
+ // TODO: fetch data here
237
+ return {} satisfies ${componentName}LoaderData;
238
+ }
239
+
240
+ export default function ${componentName}() {
241
+ const data = useLoaderData<${componentName}LoaderData>();
242
+
243
+ return (
244
+ <main>
245
+ <h1>${componentName}</h1>
246
+ </main>
247
+ );
248
+ }
249
+ `
250
+ : `import { useLoaderData } from "@anaemia/core";
251
+
252
+ // route: ${urlPattern}
253
+ ${paramName ? `// param: ${paramName}` : ""}
254
+
255
+ export async function loader({ params${paramName ? `, request` : ""} }) {
256
+ // TODO: fetch data here
257
+ return {};
258
+ }
259
+
260
+ export default function ${componentName}() {
261
+ const data = useLoaderData();
262
+
263
+ return (
264
+ <main>
265
+ <h1>${componentName}</h1>
266
+ </main>
267
+ );
268
+ }
269
+ `;
270
+ fs.writeFileSync(pagePath, componentContent, "utf8");
271
+ if (isTypeScript && typesContent) {
272
+ fs.writeFileSync(loaderTypePath, typesContent, "utf8");
273
+ }
274
+ console.log("\nšŸ“„ successfully generated page route:");
275
+ console.log(` └─ src/routes/${rawName}.${ext}`);
276
+ if (isTypeScript) {
277
+ console.log(` ā”œā”€ā”€ ${fileName}.${ext}`);
278
+ console.log(` └── ${fileName}.types.${scriptExt}\n`);
279
+ }
280
+ }
281
+ export function scaffoldHook(rawName, appRoot) {
282
+ const isTypeScript = fs.existsSync(path.join(appRoot, "tsconfig.json"));
283
+ const ext = isTypeScript ? "ts" : "js";
284
+ // rawName can be:
285
+ // "auth/usePermissions" -> adds to existing feature
286
+ // "usePermissions" -> creates in src/shared/hooks
287
+ const segments = rawName.replace(/\\/g, "/").split("/");
288
+ const isFeatureHook = segments.length > 1;
289
+ const rawHookName = segments[segments.length - 1];
290
+ const featureName = isFeatureHook ? segments[0] : null;
291
+ // ensure it starts with "use"
292
+ const hookName = rawHookName.startsWith("use") ? rawHookName : `use${toPascalCase(rawHookName)}`;
293
+ const hookDir = isFeatureHook ? path.resolve(appRoot, `./src/features/${toKebabCase(featureName)}/hooks`) : path.resolve(appRoot, `./src/shared/hooks`);
294
+ const hookPath = path.join(hookDir, `${hookName}.${ext}`);
295
+ if (fs.existsSync(hookPath)) {
296
+ console.error(`[anaemia] generation halted: hook "${hookName}" already exists at ${hookPath}`);
297
+ process.exit(1);
298
+ }
299
+ if (!fs.existsSync(hookDir)) {
300
+ fs.mkdirSync(hookDir, { recursive: true });
301
+ }
302
+ // if adding to a feature, check the feature actually exists
303
+ if (isFeatureHook) {
304
+ const featureDir = path.resolve(appRoot, `./src/features/${toKebabCase(featureName)}`);
305
+ if (!fs.existsSync(featureDir)) {
306
+ console.error(`[anaemia] feature "${featureName}" does not exist. Run "create feature:${featureName}" first.`);
307
+ process.exit(1);
308
+ }
309
+ }
310
+ const hookContent = isTypeScript
311
+ ? `import { createSignal, onMount, onCleanup } from "solid-js";
312
+
313
+ interface ${toPascalCase(hookName)}Options {
314
+ // TODO: define options
315
+ }
316
+
317
+ interface ${toPascalCase(hookName)}Return {
318
+ // TODO: define return shape
319
+ }
320
+
321
+ export function ${hookName}(options?: ${toPascalCase(hookName)}Options): ${toPascalCase(hookName)}Return {
322
+ const [data, setData] = createSignal<unknown>(null);
323
+ const [error, setError] = createSignal<string | null>(null);
324
+ const [loading, setLoading] = createSignal(false);
325
+
326
+ onMount(() => {
327
+ // TODO: setup side effects
328
+ });
329
+
330
+ onCleanup(() => {
331
+ // TODO: cleanup side effects
332
+ });
333
+
334
+ return { data, error, loading };
335
+ }
336
+ `
337
+ : `import { createSignal, onMount, onCleanup } from "solid-js";
338
+
339
+ export function ${hookName}(options) {
340
+ const [data, setData] = createSignal(null);
341
+ const [error, setError] = createSignal(null);
342
+ const [loading, setLoading] = createSignal(false);
343
+
344
+ onMount(() => {
345
+ // TODO: setup side effects
346
+ });
347
+
348
+ onCleanup(() => {
349
+ // TODO: cleanup side effects
350
+ });
351
+
352
+ return { data, error, loading };
353
+ }
354
+ `;
355
+ fs.writeFileSync(hookPath, hookContent, "utf8");
356
+ if (isFeatureHook) {
357
+ const indexPath = path.resolve(appRoot, `./src/features/${toKebabCase(featureName)}/index.${ext}`);
358
+ if (fs.existsSync(indexPath)) {
359
+ const existing = fs.readFileSync(indexPath, "utf8");
360
+ const exportLine = `export { ${hookName} } from "./hooks/${hookName}";\n`;
361
+ if (!existing.includes(exportLine)) {
362
+ fs.appendFileSync(indexPath, exportLine, "utf8");
363
+ }
364
+ }
365
+ }
366
+ const location = isFeatureHook ? `src/features/${toKebabCase(featureName)}/hooks/${hookName}.${ext}` : `src/shared/hooks/${hookName}.${ext}`;
367
+ console.log("\nšŸŖ successfully generated hook:");
368
+ console.log(` └─ ${location}\n`);
369
+ }
@@ -0,0 +1,18 @@
1
+ /** converts any string (kebab, snake, camel) to kebab-case (e.g., "user-profile") */
2
+ export function toKebabCase(str) {
3
+ return str
4
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
5
+ .replace(/[\s_]+/g, "-")
6
+ .toLowerCase();
7
+ }
8
+ /** converts any string to PascalCase (e.g., "UserProfile") */
9
+ export function toPascalCase(str) {
10
+ return toKebabCase(str)
11
+ .split("-")
12
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
13
+ .join("");
14
+ }
15
+ export function toCamelCase(str) {
16
+ const pascal = toPascalCase(str);
17
+ return pascal.charAt(0).toLowerCase() + pascal.slice(1);
18
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@anaemia/cli",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "anaemia": "./dist/index.js"
7
+ },
8
+ "dependencies": {
9
+ "@rspack/core": "^2.0.4",
10
+ "cac": "^7.0.0",
11
+ "cross-spawn": "^7.0.6",
12
+ "jiti": "^2.7.0",
13
+ "picocolors": "^1.1.1",
14
+ "prompts": "^2.4.2",
15
+ "sucrase": "^3.35.1",
16
+ "ws": "^8.21.0",
17
+ "@anaemia/bundler": "0.0.1",
18
+ "@anaemia/core": "0.0.1"
19
+ },
20
+ "devDependencies": {
21
+ "@rspack/dev-server": "^2.0.1",
22
+ "@types/cross-spawn": "^6.0.6",
23
+ "@types/fs-extra": "^11.0.4",
24
+ "@types/node": "^25.9.1",
25
+ "@types/prompts": "^2.4.9",
26
+ "@types/ws": "^8.18.1",
27
+ "fs-extra": "^11.3.5"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "tsc --watch"
32
+ }
33
+ }