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
@@ -1,6 +1,28 @@
1
1
  import { ReactNode } from 'react';
2
- import { Env } from './index';
3
- export type { Env } from './index';
2
+ /**
3
+ * Loose runtime shape for the per-`<Boundary>` Env. Each {@link App}
4
+ * narrows this to its own typed env via `App<E>({ env })`; the
5
+ * loose type exists so the framework's internal plumbing
6
+ * (`<Boundary>`, `useEnv`, handler `context.env`, Resource
7
+ * fetcher `context.env`) does not need to be parametric over E.
8
+ *
9
+ * Consumers should declare their Env shape inline via `App({ env })`
10
+ * &mdash; the inferred `E` is what flows through `app.useContext`,
11
+ * `app.useEnv`, and `app.Resource`. Module augmentation of `Env`
12
+ * is no longer required.
13
+ */
14
+ export type Env = Record<string, unknown>;
15
+ /**
16
+ * `E` generic for `shared.X<E, ...>` factories whose callers don't read
17
+ * anything off the Env. Equivalent to `Record<never, never>` &mdash; the
18
+ * named alias keeps consumer sites legible (`shared.Resource<Envless, T>`
19
+ * over `shared.Resource<Record<never, never>, T>`) and signals intent.
20
+ *
21
+ * Reach for `Envless` only when the component or resource is genuinely
22
+ * Env-agnostic. Anything that reads `context.env.x` should declare the
23
+ * required shape (or a union of host Envs) as `E` instead.
24
+ */
25
+ export type Envless = Record<never, never>;
4
26
  /**
5
27
  * Props for the Env provider component. Accepts the initial Env
6
28
  * value that satisfies the augmented {@link Env} interface.
@@ -1,5 +1,5 @@
1
1
  import { RefObject } from 'react';
2
- import { Env } from './index';
2
+ import { Env } from './types.js';
3
3
  import * as React from "react";
4
4
  /**
5
5
  * React context exposing the per-Boundary Env ref. The ref itself is
@@ -1,2 +1,2 @@
1
- export { Context, useScope, getScope } from './utils';
2
- export type { ScopeEntry, ScopeContext } from './types';
1
+ export { Context, useScope, getScope } from './utils.js';
2
+ export type { ScopeEntry, ScopeContext } from './types.js';
@@ -1,4 +1,4 @@
1
- import { BroadcastEmitter } from '../broadcast/utils';
1
+ import { BroadcastEmitter } from '../broadcast/utils.js';
2
2
  /**
3
3
  * Runtime entry for a single multicast scope opened by an
4
4
  * `<app.Scope().Boundary>`. The `id` uniquely identifies this scope
@@ -1,4 +1,4 @@
1
- import { ScopeContext, ScopeEntry } from './types';
1
+ import { ScopeContext, ScopeEntry } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context for the nearest multicast scope. `null` at the root.
@@ -1,8 +1,8 @@
1
- import { PendingCall } from '../../../resource/index';
1
+ import { Invocation } from '../../../resource/index.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Per-`<Boundary>` registry for `.coalesce(token)` sharing. Outer map
5
- * keys on the `PendingCall.run` function identity (stable per Resource
5
+ * keys on the `Invocation.run` function identity (stable per Resource
6
6
  * via the `build()` closure); inner map keys on
7
7
  * `${paramsKey}|${coalesceKey(token)}`. While an entry exists every
8
8
  * caller awaiting `.coalesce(token)` for the same Resource + params +
@@ -14,7 +14,7 @@ import * as React from "react";
14
14
  *
15
15
  * @internal
16
16
  */
