march-hare 0.13.0 → 0.13.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 +45 -1
- package/dist/action/index.d.ts +2 -2
- package/dist/action/utils.d.ts +2 -2
- package/dist/actions/index.d.ts +2 -2
- package/dist/actions/types.d.ts +3 -3
- package/dist/actions/utils.d.ts +3 -3
- package/dist/app/index.d.ts +7 -7
- package/dist/app/types.d.ts +5 -5
- package/dist/boundary/components/broadcast/index.d.ts +2 -2
- package/dist/boundary/components/broadcast/types.d.ts +1 -1
- package/dist/boundary/components/consumer/components/partition/index.d.ts +1 -1
- package/dist/boundary/components/consumer/components/partition/types.d.ts +1 -1
- package/dist/boundary/components/consumer/index.d.ts +5 -5
- package/dist/boundary/components/consumer/types.d.ts +1 -1
- package/dist/boundary/components/consumer/utils.d.ts +1 -1
- package/dist/boundary/components/env/index.d.ts +3 -27
- package/dist/boundary/components/env/types.d.ts +24 -2
- package/dist/boundary/components/env/utils.d.ts +1 -1
- package/dist/boundary/components/scope/index.d.ts +2 -2
- package/dist/boundary/components/scope/types.d.ts +1 -1
- package/dist/boundary/components/scope/utils.d.ts +1 -1
- package/dist/boundary/components/sharing/index.d.ts +1 -1
- package/dist/boundary/components/tap/index.d.ts +3 -3
- package/dist/boundary/components/tap/types.d.ts +2 -2
- package/dist/boundary/components/tap/utils.d.ts +1 -1
- package/dist/boundary/components/tasks/index.d.ts +2 -2
- package/dist/boundary/components/tasks/utils.d.ts +1 -1
- package/dist/boundary/index.d.ts +1 -1
- package/dist/boundary/types.d.ts +2 -2
- package/dist/cache/index.d.ts +91 -9
- package/dist/cache/types.d.ts +1 -1
- package/dist/cli/bin/mh.js +10 -0
- package/dist/cli/lib/banner/index.js +14 -0
- package/dist/cli/lib/commands/app/index.js +37 -0
- package/dist/cli/lib/commands/feature/index.js +55 -0
- package/dist/cli/lib/commands/index.js +89 -0
- package/dist/cli/lib/commands/init/index.js +29 -0
- package/dist/cli/lib/commands/shared/index.js +56 -0
- package/dist/cli/lib/index.js +56 -0
- package/dist/cli/lib/parser/index.js +24 -0
- package/dist/cli/lib/prompt/index.js +61 -0
- package/dist/cli/lib/runner/index.js +46 -0
- package/dist/cli/lib/runner/types.js +1 -0
- package/dist/cli/lib/runner/utils.js +60 -0
- package/dist/cli/lib/types.js +1 -0
- package/dist/cli/lib/utils.js +20 -0
- package/dist/cli/templates/app/action/actions.ts.ejs.t +10 -0
- package/dist/cli/templates/app/action/types.ts.ejs.t +7 -0
- package/dist/cli/templates/app/integration/index.integration.tsx.ejs.t +13 -0
- package/dist/cli/templates/app/page/actions.ts.ejs.t +14 -0
- package/dist/cli/templates/app/page/index.tsx.ejs.t +20 -0
- package/dist/cli/templates/app/page/styles.ts.ejs.t +35 -0
- package/dist/cli/templates/app/page/types.ts.ejs.t +12 -0
- package/dist/cli/templates/feature/action/actions.ts.ejs.t +10 -0
- package/dist/cli/templates/feature/action/types.ts.ejs.t +7 -0
- package/dist/cli/templates/feature/multicast/types.ts.ejs.t +7 -0
- package/dist/cli/templates/feature/presentational/index.tsx.ejs.t +14 -0
- package/dist/cli/templates/feature/presentational/types.ts.ejs.t +12 -0
- package/dist/cli/templates/feature/presentational/utils.ts.ejs.t +8 -0
- package/dist/cli/templates/feature/stateful/actions.ts.ejs.t +16 -0
- package/dist/cli/templates/feature/stateful/index.tsx.ejs.t +19 -0
- package/dist/cli/templates/feature/stateful/types.ts.ejs.t +16 -0
- package/dist/cli/templates/feature/stateful/utils.ts.ejs.t +8 -0
- package/dist/cli/templates/feature/unit/index.test.tsx.ejs.t +21 -0
- package/dist/cli/templates/init/new/README.md.ejs.t +48 -0
- package/dist/cli/templates/init/new/eslint.config.js.ejs.t +88 -0
- package/dist/cli/templates/init/new/gitignore.ejs.t +9 -0
- package/dist/cli/templates/init/new/index.html.ejs.t +18 -0
- package/dist/cli/templates/init/new/package.json.ejs.t +54 -0
- package/dist/cli/templates/init/new/playwright.config.ts.ejs.t +17 -0
- package/dist/cli/templates/init/new/prettierrc.ejs.t +8 -0
- package/dist/cli/templates/init/new/src.app.index.tsx.ejs.t +14 -0
- package/dist/cli/templates/init/new/src.app.pages.home.actions.ts.ejs.t +16 -0
- package/dist/cli/templates/init/new/src.app.pages.home.index.tsx.ejs.t +30 -0
- package/dist/cli/templates/init/new/src.app.pages.home.integration.tsx.ejs.t +28 -0
- package/dist/cli/templates/init/new/src.app.pages.home.styles.ts.ejs.t +45 -0
- package/dist/cli/templates/init/new/src.app.pages.home.types.ts.ejs.t +12 -0
- package/dist/cli/templates/init/new/src.app.utils.ts.ejs.t +9 -0
- package/dist/cli/templates/init/new/src.features.greet.actions.ts.ejs.t +20 -0
- package/dist/cli/templates/init/new/src.features.greet.index.test.tsx.ejs.t +21 -0
- package/dist/cli/templates/init/new/src.features.greet.index.tsx.ejs.t +24 -0
- package/dist/cli/templates/init/new/src.features.greet.types.ts.ejs.t +18 -0
- package/dist/cli/templates/init/new/src.features.greet.utils.ts.ejs.t +8 -0
- package/dist/cli/templates/init/new/src.index.tsx.ejs.t +8 -0
- package/dist/cli/templates/init/new/src.shared.components.button.index.test.tsx.ejs.t +13 -0
- package/dist/cli/templates/init/new/src.shared.components.button.index.tsx.ejs.t +10 -0
- package/dist/cli/templates/init/new/src.shared.components.button.types.ts.ejs.t +6 -0
- package/dist/cli/templates/init/new/src.shared.resources.index.ts.ejs.t +4 -0
- package/dist/cli/templates/init/new/src.shared.theme.index.ts.ejs.t +51 -0
- package/dist/cli/templates/init/new/src.shared.types.index.ts.ejs.t +23 -0
- package/dist/cli/templates/init/new/src.test-setup.ts.ejs.t +10 -0
- package/dist/cli/templates/init/new/src.vite-env.d.ts.ejs.t +4 -0
- package/dist/cli/templates/init/new/tests.home.e2e.ts.ejs.t +14 -0
- package/dist/cli/templates/init/new/tsconfig.json.ejs.t +29 -0
- package/dist/cli/templates/init/new/vite.config.ts.ejs.t +17 -0
- package/dist/cli/templates/init/new/vitest.config.ts.ejs.t +24 -0
- package/dist/cli/templates/shared/component/index.tsx.ejs.t +9 -0
- package/dist/cli/templates/shared/component/types.ts.ejs.t +8 -0
- package/dist/cli/templates/shared/resource/index.ts.ejs.t +15 -0
- package/dist/cli/templates/shared/resource/types.ts.ejs.t +10 -0
- package/dist/cli/templates/shared/type-broadcast/types.ts.ejs.t +7 -0
- package/dist/cli/templates/shared/type-payload/types.ts.ejs.t +9 -0
- package/dist/cli/templates/shared/unit-component/index.test.tsx.ejs.t +13 -0
- package/dist/cli/templates/shared/unit-resource/index.test.ts.ejs.t +15 -0
- package/dist/cli/templates/shared/unit-util/index.test.ts.ejs.t +11 -0
- package/dist/cli/templates/shared/util/index.ts.ejs.t +6 -0
- package/dist/coalesce/index.d.ts +1 -1
- package/dist/context/index.d.ts +1 -1
- package/dist/error/types.d.ts +1 -1
- package/dist/error/utils.d.ts +1 -1
- package/dist/index.d.ts +17 -17
- package/dist/march-hare.js +9 -8
- package/dist/march-hare.js.map +1 -1
- package/dist/march-hare.umd.cjs +1 -1
- package/dist/march-hare.umd.cjs.map +1 -1
- package/dist/resource/index.d.ts +6 -5
- package/dist/resource/types.d.ts +3 -3
- package/dist/resource/utils.d.ts +12 -5
- package/dist/scope/index.d.ts +3 -3
- package/dist/scope/types.d.ts +2 -2
- package/dist/scope/utils.d.ts +6 -4
- package/dist/shared/index.d.ts +4 -4
- package/dist/types/index.d.ts +5 -5
- package/dist/utils/index.d.ts +3 -3
- package/dist/utils/types.d.ts +1 -1
- package/dist/utils/utils.d.ts +1 -1
- package/dist/with/index.d.ts +3 -3
- package/dist/with/types.d.ts +2 -2
- package/dist/with/utils.d.ts +3 -3
- package/package.json +18 -4
- package/src/cli/README.md +314 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import kleur from "kleur";
|
|
3
|
+
import { pascalCase } from "change-case";
|
|
4
|
+
import { scaffold } from "../../runner/index.js";
|
|
5
|
+
import { askName, askConfirm, pickDirectory, requireProjectRoot, } from "../../prompt/index.js";
|
|
6
|
+
async function resolveStateful(flags) {
|
|
7
|
+
if (flags.stateful !== undefined)
|
|
8
|
+
return flags.stateful !== false;
|
|
9
|
+
if (flags.presentational !== undefined)
|
|
10
|
+
return flags.presentational === false;
|
|
11
|
+
return askConfirm("Does this feature own state and actions?", true);
|
|
12
|
+
}
|
|
13
|
+
export async function newFeature({ positional, flags, }) {
|
|
14
|
+
const root = requireProjectRoot();
|
|
15
|
+
const name = positional[0] || (await askName("Feature name (kebab-case)"));
|
|
16
|
+
const stateful = await resolveStateful(flags);
|
|
17
|
+
const action = stateful ? "stateful" : "presentational";
|
|
18
|
+
await scaffold("feature", action, { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
19
|
+
console.log(kleur.green("\n Feature ready."), kleur.dim(`Mount it inside a page with <${pascalCase(name)} />.`));
|
|
20
|
+
}
|
|
21
|
+
export async function unit({ positional }) {
|
|
22
|
+
const root = requireProjectRoot();
|
|
23
|
+
const featuresRoot = path.join(root, "src", "features");
|
|
24
|
+
const name = positional[0] || (await pickDirectory("feature", featuresRoot));
|
|
25
|
+
await scaffold("feature", "unit", { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
26
|
+
}
|
|
27
|
+
export async function action({ positional, flags, }) {
|
|
28
|
+
const root = requireProjectRoot();
|
|
29
|
+
const featuresRoot = path.join(root, "src", "features");
|
|
30
|
+
const feature = positional[0] || (await pickDirectory("feature", featuresRoot));
|
|
31
|
+
const name = positional[1] ||
|
|
32
|
+
(typeof flags.name === "string" ? flags.name : undefined) ||
|
|
33
|
+
(await askName("Action name (PascalCase)"));
|
|
34
|
+
await scaffold("feature", "action", {
|
|
35
|
+
feature,
|
|
36
|
+
name: pascalCase(name),
|
|
37
|
+
pascalName: pascalCase(name),
|
|
38
|
+
rawName: name,
|
|
39
|
+
}, { cwd: root });
|
|
40
|
+
}
|
|
41
|
+
export async function multicast({ positional, flags, }) {
|
|
42
|
+
const root = requireProjectRoot();
|
|
43
|
+
const featuresRoot = path.join(root, "src", "features");
|
|
44
|
+
const feature = positional[0] || (await pickDirectory("feature", featuresRoot));
|
|
45
|
+
const name = positional[1] ||
|
|
46
|
+
(typeof flags.name === "string" ? flags.name : undefined) ||
|
|
47
|
+
(await askName("Multicast action (PascalCase)"));
|
|
48
|
+
await scaffold("feature", "multicast", {
|
|
49
|
+
feature,
|
|
50
|
+
featurePascal: pascalCase(feature),
|
|
51
|
+
name: pascalCase(name),
|
|
52
|
+
pascalName: pascalCase(name),
|
|
53
|
+
rawName: name,
|
|
54
|
+
}, { cwd: root });
|
|
55
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as init from "./init/index.js";
|
|
2
|
+
import * as app from "./app/index.js";
|
|
3
|
+
import * as feature from "./feature/index.js";
|
|
4
|
+
import * as shared from "./shared/index.js";
|
|
5
|
+
export const tree = {
|
|
6
|
+
init: {
|
|
7
|
+
leaf: true,
|
|
8
|
+
description: "Bootstrap a new March Hare project",
|
|
9
|
+
run: init.run,
|
|
10
|
+
},
|
|
11
|
+
app: {
|
|
12
|
+
leaf: false,
|
|
13
|
+
description: "Manage the host (pages, integration tests, actions)",
|
|
14
|
+
children: {
|
|
15
|
+
new: {
|
|
16
|
+
leaf: true,
|
|
17
|
+
description: "Create a new page under app/pages/",
|
|
18
|
+
run: app.newPage,
|
|
19
|
+
},
|
|
20
|
+
integration: {
|
|
21
|
+
leaf: true,
|
|
22
|
+
description: "Add an integration test for an existing page",
|
|
23
|
+
run: app.integration,
|
|
24
|
+
},
|
|
25
|
+
action: {
|
|
26
|
+
leaf: true,
|
|
27
|
+
description: "Add a new action handler to an existing page",
|
|
28
|
+
run: app.action,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
feature: {
|
|
33
|
+
leaf: false,
|
|
34
|
+
description: "Manage features (slices, unit tests, actions)",
|
|
35
|
+
children: {
|
|
36
|
+
new: {
|
|
37
|
+
leaf: true,
|
|
38
|
+
description: "Create a new feature slice",
|
|
39
|
+
run: feature.newFeature,
|
|
40
|
+
},
|
|
41
|
+
unit: {
|
|
42
|
+
leaf: true,
|
|
43
|
+
description: "Add a unit test next to an existing feature",
|
|
44
|
+
run: feature.unit,
|
|
45
|
+
},
|
|
46
|
+
action: {
|
|
47
|
+
leaf: true,
|
|
48
|
+
description: "Add a new action handler to an existing feature",
|
|
49
|
+
run: feature.action,
|
|
50
|
+
},
|
|
51
|
+
multicast: {
|
|
52
|
+
leaf: true,
|
|
53
|
+
description: "Add a multicast action to an existing feature's Scope",
|
|
54
|
+
run: feature.multicast,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
shared: {
|
|
59
|
+
leaf: false,
|
|
60
|
+
description: "Manage shared building blocks",
|
|
61
|
+
children: {
|
|
62
|
+
component: {
|
|
63
|
+
leaf: true,
|
|
64
|
+
description: "Create a new shared component",
|
|
65
|
+
run: shared.component,
|
|
66
|
+
},
|
|
67
|
+
resource: {
|
|
68
|
+
leaf: true,
|
|
69
|
+
description: "Create a new shared resource",
|
|
70
|
+
run: shared.resource,
|
|
71
|
+
},
|
|
72
|
+
util: {
|
|
73
|
+
leaf: true,
|
|
74
|
+
description: "Create a new shared utility",
|
|
75
|
+
run: shared.util,
|
|
76
|
+
},
|
|
77
|
+
type: {
|
|
78
|
+
leaf: true,
|
|
79
|
+
description: "Add a shared type/payload/broadcast namespace",
|
|
80
|
+
run: shared.type,
|
|
81
|
+
},
|
|
82
|
+
unit: {
|
|
83
|
+
leaf: true,
|
|
84
|
+
description: "Add a unit test next to an existing shared module",
|
|
85
|
+
run: shared.unit,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
import { pascalCase } from "change-case";
|
|
5
|
+
import { scaffold } from "../../runner/index.js";
|
|
6
|
+
import { askName, askDescription } from "../../prompt/index.js";
|
|
7
|
+
export async function run({ positional, flags }) {
|
|
8
|
+
const rawName = positional[0] ||
|
|
9
|
+
(typeof flags.name === "string" ? flags.name : undefined) ||
|
|
10
|
+
(await askName("Project name", "my-app"));
|
|
11
|
+
const description = (typeof flags.description === "string" ? flags.description : undefined) ||
|
|
12
|
+
(await askDescription("Short description", `A March Hare project: ${rawName}`));
|
|
13
|
+
const apiBase = (typeof flags.apiBase === "string" ? flags.apiBase : undefined) ||
|
|
14
|
+
(await askDescription("Default API base URL", "https://api.example.com"));
|
|
15
|
+
const cwd = path.resolve(process.cwd(), rawName);
|
|
16
|
+
const env = pascalCase(rawName);
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(kleur.bold(` Scaffolding ${kleur.magenta(rawName)} into ${kleur.gray(cwd)}`));
|
|
19
|
+
console.log();
|
|
20
|
+
await scaffold("init", "new", { name: rawName, description, apiBase, env }, { cwd });
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(kleur.green(" Project ready."));
|
|
23
|
+
console.log();
|
|
24
|
+
console.log(kleur.bold(" Next steps:"));
|
|
25
|
+
console.log(` cd ${rawName}`);
|
|
26
|
+
console.log(" yarn install");
|
|
27
|
+
console.log(" yarn dev");
|
|
28
|
+
console.log();
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
import { select } from "@inquirer/prompts";
|
|
5
|
+
import { pascalCase } from "change-case";
|
|
6
|
+
import { scaffold } from "../../runner/index.js";
|
|
7
|
+
import { askName, pickDirectory, requireProjectRoot, } from "../../prompt/index.js";
|
|
8
|
+
const sharedSubdirs = {
|
|
9
|
+
components: "component",
|
|
10
|
+
resources: "resource",
|
|
11
|
+
utils: "util",
|
|
12
|
+
};
|
|
13
|
+
export async function component({ positional }) {
|
|
14
|
+
const root = requireProjectRoot();
|
|
15
|
+
const name = positional[0] || (await askName("Component name (kebab-case)"));
|
|
16
|
+
await scaffold("shared", "component", { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
17
|
+
}
|
|
18
|
+
export async function resource({ positional }) {
|
|
19
|
+
const root = requireProjectRoot();
|
|
20
|
+
const name = positional[0] || (await askName("Resource name (kebab-case)"));
|
|
21
|
+
await scaffold("shared", "resource", { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
22
|
+
console.log(kleur.dim(`\n Remember to re-export from src/shared/resources/index.ts: export * as ${name.replace(/-/g, "")} from "./${name}/index.ts";`));
|
|
23
|
+
}
|
|
24
|
+
export async function util({ positional }) {
|
|
25
|
+
const root = requireProjectRoot();
|
|
26
|
+
const name = positional[0] || (await askName("Util name (kebab-case)"));
|
|
27
|
+
await scaffold("shared", "util", { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
28
|
+
}
|
|
29
|
+
export async function type({ positional, flags }) {
|
|
30
|
+
const root = requireProjectRoot();
|
|
31
|
+
const kind = positional[0] ||
|
|
32
|
+
(typeof flags.kind === "string" ? flags.kind : undefined) ||
|
|
33
|
+
(await select({
|
|
34
|
+
message: "Kind of type to add",
|
|
35
|
+
choices: [
|
|
36
|
+
{ name: "Payload — cross-feature data type", value: "payload" },
|
|
37
|
+
{ name: "Broadcast — global action class", value: "broadcast" },
|
|
38
|
+
],
|
|
39
|
+
}));
|
|
40
|
+
const name = positional[1] || (await askName(`${kind} name (kebab-case)`));
|
|
41
|
+
await scaffold("shared", `type-${kind}`, { name, pascalName: pascalCase(name) }, { cwd: root });
|
|
42
|
+
}
|
|
43
|
+
export async function unit({ positional }) {
|
|
44
|
+
const root = requireProjectRoot();
|
|
45
|
+
const sharedRoot = path.join(root, "src", "shared");
|
|
46
|
+
const kindKey = positional[0] ||
|
|
47
|
+
(await select({
|
|
48
|
+
message: "Which kind of shared module?",
|
|
49
|
+
choices: Object.keys(sharedSubdirs)
|
|
50
|
+
.filter((key) => fs.existsSync(path.join(sharedRoot, key)))
|
|
51
|
+
.map((key) => ({ name: key, value: key })),
|
|
52
|
+
}));
|
|
53
|
+
const dir = path.join(sharedRoot, kindKey);
|
|
54
|
+
const name = positional[1] || (await pickDirectory(kindKey, dir));
|
|
55
|
+
await scaffold("shared", `unit-${sharedSubdirs[kindKey]}`, { name, pascalName: pascalCase(name), kind: kindKey }, { cwd: root });
|
|
56
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import process from "node:process";
|
|
2
|
+
import kleur from "kleur";
|
|
3
|
+
import { select } from "@inquirer/prompts";
|
|
4
|
+
import { banner } from "./banner/index.js";
|
|
5
|
+
import { tree } from "./commands/index.js";
|
|
6
|
+
import { parseInvocation } from "./parser/index.js";
|
|
7
|
+
async function selectChild(branch) {
|
|
8
|
+
const choices = Object.entries(branch.children).map(([key, child]) => ({
|
|
9
|
+
name: child.leaf
|
|
10
|
+
? `${kleur.bold(key).padEnd(16)} ${kleur.gray(child.description)}`
|
|
11
|
+
: `${kleur.bold(key).padEnd(16)} ${kleur.gray(`${child.description} ›`)}`,
|
|
12
|
+
value: key,
|
|
13
|
+
}));
|
|
14
|
+
return select({ message: "What would you like to do?", choices });
|
|
15
|
+
}
|
|
16
|
+
function printTree(branch) {
|
|
17
|
+
console.log(kleur.dim("Available sub-commands:"));
|
|
18
|
+
Object.entries(branch.children).forEach(([key, child]) => {
|
|
19
|
+
const arrow = child.leaf ? " " : " ›";
|
|
20
|
+
console.log(` ${kleur.bold(key).padEnd(14)} ${kleur.gray(child.description)}${arrow}`);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function descend(node, positional, flags) {
|
|
24
|
+
if (node.leaf) {
|
|
25
|
+
return node.run({ positional, flags });
|
|
26
|
+
}
|
|
27
|
+
const [next, ...rest] = positional;
|
|
28
|
+
if (next && node.children[next]) {
|
|
29
|
+
return descend(node.children[next], rest, flags);
|
|
30
|
+
}
|
|
31
|
+
if (next) {
|
|
32
|
+
console.error(kleur.red(`Unknown command: ${next}`));
|
|
33
|
+
printTree(node);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
const choice = await selectChild(node);
|
|
37
|
+
return descend(node.children[choice], rest, flags);
|
|
38
|
+
}
|
|
39
|
+
function rootBranch(children) {
|
|
40
|
+
return { leaf: false, description: "root", children };
|
|
41
|
+
}
|
|
42
|
+
export async function main(argv) {
|
|
43
|
+
const { positionals, flags } = parseInvocation(argv);
|
|
44
|
+
if (flags.help || positionals[0] === "help") {
|
|
45
|
+
banner();
|
|
46
|
+
console.log(kleur.bold("Usage:"), "mh [command] [sub-command] [name] [--flag=value]");
|
|
47
|
+
console.log();
|
|
48
|
+
printTree(rootBranch(tree));
|
|
49
|
+
console.log();
|
|
50
|
+
console.log(kleur.dim("Run a command with no name to be prompted interactively."));
|
|
51
|
+
console.log(kleur.dim("Run `mh` alone for a menu of all commands."));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
banner();
|
|
55
|
+
await descend(rootBranch(tree), [...positionals], flags);
|
|
56
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { parseArgs } from "node:util";
|
|
2
|
+
import { config } from "../utils.js";
|
|
3
|
+
function splitNegations(args) {
|
|
4
|
+
return args.reduce((acc, token) => token.startsWith("--no-")
|
|
5
|
+
? {
|
|
6
|
+
rest: acc.rest,
|
|
7
|
+
negations: { ...acc.negations, [token.slice(5)]: false },
|
|
8
|
+
}
|
|
9
|
+
: { rest: [...acc.rest, token], negations: acc.negations }, { rest: [], negations: {} });
|
|
10
|
+
}
|
|
11
|
+
export function parseInvocation(argv) {
|
|
12
|
+
const { rest, negations } = splitNegations(argv);
|
|
13
|
+
const { values, positionals } = parseArgs({
|
|
14
|
+
args: rest,
|
|
15
|
+
options: config.options,
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
strict: false,
|
|
18
|
+
tokens: false,
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
positionals,
|
|
22
|
+
flags: { ...values, ...negations },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { input, select, confirm } from "@inquirer/prompts";
|
|
5
|
+
import kleur from "kleur";
|
|
6
|
+
import { findUpSync } from "find-up";
|
|
7
|
+
import { kebabCase } from "change-case";
|
|
8
|
+
import { config } from "../utils.js";
|
|
9
|
+
export async function askName(message, fallback) {
|
|
10
|
+
const value = await input({
|
|
11
|
+
message,
|
|
12
|
+
default: fallback,
|
|
13
|
+
validate: (raw) => {
|
|
14
|
+
const slug = kebabCase(raw);
|
|
15
|
+
if (!slug)
|
|
16
|
+
return "Name is required";
|
|
17
|
+
if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
|
|
18
|
+
return "Use lowercase letters, digits and hyphens (e.g. add-cat)";
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
return kebabCase(value);
|
|
24
|
+
}
|
|
25
|
+
export async function askDescription(message, fallback = "") {
|
|
26
|
+
return input({ message, default: fallback });
|
|
27
|
+
}
|
|
28
|
+
export async function askConfirm(message, def = true) {
|
|
29
|
+
return confirm({ message, default: def });
|
|
30
|
+
}
|
|
31
|
+
export async function pickDirectory(label, root) {
|
|
32
|
+
if (!fs.existsSync(root)) {
|
|
33
|
+
throw new Error(`${label} root not found: ${root}. Run \`mh init <name>\` first.`);
|
|
34
|
+
}
|
|
35
|
+
const entries = fs
|
|
36
|
+
.readdirSync(root, { withFileTypes: true })
|
|
37
|
+
.filter((entry) => entry.isDirectory())
|
|
38
|
+
.map((entry) => entry.name)
|
|
39
|
+
.toSorted();
|
|
40
|
+
if (entries.length === 0) {
|
|
41
|
+
throw new Error(`No ${label} found under ${path.relative(process.cwd(), root)}.`);
|
|
42
|
+
}
|
|
43
|
+
return select({
|
|
44
|
+
message: `Pick a ${label}`,
|
|
45
|
+
choices: entries.map((name) => ({ name, value: name })),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export function findProjectRoot(startCwd = process.cwd()) {
|
|
49
|
+
const marker = findUpSync((directory) => config.projectMarkers.every((relative) => fs.existsSync(path.join(directory, relative)))
|
|
50
|
+
? directory
|
|
51
|
+
: undefined, { cwd: startCwd, type: "directory" });
|
|
52
|
+
return marker ?? null;
|
|
53
|
+
}
|
|
54
|
+
export function requireProjectRoot() {
|
|
55
|
+
const root = findProjectRoot();
|
|
56
|
+
if (!root) {
|
|
57
|
+
console.error(kleur.red("Could not find a March Hare project root. Run inside a project created with `mh init <name>`."));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import ejs from "ejs";
|
|
6
|
+
import kleur from "kleur";
|
|
7
|
+
import { glob } from "tinyglobby";
|
|
8
|
+
import { helpers, parseTemplate, renderCondition, renderForce, writeFile, injectInto, } from "./utils.js";
|
|
9
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const templates = path.resolve(here, "..", "..", "templates");
|
|
11
|
+
export async function scaffold(generator, action, vars, { cwd = process.cwd() } = {}) {
|
|
12
|
+
const root = path.join(templates, generator, action);
|
|
13
|
+
if (!fs.existsSync(root)) {
|
|
14
|
+
throw new Error(`Unknown generator: ${generator}/${action}`);
|
|
15
|
+
}
|
|
16
|
+
const data = { ...helpers, h: helpers, ...vars };
|
|
17
|
+
const files = await glob(["**/*.ejs.t"], { cwd: root, absolute: true });
|
|
18
|
+
const initial = { written: [], skipped: [], injected: [] };
|
|
19
|
+
const result = files.reduce((acc, file) => {
|
|
20
|
+
const raw = fs.readFileSync(file, "utf8");
|
|
21
|
+
const { meta, body } = parseTemplate(raw);
|
|
22
|
+
if (!meta.to)
|
|
23
|
+
return acc;
|
|
24
|
+
const target = path.resolve(cwd, ejs.render(meta.to, data));
|
|
25
|
+
if (!renderCondition(meta.if, data)) {
|
|
26
|
+
return { ...acc, skipped: [...acc.skipped, path.relative(cwd, target)] };
|
|
27
|
+
}
|
|
28
|
+
const rendered = ejs.render(body, data, { filename: file });
|
|
29
|
+
if (meta.inject) {
|
|
30
|
+
injectInto(target, rendered, meta, data);
|
|
31
|
+
return {
|
|
32
|
+
...acc,
|
|
33
|
+
injected: [...acc.injected, path.relative(cwd, target)],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (fs.existsSync(target) && !renderForce(meta.force, data)) {
|
|
37
|
+
return { ...acc, skipped: [...acc.skipped, path.relative(cwd, target)] };
|
|
38
|
+
}
|
|
39
|
+
writeFile(target, rendered);
|
|
40
|
+
return { ...acc, written: [...acc.written, path.relative(cwd, target)] };
|
|
41
|
+
}, initial);
|
|
42
|
+
result.written.forEach((file) => console.log(kleur.green(" added"), file));
|
|
43
|
+
result.injected.forEach((file) => console.log(kleur.cyan(" injected"), file));
|
|
44
|
+
result.skipped.forEach((file) => console.log(kleur.yellow(" skipped"), file));
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import matter from "gray-matter";
|
|
4
|
+
import ejs from "ejs";
|
|
5
|
+
import { kebabCase, pascalCase, camelCase, capitalCase } from "change-case";
|
|
6
|
+
export const helpers = {
|
|
7
|
+
kebab: kebabCase,
|
|
8
|
+
pascal: pascalCase,
|
|
9
|
+
camel: camelCase,
|
|
10
|
+
title: capitalCase,
|
|
11
|
+
};
|
|
12
|
+
export function parseTemplate(source) {
|
|
13
|
+
const parsed = matter(source, { delimiters: ["---", "---"] });
|
|
14
|
+
return {
|
|
15
|
+
meta: parsed.data,
|
|
16
|
+
body: parsed.content.replace(/^\n/, ""),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function isTruthy(value) {
|
|
20
|
+
return value === true || value === "true" || value === "yes";
|
|
21
|
+
}
|
|
22
|
+
export function renderCondition(expr, data) {
|
|
23
|
+
if (!expr)
|
|
24
|
+
return true;
|
|
25
|
+
return isTruthy(ejs.render(`<%= ${expr} %>`, data));
|
|
26
|
+
}
|
|
27
|
+
export function renderForce(expr, data) {
|
|
28
|
+
if (!expr)
|
|
29
|
+
return false;
|
|
30
|
+
return isTruthy(ejs.render(`<%= ${expr} %>`, data));
|
|
31
|
+
}
|
|
32
|
+
export function writeFile(target, content) {
|
|
33
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
34
|
+
fs.writeFileSync(target, content, "utf8");
|
|
35
|
+
}
|
|
36
|
+
export function injectInto(target, content, meta, data) {
|
|
37
|
+
if (!fs.existsSync(target)) {
|
|
38
|
+
writeFile(target, content);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const original = fs.readFileSync(target, "utf8");
|
|
42
|
+
if (meta.skip_if) {
|
|
43
|
+
const pattern = new RegExp(ejs.render(meta.skip_if, data), "m");
|
|
44
|
+
if (pattern.test(original))
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (meta.after) {
|
|
48
|
+
const pattern = new RegExp(ejs.render(meta.after, data), "m");
|
|
49
|
+
const updated = original.replace(pattern, (match) => `${match}\n${content}`);
|
|
50
|
+
fs.writeFileSync(target, updated, "utf8");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (meta.before) {
|
|
54
|
+
const pattern = new RegExp(ejs.render(meta.before, data), "m");
|
|
55
|
+
const updated = original.replace(pattern, (match) => `${content}\n${match}`);
|
|
56
|
+
fs.writeFileSync(target, updated, "utf8");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
fs.writeFileSync(target, `${original}\n${content}`, "utf8");
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const config = {
|
|
2
|
+
options: {
|
|
3
|
+
help: { type: "boolean", short: "h" },
|
|
4
|
+
name: { type: "string" },
|
|
5
|
+
description: { type: "string" },
|
|
6
|
+
apiBase: { type: "string" },
|
|
7
|
+
heading: { type: "string" },
|
|
8
|
+
tagline: { type: "string" },
|
|
9
|
+
kind: { type: "string" },
|
|
10
|
+
stateful: { type: "boolean" },
|
|
11
|
+
presentational: { type: "boolean" },
|
|
12
|
+
},
|
|
13
|
+
banner: {
|
|
14
|
+
title: "March Hare",
|
|
15
|
+
tagline: "We're all mad here.",
|
|
16
|
+
subtitle: "— scaffold tool for March Hare projects",
|
|
17
|
+
font: "Slant",
|
|
18
|
+
},
|
|
19
|
+
projectMarkers: ["package.json", "src/app"],
|
|
20
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: src/app/pages/<%= name %>/index.integration.tsx
|
|
3
|
+
---
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { render } from "@testing-library/react";
|
|
6
|
+
import { Root } from "@app/index.tsx";
|
|
7
|
+
|
|
8
|
+
describe("<%= pascalName %>Page (integration)", () => {
|
|
9
|
+
it("mounts inside the app boundary without crashing", () => {
|
|
10
|
+
render(<Root />);
|
|
11
|
+
expect(document.body).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: src/app/pages/<%= name %>/actions.ts
|
|
3
|
+
---
|
|
4
|
+
import { app } from "../../utils.ts";
|
|
5
|
+
import { Actions, type Model } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function useActions() {
|
|
8
|
+
const context = app.useContext<Model, typeof Actions>();
|
|
9
|
+
const actions = context.useActions({ ready: false });
|
|
10
|
+
|
|
11
|
+
actions.useAction(Actions.Ready, context.with.always("ready", true));
|
|
12
|
+
|
|
13
|
+
return actions;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: src/app/pages/<%= name %>/index.tsx
|
|
3
|
+
---
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
import { useActions } from "./actions.ts";
|
|
6
|
+
import * as styles from "./styles.ts";
|
|
7
|
+
|
|
8
|
+
export function <%= pascalName %>Page(): React.ReactElement {
|
|
9
|
+
const [model] = useActions();
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<main className={styles.layout}>
|
|
13
|
+
<header className={styles.header}>
|
|
14
|
+
<h1 className={styles.title}><%= heading %></h1>
|
|
15
|
+
<p className={styles.tagline}><%= tagline %></p>
|
|
16
|
+
</header>
|
|
17
|
+
<p>Model says: {JSON.stringify(model)}</p>
|
|
18
|
+
</main>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: src/app/pages/<%= name %>/styles.ts
|
|
3
|
+
---
|
|
4
|
+
import { css } from "@emotion/css";
|
|
5
|
+
import { colour, font, spacing } from "@shared/theme/index.ts";
|
|
6
|
+
|
|
7
|
+
export const layout = css`
|
|
8
|
+
min-height: 100vh;
|
|
9
|
+
padding: ${spacing.xxl} ${spacing.l};
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: ${spacing.xl};
|
|
14
|
+
font-family: ${font.family};
|
|
15
|
+
`;
|
|
16
|
+
|
|
17
|
+
export const header = css`
|
|
18
|
+
display: flex;
|
|
19
|
+
flex-direction: column;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: ${spacing.xs};
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export const title = css`
|
|
25
|
+
margin: 0;
|
|
26
|
+
font-size: ${font.size.xxl};
|
|
27
|
+
font-weight: ${font.weight.bold};
|
|
28
|
+
color: ${colour.text.primary};
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
export const tagline = css`
|
|
32
|
+
margin: 0;
|
|
33
|
+
color: ${colour.text.secondary};
|
|
34
|
+
font-size: ${font.size.m};
|
|
35
|
+
`;
|