march-hare 0.12.1 → 0.13.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 (132) hide show
  1. package/README.md +66 -25
  2. package/dist/action/index.d.ts +2 -2
  3. package/dist/action/utils.d.ts +2 -2
  4. package/dist/actions/index.d.ts +2 -2
  5. package/dist/actions/types.d.ts +3 -3
  6. package/dist/actions/utils.d.ts +3 -3
  7. package/dist/app/index.d.ts +33 -87
  8. package/dist/app/types.d.ts +79 -26
  9. package/dist/boundary/components/broadcast/index.d.ts +2 -2
  10. package/dist/boundary/components/broadcast/types.d.ts +1 -1
  11. package/dist/boundary/components/consumer/components/partition/index.d.ts +1 -1
  12. package/dist/boundary/components/consumer/components/partition/types.d.ts +1 -1
  13. package/dist/boundary/components/consumer/index.d.ts +5 -5
  14. package/dist/boundary/components/consumer/types.d.ts +1 -1
  15. package/dist/boundary/components/consumer/utils.d.ts +1 -1
  16. package/dist/boundary/components/env/index.d.ts +3 -16
  17. package/dist/boundary/components/env/types.d.ts +24 -2
  18. package/dist/boundary/components/env/utils.d.ts +1 -1
  19. package/dist/boundary/components/scope/index.d.ts +2 -2
  20. package/dist/boundary/components/scope/types.d.ts +1 -1
  21. package/dist/boundary/components/scope/utils.d.ts +1 -1
  22. package/dist/boundary/components/sharing/index.d.ts +3 -3
  23. package/dist/boundary/components/tap/index.d.ts +3 -3
  24. package/dist/boundary/components/tap/types.d.ts +2 -2
  25. package/dist/boundary/components/tap/utils.d.ts +1 -1
  26. package/dist/boundary/components/tasks/index.d.ts +2 -2
  27. package/dist/boundary/components/tasks/utils.d.ts +1 -1
  28. package/dist/boundary/index.d.ts +3 -3
  29. package/dist/boundary/types.d.ts +3 -3
  30. package/dist/cache/index.d.ts +68 -12
  31. package/dist/cache/types.d.ts +33 -19
  32. package/dist/cli/bin/mh.js +10 -0
  33. package/dist/cli/lib/banner/index.js +14 -0
  34. package/dist/cli/lib/commands/app/index.js +37 -0
  35. package/dist/cli/lib/commands/feature/index.js +55 -0
  36. package/dist/cli/lib/commands/index.js +89 -0
  37. package/dist/cli/lib/commands/init/index.js +29 -0
  38. package/dist/cli/lib/commands/shared/index.js +56 -0
  39. package/dist/cli/lib/index.js +56 -0
  40. package/dist/cli/lib/parser/index.js +24 -0
  41. package/dist/cli/lib/prompt/index.js +61 -0
  42. package/dist/cli/lib/runner/index.js +46 -0
  43. package/dist/cli/lib/runner/types.js +1 -0
  44. package/dist/cli/lib/runner/utils.js +60 -0
  45. package/dist/cli/lib/types.js +1 -0
  46. package/dist/cli/lib/utils.js +20 -0
  47. package/dist/cli/templates/app/action/actions.ts.ejs.t +10 -0
  48. package/dist/cli/templates/app/action/types.ts.ejs.t +7 -0
  49. package/dist/cli/templates/app/integration/index.integration.tsx.ejs.t +13 -0
  50. package/dist/cli/templates/app/page/actions.ts.ejs.t +14 -0
  51. package/dist/cli/templates/app/page/index.tsx.ejs.t +20 -0
  52. package/dist/cli/templates/app/page/styles.ts.ejs.t +35 -0
  53. package/dist/cli/templates/app/page/types.ts.ejs.t +12 -0
  54. package/dist/cli/templates/feature/action/actions.ts.ejs.t +10 -0
  55. package/dist/cli/templates/feature/action/types.ts.ejs.t +7 -0
  56. package/dist/cli/templates/feature/multicast/types.ts.ejs.t +7 -0
  57. package/dist/cli/templates/feature/presentational/index.tsx.ejs.t +14 -0
  58. package/dist/cli/templates/feature/presentational/types.ts.ejs.t +12 -0
  59. package/dist/cli/templates/feature/presentational/utils.ts.ejs.t +8 -0
  60. package/dist/cli/templates/feature/stateful/actions.ts.ejs.t +16 -0
  61. package/dist/cli/templates/feature/stateful/index.tsx.ejs.t +19 -0
  62. package/dist/cli/templates/feature/stateful/types.ts.ejs.t +16 -0
  63. package/dist/cli/templates/feature/stateful/utils.ts.ejs.t +8 -0
  64. package/dist/cli/templates/feature/unit/index.test.tsx.ejs.t +21 -0
  65. package/dist/cli/templates/init/new/README.md.ejs.t +48 -0
  66. package/dist/cli/templates/init/new/eslint.config.js.ejs.t +88 -0
  67. package/dist/cli/templates/init/new/gitignore.ejs.t +9 -0
  68. package/dist/cli/templates/init/new/index.html.ejs.t +18 -0
  69. package/dist/cli/templates/init/new/package.json.ejs.t +54 -0
  70. package/dist/cli/templates/init/new/playwright.config.ts.ejs.t +17 -0
  71. package/dist/cli/templates/init/new/prettierrc.ejs.t +8 -0
  72. package/dist/cli/templates/init/new/src.app.index.tsx.ejs.t +14 -0
  73. package/dist/cli/templates/init/new/src.app.pages.home.actions.ts.ejs.t +16 -0
  74. package/dist/cli/templates/init/new/src.app.pages.home.index.tsx.ejs.t +30 -0
  75. package/dist/cli/templates/init/new/src.app.pages.home.integration.tsx.ejs.t +28 -0
  76. package/dist/cli/templates/init/new/src.app.pages.home.styles.ts.ejs.t +45 -0
  77. package/dist/cli/templates/init/new/src.app.pages.home.types.ts.ejs.t +12 -0
  78. package/dist/cli/templates/init/new/src.app.utils.ts.ejs.t +9 -0
  79. package/dist/cli/templates/init/new/src.features.greet.actions.ts.ejs.t +20 -0
  80. package/dist/cli/templates/init/new/src.features.greet.index.test.tsx.ejs.t +21 -0
  81. package/dist/cli/templates/init/new/src.features.greet.index.tsx.ejs.t +24 -0
  82. package/dist/cli/templates/init/new/src.features.greet.types.ts.ejs.t +18 -0
  83. package/dist/cli/templates/init/new/src.features.greet.utils.ts.ejs.t +8 -0
  84. package/dist/cli/templates/init/new/src.index.tsx.ejs.t +8 -0
  85. package/dist/cli/templates/init/new/src.shared.components.button.index.test.tsx.ejs.t +13 -0
  86. package/dist/cli/templates/init/new/src.shared.components.button.index.tsx.ejs.t +10 -0
  87. package/dist/cli/templates/init/new/src.shared.components.button.types.ts.ejs.t +6 -0
  88. package/dist/cli/templates/init/new/src.shared.resources.index.ts.ejs.t +4 -0
  89. package/dist/cli/templates/init/new/src.shared.theme.index.ts.ejs.t +51 -0
  90. package/dist/cli/templates/init/new/src.shared.types.index.ts.ejs.t +23 -0
  91. package/dist/cli/templates/init/new/src.test-setup.ts.ejs.t +10 -0
  92. package/dist/cli/templates/init/new/src.vite-env.d.ts.ejs.t +4 -0
  93. package/dist/cli/templates/init/new/tests.home.e2e.ts.ejs.t +14 -0
  94. package/dist/cli/templates/init/new/tsconfig.json.ejs.t +29 -0
  95. package/dist/cli/templates/init/new/vite.config.ts.ejs.t +17 -0
  96. package/dist/cli/templates/init/new/vitest.config.ts.ejs.t +24 -0
  97. package/dist/cli/templates/shared/component/index.tsx.ejs.t +9 -0
  98. package/dist/cli/templates/shared/component/types.ts.ejs.t +8 -0
  99. package/dist/cli/templates/shared/resource/index.ts.ejs.t +15 -0
  100. package/dist/cli/templates/shared/resource/types.ts.ejs.t +10 -0
  101. package/dist/cli/templates/shared/type-broadcast/types.ts.ejs.t +7 -0
  102. package/dist/cli/templates/shared/type-payload/types.ts.ejs.t +9 -0
  103. package/dist/cli/templates/shared/unit-component/index.test.tsx.ejs.t +13 -0
  104. package/dist/cli/templates/shared/unit-resource/index.test.ts.ejs.t +15 -0
  105. package/dist/cli/templates/shared/unit-util/index.test.ts.ejs.t +11 -0
  106. package/dist/cli/templates/shared/util/index.ts.ejs.t +6 -0
  107. package/dist/coalesce/index.d.ts +1 -1
  108. package/dist/context/index.d.ts +2 -2
  109. package/dist/error/index.d.ts +18 -1
  110. package/dist/error/types.d.ts +1 -18
  111. package/dist/error/utils.d.ts +1 -1
  112. package/dist/index.d.ts +16 -14
  113. package/dist/march-hare.js +7 -6
  114. package/dist/march-hare.js.map +1 -0
  115. package/dist/march-hare.umd.cjs +2 -1
  116. package/dist/march-hare.umd.cjs.map +1 -0
  117. package/dist/resource/index.d.ts +32 -61
  118. package/dist/resource/types.d.ts +45 -22
  119. package/dist/resource/utils.d.ts +31 -3
  120. package/dist/scope/index.d.ts +4 -64
  121. package/dist/scope/types.d.ts +8 -8
  122. package/dist/scope/utils.d.ts +12 -0
  123. package/dist/shared/index.d.ts +12 -21
  124. package/dist/types/index.d.ts +114 -29
  125. package/dist/utils/index.d.ts +3 -3
  126. package/dist/utils/types.d.ts +1 -3
  127. package/dist/utils/utils.d.ts +1 -3
  128. package/dist/with/index.d.ts +17 -62
  129. package/dist/with/types.d.ts +66 -0
  130. package/dist/with/utils.d.ts +61 -0
  131. package/package.json +21 -4
  132. package/src/cli/README.md +314 -0