17
- export type Sharing = WeakMap<PendingCall["run"], Map<string, Promise<unknown>>>;
17
+ export type Sharing = WeakMap<Invocation<unknown, object>["run"], Map<string, Promise<unknown>>>;
18
18
  /**
19
19
  * React context exposing the per-Boundary sharing registry. The
20
20
  * fallback is a fresh `WeakMap` used when `useSharing()` is read
@@ -1,7 +1,7 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export { useTap } from './utils';
4
- export type { Tap, Taps, Invocation, Failure, Mutations, Snapshot, } from './types';
3
+ export { useTap } from './utils.js';
4
+ export type { Tap, Taps, Invocation, Failure, Mutations, Snapshot, } from './types.js';
5
5
  /**
6
6
  * Internal provider that wires a {@link Tap} observer into the React
7
7
  * context consumed by `useActions` during dispatch. Rendered by the
@@ -1,5 +1,5 @@
1
- import { Reason } from '../../../error/types';
2
- import { Task } from '../tasks/types';
1
+ import { Reason } from '../../../error/types.js';
2
+ import { Task } from '../tasks/types.js';
3
3
  import type * as React from "react";
4
4
  /**
5
5
  * Identity of a handler invocation: the action being handled and the
@@ -1,4 +1,4 @@
1
- import { Tap } from './types';
1
+ import { Tap } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context carrying the active {@link Tap} observer for the
@@ -1,6 +1,6 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
- export type { Task } from './types';
3
+ export type { Task } from './types.js';
4
4
  /**
5
5
  * Creates a new tasks context for action control. Only needed if you
6
6
  * want to isolate a tasks context, useful for libraries that want to provide
@@ -1,4 +1,4 @@
1
- import { Tasks } from './types';
1
+ import { Tasks } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * React context for the shared tasks Set.
@@ -1,13 +1,13 @@
1
- import { Props } from './types';
1
+ import { Props } from './types.js';
2
2
  import * as React from "react";
3
3
  /**
4
4
  * Low-level boundary primitive. Wraps children with the Broadcaster,
5
5
  * Env, and Tasks providers required by every March Hare hook.
6
6
  *
7
7
  * Most applications should reach for {@link App} instead &mdash;
8
- * `App<S>({ env })` returns a typed `app.Boundary` along with
8
+ * `App<E>({ env })` returns a typed `app.Boundary` along with
9
9
  * matching `useContext` / `useEnv` / `Resource` factories that all
10
- * close over the App's inferred env shape `S`. The bare `Boundary`
10
+ * close over the App's inferred env shape `E`. The bare `Boundary`
11
11
  * is exposed for advanced or library-internal use where the loose
12
12
  * Env record type is sufficient.
13
13
  *
@@ -1,5 +1,5 @@
1
- import { Env } from './components/env/types';
2
- import { Tap } from './components/tap/types';
1
+ import { Env } from './components/env/types.js';
2
+ import { Tap } from './components/tap/types.js';
3
3
  import type * as React from "react";
4
4
  /**
5
5
  * Props accepted by the bare `<Boundary>` provider.
@@ -18,7 +18,7 @@ import type * as React from "react";
18
18
  export type Props = {
19
19
  /**
20
20
  * Initial value of the per-Boundary {@link Env}. Prefer `App({ env })`
21
- * &mdash; it infers the Env shape `S` and threads it through
21
+ * &mdash; it infers the Env shape `E` and threads it through
22
22
  * `app.useContext`, `app.useEnv`, and `app.Resource`, so handler
23
23
  * `context.env` is typed accordingly. Pass `env` directly here only
24
24
  * for advanced cases where the loose record type is sufficient.
@@ -1,19 +1,29 @@
1
- import { Adapter, Stored } from './types';
2
- export type { Adapter, Encoded } from './types';
1
+ import { Adapter, Stored } from './types.js';
2
+ export type { Adapter, Encoded } from './types.js';
3
3
  /**
4
4
  * Persistence-aware cache for a single {@link Resource}. Wraps a
5
- * synchronous {@link Adapter} (localStorage, MMKV, chrome.storage with a
6
- * sync facade, etc.) and traffics in {@link Stored} envelopes &mdash;
7
- * storage entries serialise as {@link Encoded}`<T>` so the
8
- * `Temporal.Instant` timestamp survives the string round-trip and
9
- * `.exceeds({...})` can short-circuit on the persisted timestamp after
10
- * a reload.
5
+ * **strictly synchronous** {@link Adapter} (localStorage, MMKV,
6
+ * chrome.storage with a sync facade, etc.) and traffics in {@link
7
+ * Stored} envelopes &mdash; storage entries serialise as {@link
8
+ * Encoded}`<T>` so the `Temporal.Instant` timestamp survives the
9
+ * string round-trip and `.exceeds({...})` can short-circuit on the
10
+ * persisted timestamp after a reload.
11
+ *
12
+ * Every method on the Cache is sync &mdash; the model-literal sync
13
+ * read has no place to wait, so the adapter contract foregoes
14
+ * `Promise` entirely. Async backends (IndexedDB, AsyncStorage,
15
+ * `chrome.storage.local`) need a sync facade hydrated at app entry;
16
+ * see `recipes/storage.md` for the pattern. React Native projects
17
+ * should reach for {@link https://github.com/mrousavy/react-native-mmkv
18
+ * `react-native-mmkv`} &mdash; it's synchronous out of the box and
19
+ * drops straight into the Adapter contract.
11
20
  *
12
21
  * Call with no arguments for an in-memory cache scoped to this
13
- * instance &mdash; useful for tests, ephemeral state, or when you want a
14
- * first-class cache object to share between Resources without
22
+ * instance &mdash; useful for tests, ephemeral state, or when you
23
+ * want a first-class cache object to share between Resources without
15
24
  * persistence. Pass an {@link Adapter} to back the cache with a
16
- * persistent store.
25
+ * persistent store; when supplied, the adapter is the **only** tier
26
+ * &mdash; the Cache does not maintain a separate in-memory mirror.
17
27
  *
18
28
  * @example
19
29
  * ```ts
@@ -36,9 +46,55 @@ export type { Adapter, Encoded } from './types';
36
46
  * ```
37
47
  */
