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