@@ -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,10 @@
1
+ ---
2
+ to: src/app/pages/<%= page %>/actions.ts
3
+ inject: true
4
+ before: ^\s*return actions;
5
+ skip_if: Actions\.<%= pascalName %>
6
+ ---
7
+ actions.useAction(Actions.<%= pascalName %>, (_context) => {
8
+ // TODO: handle the <%= pascalName %> action
9
+ });
10
+
@@ -0,0 +1,7 @@
1
+ ---
2
+ to: src/app/pages/<%= page %>/types.ts
3
+ inject: true
4
+ after: ^export class Actions \{$
5
+ skip_if: <%= pascalName %>
6
+ ---
7
+ static <%= pascalName %> = Action("<%= rawName %>");
@@ -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
+ `;
@@ -0,0 +1,12 @@
1
+ ---
2
+ to: src/app/pages/<%= name %>/types.ts
3
+ ---
4
+ import { Action } from "march-hare";
5
+
6
+ export type Model = {
7
+ ready: boolean;
8
+ };
9
+
10
+ export class Actions {
11
+ static Ready = Action("<%= pascalName %>.Ready");
12
+ }
@@ -0,0 +1,10 @@
1
+ ---
2
+ to: src/features/<%= feature %>/actions.ts
3
+ inject: true
4
+ before: ^\s*return actions;
5
+ skip_if: Actions\.<%= pascalName %>
6
+ ---
7
+ actions.useAction(Actions.<%= pascalName %>, (_context) => {
8
+ // TODO: handle <%= pascalName %>
9
+ });
10
+
@@ -0,0 +1,7 @@
1
+ ---
2
+ to: src/features/<%= feature %>/types.ts
3
+ inject: true
4
+ after: ^export class Actions \{$
5
+ skip_if: <%= pascalName %>
6
+ ---
7
+ static <%= pascalName %> = Action("<%= rawName %>");
@@ -0,0 +1,7 @@
1
+ ---
2
+ to: src/features/<%= feature %>/types.ts
3
+ inject: true
4
+ after: ^export class Multicast \{$
5
+ skip_if: <%= pascalName %>
6
+ ---
7
+ static <%= pascalName %> = Action<unknown>("<%= rawName %>", Distribution.Multicast);
@@ -0,0 +1,14 @@
1
+ ---
2
+ to: src/features/<%= name %>/index.tsx
3
+ ---
4
+ import * as React from "react";
5
+ import type { Props } from "./types.ts";
6
+ import { scope } from "./utils.ts";
7
+
8
+ export function <%= pascalName %>({ label }: Props): React.ReactElement {
9
+ return (
10
+ <scope.Boundary>
11
+ <span>{label}</span>
12
+ </scope.Boundary>
13
+ );
14
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ to: src/features/<%= name %>/types.ts
3
+ ---
4
+ import { Action, Distribution } from "march-hare";
5
+
6
+ export type Props = {
7
+ label: string;
8
+ };
9
+
10
+ export class Multicast {
11
+ static Update = Action<string>("<%= pascalName %>.Update", Distribution.Multicast);
12
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ to: src/features/<%= name %>/utils.ts
3
+ ---
4
+ import { shared } from "march-hare";
5
+ import { type Envs } from "@shared/types/index.ts";
6
+ import type { Multicast } from "./types.ts";
7
+
8
+ export const scope = shared.Scope<Envs, typeof Multicast>();
@@ -0,0 +1,16 @@
1
+ ---
2
+ to: src/features/<%= name %>/actions.ts
3
+ ---
4
+ import { Actions, type Model } from "./types.ts";
5
+ import { scope } from "./utils.ts";
6
+
7
+ export function useActions() {
8
+ const context = scope.useContext<Model, typeof Actions>();
9
+ const actions = context.useActions({ count: 0 });
10
+
11
+ actions.useAction(Actions.Tick, (context) => {
12
+ context.actions.produce(({ model }) => void (model.count += 1));
13
+ });
14
+
15
+ return actions;
16
+ }
@@ -0,0 +1,19 @@
1
+ ---
2
+ to: src/features/<%= name %>/index.tsx
3
+ ---
4
+ import * as React from "react";
5
+ import { useActions } from "./actions.ts";
6
+ import { scope } from "./utils.ts";
7
+ import { Actions } from "./types.ts";
8
+
9
+ export function <%= pascalName %>(): React.ReactElement {
10
+ const [model, actions] = useActions();
11
+
12
+ return (
13
+ <scope.Boundary>
14
+ <button onClick={() => actions.dispatch(Actions.Tick)}>
15
+ <%= pascalName %> ({model.count})
16
+ </button>
17
+ </scope.Boundary>
18
+ );
19
+ }
@@ -0,0 +1,16 @@
1
+ ---
2
+ to: src/features/<%= name %>/types.ts
3
+ ---
4
+ import { Action, Distribution } from "march-hare";
5
+
6
+ export type Model = {
7
+ count: number;
8
+ };
9
+
10
+ export class Actions {
11
+ static Tick = Action("<%= pascalName %>.Tick");
12
+ }
13
+
14
+ export class Multicast {
15
+ static Update = Action<number>("<%= pascalName %>.Update", Distribution.Multicast);
16
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ to: src/features/<%= name %>/utils.ts
3
+ ---
4
+ import { shared } from "march-hare";
5
+ import { type Envs } from "@shared/types/index.ts";
6
+ import type { Multicast } from "./types.ts";
7
+
8
+ export const scope = shared.Scope<Envs, typeof Multicast>();
@@ -0,0 +1,21 @@
1
+ ---
2
+ to: src/features/<%= name %>/index.test.tsx
3
+ ---
4
+ import { describe, expect, it } from "vitest";
5
+ import { render } from "@testing-library/react";
6
+ import { App } from "march-hare";
7
+ import { <%= pascalName %> } from "./index.tsx";
8
+ import { type Envs } from "@shared/types/index.ts";
9
+
10
+ const app = App<Envs>({ env: { apiBase: "https://api.example.test" } });
11
+
12
+ describe("<%= pascalName %>", () => {
13
+ it("renders inside an app boundary", () => {
14
+ render(
15
+ <app.Boundary>
16
+ <<%= pascalName %> />
17
+ </app.Boundary>,
18
+ );
19
+ expect(document.body).toBeInTheDocument();
20
+ });
21
+ });
@@ -0,0 +1,48 @@
1
+ ---
2
+ to: README.md
3
+ ---
4
+ # <%= title(name) %>
5
+
6
+ <%= description %>
7
+
8
+ Built with [March Hare](https://github.com/Wildhoney/MarchHare) and the
9
+ [`@march-hare/cli`](https://github.com/Wildhoney/MarchHare/tree/main/src/cli)
10
+ scaffolder.
11
+
12
+ ## Layout
13
+
14
+ ```
15
+ src/
16
+ ├── app/ ← the host. One App() declaration, one Boundary, all routes.
17
+ ├── features/ ← user-facing behaviours. Each feature owns its own scope.
18
+ └── shared/ ← reusable building blocks: types, resources, theme, utils.
19
+ ```
20
+
21
+ Layer boundaries are enforced by `eslint-plugin-boundaries` — imports
22
+ flow strictly downward (`app → features → shared`).
23
+
24
+ ## Scripts
25
+
26
+ | Command | What it does |
27
+ | -------------------- | ----------------------------------------- |
28
+ | `yarn dev` | Start the Vite dev server |
29
+ | `yarn test` | Run unit + integration tests with Vitest |
30
+ | `yarn test:e2e` | Run Playwright end-to-end tests |
31
+ | `yarn typecheck` | TypeScript with no emit |
32
+ | `yarn lint` | ESLint |
33
+ | `yarn checks` | Format, lint, typecheck, and test |
34
+
35
+ ## Scaffolding more
36
+
37
+ With the CLI installed globally (`npm i -g @march-hare/cli`) or linked
38
+ locally, drop new pages, features, and shared modules in:
39
+
40
+ ```bash
41
+ mh # interactive menu
42
+ mh feature new # add a feature
43
+ mh app new # add a page
44
+ mh shared component # add a shared component
45
+ ```
46
+
47
+ See the [CLI README](https://github.com/Wildhoney/MarchHare/tree/main/src/cli)
48
+ for the full list.