38
48
  export type Cache = {
49
+ /**
50
+ * Returns the {@link Stored} envelope for `key`. The envelope is
51
+ * `empty()` when nothing is persisted; otherwise it carries the
52
+ * decoded payload and the timestamp recorded at write-time.
53
+ *
54
+ * @template T The payload type expected at `key`.
55
+ * @param key Cache slot identifier &mdash; usually the JSON-stringified
56
+ * call-site params, prefixed by the Resource's namespace.
57
+ */
39
58
  get<T>(key: string): Stored<T>;
40
- set<T>(key: string, value: Stored<T>): boolean;
59
+ /**
60
+ * Writes `value` to `key`. Skipped when the envelope has no concrete
61
+ * payload (e.g. an `empty()` slot), since there is nothing meaningful
62
+ * to persist. Serialisation, quota errors, and unserialisable payloads
63
+ * are swallowed &mdash; writes are best-effort.
64
+ *
65
+ * @template T The payload type contained in `value`.
66
+ * @param key Cache slot identifier &mdash; usually the JSON-stringified
67
+ * call-site params, prefixed by the Resource's namespace.
68
+ * @param value Stored envelope carrying the payload and its
69
+ * write-time `Temporal.Instant`.
70
+ */
71
+ set<T>(key: string, value: Stored<T>): void;
72
+ /**
73
+ * Drops a single cache slot. Best-effort &mdash; backing-store errors
74
+ * are swallowed.
75
+ *
76
+ * @param key Cache slot identifier.
77
+ */
41
78
  remove(key: string): void;
79
+ /**
80
+ * Drops every cache slot in the backing store. Best-effort &mdash;
81
+ * backing-store errors are swallowed.
82
+ */
42
83
  clear(): void;
84
+ /**
85
+ * Returns every key currently held by the backing store. Used by
86
+ * partial-match eviction (`evict(where)`) to iterate slots whose
87
+ * stored params satisfy a `where` pattern.
88
+ */
89
+ keys(): Iterable<string>;
43
90
  };
91
+ /**
92
+ * Constructs a {@link Cache} backed by `adapter`, or by an in-memory
93
+ * `Map` when none is supplied. The returned object is the same shape
94
+ * regardless &mdash; only the durability differs.
95
+ *
96
+ * @param adapter Optional synchronous backing store (localStorage, MMKV,
97
+ * or a custom sync facade). Omit for an in-memory cache scoped to
98
+ * this instance.
99
+ */
44
100
  export declare function Cache(adapter?: Adapter): Cache;
@@ -1,4 +1,4 @@
1
- export type { Stored } from '../utils/types';
1
+ export type { Stored } from '../utils/types.js';
2
2
  /**
3
3
  * On-disk JSON shape of a `Stored` envelope. The Cache wrapper
4
4
  * encodes a populated Stored as `{ data, at: at.toString() }` so the
@@ -18,37 +18,51 @@ export type Encoded<T> = {
18
18
  * facade, etc.) and pass to {@link Cache}. The adapter shuttles raw
19
19
  * strings; JSON encoding and `Temporal.Instant` round-tripping happen
20
20
  * inside the Cache wrapper, so adapters stay trivial.
21
+ *
22
+ * **Every method is strictly synchronous.** The library never awaits
23
+ * adapter calls &mdash; the model-literal sync read has no place to
24
+ * wait. Async backends (IndexedDB, AsyncStorage, chrome.storage.local)
25
+ * need a sync facade hydrated at app entry; see `recipes/storage.md`
26
+ * for the pattern. React Native projects should use
27
+ * {@link https://github.com/mrousavy/react-native-mmkv `react-native-mmkv`}
28
+ * &mdash; it's sync out of the box and drops straight into this
29
+ * contract.
21
30
  */
22
31
  export type Adapter = {
23
32
  /**
24
33
  * Return the raw string stored under `key`, or `null` when no entry
25
- * exists. The Cache wrapper handles JSON parsing and `Temporal.Instant`
26
- * round-tripping, so this stays a plain string getter. Treat any
27
- * read-time error (decryption, IPC, etc.) as "not found" and return
28
- * `null` &mdash; the Cache falls through to its next fallback rather
29
- * than crashing the render.
34
+ * exists. **Strictly sync.** Treat any read-time error (decryption,
35
+ * IPC, etc.) as "not found" and return `null` &mdash; the Cache will
36
+ * fall back to its empty state.
30
37
  */
31
38
  readonly get: (key: string) => string | null;
32
39
  /**
33
- * Persist the raw string `value` under `key`. The Cache guarantees
34
- * `value` is a JSON-encoded `{ data, at }` envelope produced by a
35
- * resolved snapshot &mdash; never a placeholder. Throwing is fine on
36
- * quota, private mode, sandboxed iframes, etc.; the Cache catches and
37
- * swallows so a write failure can't poison an already-resolved fetch.
40
+ * Persist the raw string `value` under `key`. **Strictly sync.**
41
+ * Throwing is fine on quota, private mode, sandboxed iframes, etc.;
42
+ * the Cache catches and swallows so a failure can't poison an
43
+ * already-resolved fetch.
38
44
  */
39
45
  readonly set: (key: string, value: string) => void;
40
46
  /**
41
- * Drop the entry at `key`. Idempotent &mdash; calling `remove` for a
42
- * key that isn't present must not throw.
47
+ * Drop the entry at `key`. **Strictly sync.** Idempotent &mdash;
48
+ * calling `remove` for a key that isn't present must not throw.
43
49
  */
44
50
  readonly remove: (key: string) => void;
45
51
  /**
46
- * Wipe every entry this adapter can see. On a shared backend such as
47
- * `localStorage` this means the whole origin &mdash; third-party SDK
48
- * state, dismissed banners, route hints, etc. all go with it. Adapter
49
- * authors should either delegate to the backend's native clear
50
- * (accepting that scope) or namespace by key prefix and remove only
51
- * their own.
52
+ * Wipe every entry this adapter can see. **Strictly sync.** On a
53
+ * shared backend such as `localStorage` this means the whole origin
54
+ * &mdash; third-party SDK state, dismissed banners, route hints, etc.
55
+ * all go with it. Adapter authors should either delegate to the
56
+ * backend's native clear (accepting that scope) or namespace by key
57
+ * prefix and remove only their own.
52
58
  */
53
59
  readonly clear: () => void;
60
+ /**
61
+ * Optional enumerator over every key the adapter currently knows
62
+ * about. **Strictly sync** when implemented &mdash; partial-match
63
+ * evictions sweep these keys in the current tick. `localStorage`
64
+ * exposes this via `Object.keys(localStorage)`; MMKV via
65
+ * `getAllKeys()`.
66
+ */
67
+ readonly keys?: () => Iterable<string>;
54
68
  };
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import process from "node:process";
3
+ import { main } from "../lib/index.js";
4
+ main(process.argv.slice(2)).catch((error) => {
5
+ if (error instanceof Error && error.name === "ExitPromptError") {
6
+ process.exit(130);
7
+ }
8
+ console.error(error);
9
+ process.exit(1);
10
+ });
@@ -0,0 +1,14 @@
1
+ import figlet from "figlet";
2
+ import kleur from "kleur";
3
+ import { config } from "../utils.js";
4
+ export function banner() {
5
+ const art = figlet.textSync(config.banner.title, {
6
+ font: config.banner.font,
7
+ horizontalLayout: "default",
8
+ verticalLayout: "default",
9
+ });
10
+ console.log(kleur.magenta(art));
11
+ console.log(kleur.gray(` ${config.banner.tagline} `) +
12
+ kleur.dim(config.banner.subtitle));
13
+ console.log();
14
+ }
@@ -0,0 +1,37 @@
1
+ import path from "node:path";
2
+ import kleur from "kleur";
3
+ import { pascalCase, capitalCase } from "change-case";
4
+ import { scaffold } from "../../runner/index.js";
5
+ import { askName, askDescription, pickDirectory, requireProjectRoot, } from "../../prompt/index.js";
6
+ export async function newPage({ positional, flags, }) {
7
+ const root = requireProjectRoot();
8
+ const name = positional[0] || (await askName("Page name (kebab-case)"));
9
+ const heading = (typeof flags.heading === "string" ? flags.heading : undefined) ||
10
+ capitalCase(name);
11
+ const tagline = (typeof flags.tagline === "string" ? flags.tagline : undefined) ||
12
+ (await askDescription("Page tagline", `Welcome to ${heading}`));
13
+ await scaffold("app", "page", { name, heading, tagline, pascalName: pascalCase(name) }, { cwd: root });
14
+ console.log(kleur.green("\n Page ready."), kleur.dim(`Wire it up in src/app/index.tsx with <${pascalCase(name)}Page />.`));
15
+ }
16
+ export async function integration({ positional }) {
17
+ const root = requireProjectRoot();
18
+ const pagesRoot = path.join(root, "src", "app", "pages");
19
+ const name = positional[0] || (await pickDirectory("page", pagesRoot));
20
+ await scaffold("app", "integration", { name, pascalName: pascalCase(name) }, { cwd: root });
21
+ console.log(kleur.green("\n Integration test added."), kleur.dim(`Run with \`make integration\` or \`npx playwright test\`.`));
22
+ }
23
+ export async function action({ positional, flags, }) {
24
+ const root = requireProjectRoot();
25
+ const pagesRoot = path.join(root, "src", "app", "pages");
26
+ const page = positional[0] || (await pickDirectory("page", pagesRoot));
27
+ const name = positional[1] ||
28
+ (typeof flags.name === "string" ? flags.name : undefined) ||
29
+ (await askName("Action name (PascalCase)"));
30
+ await scaffold("app", "action", {
31
+ page,
32
+ name: pascalCase(name),
33
+ pascalName: pascalCase(name),
34
+ rawName: name,
35
+ }, { cwd: root });
36
+ console.log(kleur.green("\n Action added."), kleur.dim(`Dispatch with actions.dispatch(Actions.${pascalCase(name)}).`));
37
+ }
@@ -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
+ }