@canonical/summon-application 0.29.0-experimental.0
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 +264 -0
- package/package.json +50 -0
- package/src/application/react/index.ts +294 -0
- package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
- package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
- package/src/application/react/templates/.storybook/main.ts +5 -0
- package/src/application/react/templates/.storybook/preview.ts +10 -0
- package/src/application/react/templates/README.md.ejs +82 -0
- package/src/application/react/templates/biome.json.ejs +6 -0
- package/src/application/react/templates/index.html.ejs +14 -0
- package/src/application/react/templates/package.json.ejs +72 -0
- package/src/application/react/templates/public/.gitkeep +0 -0
- package/src/application/react/templates/public/robots.txt +2 -0
- package/src/application/react/templates/src/assets/.gitkeep +0 -0
- package/src/application/react/templates/src/client/entry.tsx +25 -0
- package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
- package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
- package/src/application/react/templates/src/domains/account/routes.ts +44 -0
- package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
- package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
- package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
- package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
- package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
- package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
- package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
- package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
- package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
- package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
- package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
- package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
- package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
- package/src/application/react/templates/src/lib/index.ts +4 -0
- package/src/application/react/templates/src/routes.tsx.ejs +129 -0
- package/src/application/react/templates/src/server/entry.tsx +45 -0
- package/src/application/react/templates/src/server/preview.bun.ts +79 -0
- package/src/application/react/templates/src/server/preview.express.ts +69 -0
- package/src/application/react/templates/src/server/renderer.tsx +50 -0
- package/src/application/react/templates/src/server/server.bun.ts +105 -0
- package/src/application/react/templates/src/server/server.express.ts +102 -0
- package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
- package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
- package/src/application/react/templates/src/styles/app.css +16 -0
- package/src/application/react/templates/src/styles/index.css.ejs +5 -0
- package/src/application/react/templates/src/vite-env.d.ts +1 -0
- package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
- package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
- package/src/application/react/templates/tsconfig.json +32 -0
- package/src/application/react/templates/vite.config.ts +45 -0
- package/src/application/react/templates/vitest.config.ts +31 -0
- package/src/application/react/templates/vitest.e2e.config.ts +17 -0
- package/src/application/react/templates/vitest.setup.ts +9 -0
- package/src/domain/index.ts +119 -0
- package/src/index.test.ts +398 -0
- package/src/index.ts +14 -0
- package/src/route/index.ts +154 -0
- package/src/route/insertRoute.test.ts +98 -0
- package/src/route/insertRoute.ts +236 -0
- package/src/shared/casing.ts +14 -0
- package/src/shared/versions.ts +48 -0
- package/src/wrapper/index.ts +100 -0
package/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# @canonical/summon-application
|
|
2
|
+
|
|
3
|
+
Summon generators for scaffolding application structure: full applications, domains, routes, and wrappers. Produces code aligned with the [boilerplate reference app](../../../apps/react/boilerplate-vite/).
|
|
4
|
+
|
|
5
|
+
## Generators
|
|
6
|
+
|
|
7
|
+
### `summon application/react <name>`
|
|
8
|
+
|
|
9
|
+
Scaffolds a complete React application with SSR, routing, Storybook, and two starter domains.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
summon application/react my-app
|
|
13
|
+
summon application/react --forms my-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Produces:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
my-app/
|
|
20
|
+
├── .storybook/
|
|
21
|
+
│ ├── main.ts
|
|
22
|
+
│ ├── preview.ts
|
|
23
|
+
│ └── decorators/
|
|
24
|
+
│ ├── withRouter.tsx # Hash-based router decorator
|
|
25
|
+
│ └── index.ts
|
|
26
|
+
├── src/
|
|
27
|
+
│ ├── client/entry.tsx # Client hydration
|
|
28
|
+
│ ├── server/
|
|
29
|
+
│ │ ├── entry.tsx # SSR render
|
|
30
|
+
│ │ ├── server.express.ts # Express dev server
|
|
31
|
+
│ │ ├── server.bun.ts # Bun dev server
|
|
32
|
+
│ │ └── sitemap.ts
|
|
33
|
+
│ ├── domains/
|
|
34
|
+
│ │ ├── marketing/ # HomePage, GuidePage, routes
|
|
35
|
+
│ │ ├── account/ # AccountPage, LoginPage, routes
|
|
36
|
+
│ │ └── contact/ # ContactPage, routes (--forms only)
|
|
37
|
+
│ ├── lib/
|
|
38
|
+
│ │ ├── Navigation/
|
|
39
|
+
│ │ ├── ThemeSelector/
|
|
40
|
+
│ │ ├── ExampleComponent/
|
|
41
|
+
│ │ └── LazyComponent/
|
|
42
|
+
│ ├── styles/
|
|
43
|
+
│ ├── routes.tsx
|
|
44
|
+
│ └── vite-env.d.ts
|
|
45
|
+
├── biome.json
|
|
46
|
+
├── index.html
|
|
47
|
+
├── package.json
|
|
48
|
+
├── tsconfig.json
|
|
49
|
+
└── vite.config.ts
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `--forms` flag adds the contact domain with form components and wires `contactRoutes` into `routes.tsx`.
|
|
53
|
+
|
|
54
|
+
### `summon domain <name>`
|
|
55
|
+
|
|
56
|
+
Creates a domain folder under `src/domains/` with a `MainPage` and a `routes.ts` barrel.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
summon domain billing
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Produces:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
src/domains/billing/
|
|
66
|
+
├── MainPage.tsx # Page component with useHead()
|
|
67
|
+
└── routes.ts # Route barrel with example route entry
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
After generating, import the domain routes in `src/routes.tsx` and wire them with `group()`:
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import billingRoutes from "#domains/billing/routes.js";
|
|
74
|
+
|
|
75
|
+
const [billing] = group(publicLayout, [billingRoutes.billing] as const);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### `summon route <domain>/<name>`
|
|
79
|
+
|
|
80
|
+
Adds a page component to an existing domain and appends the import to its `routes.ts`.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
summon route billing/invoices
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Produces:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
src/domains/billing/
|
|
90
|
+
├── InvoicesPage.tsx # New page component
|
|
91
|
+
└── routes.ts # Import + TODO comment appended
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The generator appends the import and a comment with the route entry. Add the route to the `routes` object manually:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
invoices: route({ url: "/billing/invoices", content: InvoicesPage }),
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Create the domain first with `summon domain <name>`.
|
|
101
|
+
|
|
102
|
+
### `summon wrapper <name>`
|
|
103
|
+
|
|
104
|
+
Creates a layout wrapper component under `src/lib/`.
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
summon wrapper sidebar
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Produces:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
src/lib/SidebarLayout/
|
|
114
|
+
├── SidebarLayout.tsx # Layout component with children prop
|
|
115
|
+
└── index.ts # Barrel export
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Use the layout component in a `wrapper()` call in `routes.tsx`:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import SidebarLayout from "#lib/SidebarLayout/index.js";
|
|
122
|
+
|
|
123
|
+
const sidebarWrapper = wrapper({
|
|
124
|
+
id: "sidebar",
|
|
125
|
+
component: ({ children }) => <SidebarLayout>{children}</SidebarLayout>,
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Conventions
|
|
130
|
+
|
|
131
|
+
The generators enforce the conventions established by the boilerplate:
|
|
132
|
+
|
|
133
|
+
### File structure
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
src/
|
|
137
|
+
├── client/ # Client entry point (hydration)
|
|
138
|
+
├── server/ # Server entry points (Express, Bun)
|
|
139
|
+
├── domains/ # Feature domains
|
|
140
|
+
│ ├── marketing/
|
|
141
|
+
│ │ ├── HomePage.tsx
|
|
142
|
+
│ │ ├── GuidePage.tsx
|
|
143
|
+
│ │ └── routes.ts
|
|
144
|
+
│ ├── account/
|
|
145
|
+
│ │ ├── AccountPage.tsx
|
|
146
|
+
│ │ ├── LoginPage.tsx
|
|
147
|
+
│ │ └── routes.ts
|
|
148
|
+
│ └── contact/ # When --forms is enabled
|
|
149
|
+
│ ├── ContactPage.tsx
|
|
150
|
+
│ └── routes.ts
|
|
151
|
+
├── lib/ # Shared components
|
|
152
|
+
│ ├── Navigation/
|
|
153
|
+
│ └── SidebarLayout/
|
|
154
|
+
├── styles/ # CSS
|
|
155
|
+
└── routes.tsx # Root route map, middleware, type registration
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Naming
|
|
159
|
+
|
|
160
|
+
| Concept | Pattern | Example |
|
|
161
|
+
|---------|---------|---------|
|
|
162
|
+
| Page component | `{Name}Page.tsx` | `SettingsPage.tsx` |
|
|
163
|
+
| Layout component | `{Name}Layout.tsx` | `SidebarLayout.tsx` |
|
|
164
|
+
| Domain routes | `routes.ts` (not `.tsx`) | `src/domains/billing/routes.ts` |
|
|
165
|
+
| Root routes | `routes.tsx` | `src/routes.tsx` |
|
|
166
|
+
| Component folders | PascalCase | `src/lib/SidebarLayout/` |
|
|
167
|
+
|
|
168
|
+
### Route definitions
|
|
169
|
+
|
|
170
|
+
Routes are flat objects using `route()` from `@canonical/router-core`:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const routes = {
|
|
174
|
+
invoices: route({
|
|
175
|
+
url: "/billing/invoices",
|
|
176
|
+
content: InvoicesPage,
|
|
177
|
+
}),
|
|
178
|
+
} as const;
|
|
179
|
+
|
|
180
|
+
export default routes;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
- `content` receives the component directly (not a wrapper function)
|
|
184
|
+
- Pages use `useHead()` from `@canonical/react-head` for title and meta
|
|
185
|
+
- No `fetch()` / `prefetch()` unless needed for cache warming
|
|
186
|
+
- No `.error` — use React error boundaries
|
|
187
|
+
- Route files are `.ts` (no JSX in route definitions)
|
|
188
|
+
|
|
189
|
+
### Storybook decorators
|
|
190
|
+
|
|
191
|
+
The generated application includes a `withRouter` decorator for stories that need a router context:
|
|
192
|
+
|
|
193
|
+
```tsx
|
|
194
|
+
import withRouter from "../decorators/withRouter.js";
|
|
195
|
+
|
|
196
|
+
const meta = {
|
|
197
|
+
decorators: [withRouter()],
|
|
198
|
+
} satisfies Meta;
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Pass custom routes when the story depends on specific route shapes:
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
import { appRoutes } from "../../routes.js";
|
|
205
|
+
|
|
206
|
+
const meta = {
|
|
207
|
+
decorators: [withRouter({ routes: appRoutes })],
|
|
208
|
+
} satisfies Meta;
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The decorator uses `createHashRouter` from `@canonical/router-core`, which stores routes in `window.location.hash` — suitable for Storybook and static environments where no server handles URL paths.
|
|
212
|
+
|
|
213
|
+
### Wrappers
|
|
214
|
+
|
|
215
|
+
Wrapper components are standard React components with a `children` prop. They become routable wrappers via `wrapper()` + `group()` in `routes.tsx`:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
const layout = wrapper({
|
|
219
|
+
id: "sidebar",
|
|
220
|
+
component: ({ children }) => <SidebarLayout>{children}</SidebarLayout>,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const [invoices, payments] = group(layout, [
|
|
224
|
+
billingRoutes.invoices,
|
|
225
|
+
billingRoutes.payments,
|
|
226
|
+
] as const);
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Type registration
|
|
230
|
+
|
|
231
|
+
Register routes once in `routes.tsx` for global type inference:
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
declare module "@canonical/router-react" {
|
|
235
|
+
interface RouterRegister {
|
|
236
|
+
routes: typeof appRoutes;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
This enables typed `<Link to="invoices">`, `router.navigate("invoices")`, and `router.buildPath("invoices")`.
|
|
242
|
+
|
|
243
|
+
## Open questions
|
|
244
|
+
|
|
245
|
+
Design decisions about the generated output that are not yet settled:
|
|
246
|
+
|
|
247
|
+
- **Pinned pragma version is a hand-maintained constant.** Generated apps pin the pragma
|
|
248
|
+
workspace packages (`react-ds-global`, `router-core`, `styles`, …) at the range in
|
|
249
|
+
`PRAGMA_WORKSPACE_VERSION` (`src/shared/versions.ts`), which must be bumped in lockstep
|
|
250
|
+
with each lerna release. It is a literal constant, not read from a package.json at runtime,
|
|
251
|
+
because the generator ships as a compiled binary where such a read resolves to `"unknown"`.
|
|
252
|
+
**Open:** inject the version at binary-build time (a build-step `define`/codegen) so it
|
|
253
|
+
cannot drift from the release — deferred until the compile pipeline supports it, to avoid
|
|
254
|
+
adding build tooling prematurely. Note `@canonical/design-tokens` is versioned separately
|
|
255
|
+
and is *not* covered by this constant (it arrives transitively via `@canonical/styles`).
|
|
256
|
+
|
|
257
|
+
- **Two static-asset folders (`src/assets/` + `public/`).** The generated app ships both,
|
|
258
|
+
and `.storybook/main.ts` lists `staticDirs: ["../src/assets", "../public"]` to match the
|
|
259
|
+
`@canonical/react-ds-global` convention. The two serve different roles under Vite —
|
|
260
|
+
`src/assets/` for assets *imported in code* (hashed/optimized, dropped if unused), `public/`
|
|
261
|
+
for files referenced by fixed URL (favicon, `robots.txt`, served as-is, unhashed). **Open:**
|
|
262
|
+
whether a scaffolded app genuinely needs both by default, or whether one (likely just
|
|
263
|
+
`public/` for a favicon) is enough and `src/assets/` should be added only when the app
|
|
264
|
+
actually imports an asset. Currently both ship with `.gitkeep` placeholders.
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canonical/summon-application",
|
|
3
|
+
"description": "Summon generators for application scaffolding: domain, route, and wrapper",
|
|
4
|
+
"version": "0.29.0-experimental.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"module": "src/index.ts",
|
|
8
|
+
"types": "src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"import": "./src/index.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"author": {
|
|
19
|
+
"email": "webteam@canonical.com",
|
|
20
|
+
"name": "Canonical Webteam"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/canonical/pragma"
|
|
25
|
+
},
|
|
26
|
+
"license": "GPL-3.0",
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "echo 'No build needed - runs directly from TypeScript'",
|
|
29
|
+
"check": "bun run check:biome && bun run check:ts && bun run check:webarchitect",
|
|
30
|
+
"check:webarchitect": "webarchitect tool-ts",
|
|
31
|
+
"check:fix": "bun run check:biome:fix && bun run check:ts",
|
|
32
|
+
"check:biome": "biome check",
|
|
33
|
+
"check:biome:fix": "biome check --write",
|
|
34
|
+
"check:ts": "tsc --noEmit",
|
|
35
|
+
"test": "vitest run"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@canonical/summon-core": "^0.29.0-experimental.0",
|
|
39
|
+
"@canonical/task": "^0.29.0-experimental.0",
|
|
40
|
+
"@canonical/utils": "^0.29.0-experimental.0",
|
|
41
|
+
"typescript": "^5.9.3"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "2.4.9",
|
|
45
|
+
"@canonical/biome-config": "^0.28.0",
|
|
46
|
+
"@canonical/webarchitect": "^0.29.0-experimental.0",
|
|
47
|
+
"@types/node": "^24.12.0",
|
|
48
|
+
"vitest": "^4.0.18"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import type {
|
|
4
|
+
GeneratorDefinition,
|
|
5
|
+
PromptDefinition,
|
|
6
|
+
} from "@canonical/summon-core";
|
|
7
|
+
import { template, withHelpers } from "@canonical/summon-core";
|
|
8
|
+
import {
|
|
9
|
+
copyFile,
|
|
10
|
+
exec,
|
|
11
|
+
exists,
|
|
12
|
+
flatMap,
|
|
13
|
+
info,
|
|
14
|
+
sequence_,
|
|
15
|
+
warn,
|
|
16
|
+
when,
|
|
17
|
+
} from "@canonical/task";
|
|
18
|
+
import { PRAGMA_WORKSPACE_VERSION } from "../../shared/versions.js";
|
|
19
|
+
|
|
20
|
+
interface ApplicationReactAnswers {
|
|
21
|
+
readonly appPath: string;
|
|
22
|
+
readonly ssr: boolean;
|
|
23
|
+
readonly router: boolean;
|
|
24
|
+
readonly forms: boolean;
|
|
25
|
+
readonly runInstall: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const prompts: PromptDefinition[] = [
|
|
29
|
+
{
|
|
30
|
+
name: "appPath",
|
|
31
|
+
type: "text",
|
|
32
|
+
message: "Application directory name:",
|
|
33
|
+
default: "my-app",
|
|
34
|
+
positional: true,
|
|
35
|
+
group: "Application",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "ssr",
|
|
39
|
+
type: "confirm",
|
|
40
|
+
message: "Include SSR?",
|
|
41
|
+
default: true,
|
|
42
|
+
group: "Application",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "router",
|
|
46
|
+
type: "confirm",
|
|
47
|
+
message: "Include router?",
|
|
48
|
+
default: true,
|
|
49
|
+
group: "Application",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "forms",
|
|
53
|
+
type: "confirm",
|
|
54
|
+
message: "Include form components?",
|
|
55
|
+
default: true,
|
|
56
|
+
group: "Application",
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "runInstall",
|
|
60
|
+
type: "confirm",
|
|
61
|
+
message: "Run bun install?",
|
|
62
|
+
default: true,
|
|
63
|
+
group: "Application",
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
68
|
+
const templatesDir = path.join(__dirname, "templates");
|
|
69
|
+
|
|
70
|
+
/** Resolve a path inside the templates directory. */
|
|
71
|
+
const src = (templatePath: string) => path.join(templatesDir, templatePath);
|
|
72
|
+
|
|
73
|
+
export const generator: GeneratorDefinition<ApplicationReactAnswers> = {
|
|
74
|
+
meta: {
|
|
75
|
+
name: "application/react",
|
|
76
|
+
displayName: "@canonical/summon-application:application/react",
|
|
77
|
+
description: "Scaffold a complete React application with SSR and routing",
|
|
78
|
+
version: "0.1.0",
|
|
79
|
+
help: `Creates a full React application with:
|
|
80
|
+
- Vite build + dev server
|
|
81
|
+
- Server-side rendering (Express + Bun dev servers)
|
|
82
|
+
- Routing with @canonical/router-core
|
|
83
|
+
- Head management with @canonical/react-head
|
|
84
|
+
- Two domains (marketing + account) with pages
|
|
85
|
+
- Contact domain with form components (when --forms is enabled)
|
|
86
|
+
- Navigation, ThemeSelector, ExampleComponent
|
|
87
|
+
- Storybook with router decorator
|
|
88
|
+
- Biome + TypeScript configuration
|
|
89
|
+
|
|
90
|
+
Requires both --ssr and --router flags.`,
|
|
91
|
+
examples: [
|
|
92
|
+
"summon application/react my-app",
|
|
93
|
+
"summon application/react --forms my-app",
|
|
94
|
+
"summon application/react --ssr --router --forms my-app",
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
prompts,
|
|
99
|
+
|
|
100
|
+
generate: (answers) => {
|
|
101
|
+
if (!answers.ssr || !answers.router) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
"The application/react generator requires both --ssr and --router. " +
|
|
104
|
+
"Standalone SPA mode is not supported.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// The app path is a directory path, not a route path — keep it as given
|
|
109
|
+
// (absolute or relative), only trimming surrounding whitespace and any
|
|
110
|
+
// trailing slash.
|
|
111
|
+
const appPath =
|
|
112
|
+
(answers.appPath || "my-app").trim().replace(/\/+$/, "") || "my-app";
|
|
113
|
+
|
|
114
|
+
// The package name is the final path segment. For "." / "" / "/" (scaffold
|
|
115
|
+
// into the current dir) basename gives "."/"" — resolve against the real
|
|
116
|
+
// directory so the name is the actual folder name. Then slugify to an
|
|
117
|
+
// npm-safe form (lowercase, safe chars).
|
|
118
|
+
const rawName = path.basename(path.resolve(appPath));
|
|
119
|
+
const name = rawName
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
122
|
+
.replace(/^[._-]+|[._-]+$/g, "");
|
|
123
|
+
|
|
124
|
+
if (!name) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Could not derive a valid application name from path "${appPath}". ` +
|
|
127
|
+
"Pass an explicit directory name (lowercase letters/digits).",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const vars = withHelpers({
|
|
132
|
+
name,
|
|
133
|
+
forms: answers.forms,
|
|
134
|
+
pragmaVersion: PRAGMA_WORKSPACE_VERSION,
|
|
135
|
+
});
|
|
136
|
+
const dest = (...segments: string[]) => path.join(appPath, ...segments);
|
|
137
|
+
const copy = (filePath: string) => copyFile(src(filePath), dest(filePath));
|
|
138
|
+
|
|
139
|
+
return sequence_([
|
|
140
|
+
// Warn (don't block) if the destination already exists — scaffolding
|
|
141
|
+
// will overwrite files in place.
|
|
142
|
+
flatMap(exists(appPath), (present) =>
|
|
143
|
+
when(
|
|
144
|
+
present,
|
|
145
|
+
warn(
|
|
146
|
+
`"${appPath}" already exists — existing files may be overwritten.`,
|
|
147
|
+
),
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
info(`Scaffolding React application in "${appPath}"...`),
|
|
151
|
+
|
|
152
|
+
// EJS templates (files needing interpolation)
|
|
153
|
+
template({
|
|
154
|
+
source: src("package.json.ejs"),
|
|
155
|
+
dest: dest("package.json"),
|
|
156
|
+
vars,
|
|
157
|
+
}),
|
|
158
|
+
template({ source: src("README.md.ejs"), dest: dest("README.md"), vars }),
|
|
159
|
+
template({
|
|
160
|
+
source: src("biome.json.ejs"),
|
|
161
|
+
dest: dest("biome.json"),
|
|
162
|
+
vars,
|
|
163
|
+
}),
|
|
164
|
+
|
|
165
|
+
// Root config
|
|
166
|
+
copy("tsconfig.json"),
|
|
167
|
+
copy("vite.config.ts"),
|
|
168
|
+
copy("vitest.config.ts"),
|
|
169
|
+
copy("vitest.setup.ts"),
|
|
170
|
+
copy("vitest.e2e.config.ts"),
|
|
171
|
+
// index.html (EJS — <title> uses the app name)
|
|
172
|
+
template({
|
|
173
|
+
source: src("index.html.ejs"),
|
|
174
|
+
dest: dest("index.html"),
|
|
175
|
+
vars,
|
|
176
|
+
}),
|
|
177
|
+
copy(".gitignore"),
|
|
178
|
+
|
|
179
|
+
// E2e tests (the 2×3 server matrix + its spawn/teardown harness)
|
|
180
|
+
copy("test/e2e/serverHarness.ts"),
|
|
181
|
+
copy("test/e2e/servers.e2e.ts"),
|
|
182
|
+
|
|
183
|
+
// Styles
|
|
184
|
+
// styles (EJS — form stylesheet imported only when --forms)
|
|
185
|
+
template({
|
|
186
|
+
source: src("src/styles/index.css.ejs"),
|
|
187
|
+
dest: dest("src/styles/index.css"),
|
|
188
|
+
vars,
|
|
189
|
+
}),
|
|
190
|
+
copy("src/styles/app.css"),
|
|
191
|
+
|
|
192
|
+
// Client
|
|
193
|
+
copy("src/client/entry.tsx"),
|
|
194
|
+
|
|
195
|
+
// Server — dev (Vite + HMR) and preview (compiled) servers each route
|
|
196
|
+
// between the app + sitemap renderers; the renderers stay routing-agnostic.
|
|
197
|
+
copy("src/server/entry.tsx"),
|
|
198
|
+
copy("src/server/renderer.tsx"),
|
|
199
|
+
copy("src/server/server.express.ts"),
|
|
200
|
+
copy("src/server/server.bun.ts"),
|
|
201
|
+
copy("src/server/preview.express.ts"),
|
|
202
|
+
copy("src/server/preview.bun.ts"),
|
|
203
|
+
|
|
204
|
+
// Sitemap (rendered route at /sitemap.xml)
|
|
205
|
+
copy("src/sitemap/renderer.ts"),
|
|
206
|
+
// sitemap getters (EJS — /contact entry only when --forms)
|
|
207
|
+
template({
|
|
208
|
+
source: src("src/sitemap/getSitemapItems.ts.ejs"),
|
|
209
|
+
dest: dest("src/sitemap/getSitemapItems.ts"),
|
|
210
|
+
vars,
|
|
211
|
+
}),
|
|
212
|
+
|
|
213
|
+
// Domain: marketing
|
|
214
|
+
copy("src/domains/marketing/HomePage.tsx"),
|
|
215
|
+
copy("src/domains/marketing/GuidePage.tsx"),
|
|
216
|
+
copy("src/domains/marketing/routes.ts"),
|
|
217
|
+
|
|
218
|
+
// Domain: account
|
|
219
|
+
copy("src/domains/account/AccountPage.tsx"),
|
|
220
|
+
copy("src/domains/account/LoginPage.tsx"),
|
|
221
|
+
copy("src/domains/account/routes.ts"),
|
|
222
|
+
|
|
223
|
+
// Domain: contact (when --forms is enabled)
|
|
224
|
+
when(answers.forms, copy("src/domains/contact/ContactPage.tsx")),
|
|
225
|
+
when(answers.forms, copy("src/domains/contact/routes.ts")),
|
|
226
|
+
|
|
227
|
+
// Routes (EJS — conditionally includes contact domain)
|
|
228
|
+
template({
|
|
229
|
+
source: src("src/routes.tsx.ejs"),
|
|
230
|
+
dest: dest("src/routes.tsx"),
|
|
231
|
+
vars,
|
|
232
|
+
}),
|
|
233
|
+
|
|
234
|
+
// Lib: Navigation (EJS — contact link only when --forms)
|
|
235
|
+
template({
|
|
236
|
+
source: src("src/lib/Navigation/Navigation.tsx.ejs"),
|
|
237
|
+
dest: dest("src/lib/Navigation/Navigation.tsx"),
|
|
238
|
+
vars,
|
|
239
|
+
}),
|
|
240
|
+
copy("src/lib/Navigation/index.ts"),
|
|
241
|
+
|
|
242
|
+
// Lib: ThemeSelector
|
|
243
|
+
copy("src/lib/ThemeSelector/ThemeSelector.tsx"),
|
|
244
|
+
copy("src/lib/ThemeSelector/index.ts"),
|
|
245
|
+
|
|
246
|
+
// Lib: ExampleComponent
|
|
247
|
+
copy("src/lib/ExampleComponent/ExampleComponent.tsx"),
|
|
248
|
+
copy("src/lib/ExampleComponent/ExampleComponent.stories.tsx"),
|
|
249
|
+
copy("src/lib/ExampleComponent/ExampleComponent.tests.tsx"),
|
|
250
|
+
copy("src/lib/ExampleComponent/index.ts"),
|
|
251
|
+
copy("src/lib/ExampleComponent/types.ts"),
|
|
252
|
+
copy("src/lib/ExampleComponent/styles.css"),
|
|
253
|
+
|
|
254
|
+
// Lib: LazyComponent
|
|
255
|
+
copy("src/lib/LazyComponent/LazyComponent.tsx"),
|
|
256
|
+
copy("src/lib/LazyComponent/LazyComponent.stories.tsx"),
|
|
257
|
+
copy("src/lib/LazyComponent/index.ts"),
|
|
258
|
+
|
|
259
|
+
// Lib barrel
|
|
260
|
+
copy("src/lib/index.ts"),
|
|
261
|
+
|
|
262
|
+
// Vite types
|
|
263
|
+
copy("src/vite-env.d.ts"),
|
|
264
|
+
|
|
265
|
+
// Storybook
|
|
266
|
+
copy(".storybook/main.ts"),
|
|
267
|
+
copy(".storybook/preview.ts"),
|
|
268
|
+
copy(".storybook/decorators/withRouter.tsx"),
|
|
269
|
+
copy(".storybook/decorators/index.ts"),
|
|
270
|
+
|
|
271
|
+
// Static asset dirs (kept by placeholder; both wired into Storybook staticDirs)
|
|
272
|
+
copy("src/assets/.gitkeep"),
|
|
273
|
+
copy("public/.gitkeep"),
|
|
274
|
+
copy("public/robots.txt"),
|
|
275
|
+
|
|
276
|
+
// Install dependencies
|
|
277
|
+
when(
|
|
278
|
+
answers.runInstall,
|
|
279
|
+
sequence_([
|
|
280
|
+
info("Installing dependencies..."),
|
|
281
|
+
exec("bun", ["install"], appPath),
|
|
282
|
+
]),
|
|
283
|
+
),
|
|
284
|
+
|
|
285
|
+
info(
|
|
286
|
+
answers.runInstall
|
|
287
|
+
? `Application "${appPath}" created. Run \`cd ${appPath} && bun run dev\` to start.`
|
|
288
|
+
: `Application "${appPath}" created. Run \`cd ${appPath} && bun install && bun run dev\` to start.`,
|
|
289
|
+
),
|
|
290
|
+
]);
|
|
291
|
+
},
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export default generator;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as withRouter } from "./withRouter.js";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { HeadProvider } from "@canonical/react-head";
|
|
2
|
+
import { createHashRouter, type RouteMap, route } from "@canonical/router-core";
|
|
3
|
+
import { Outlet, RouterProvider } from "@canonical/router-react";
|
|
4
|
+
import type { ElementType } from "react";
|
|
5
|
+
|
|
6
|
+
const defaultRoutes = {
|
|
7
|
+
story: route({
|
|
8
|
+
url: "/",
|
|
9
|
+
content: () => null,
|
|
10
|
+
}),
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
interface WithRouterOptions {
|
|
14
|
+
readonly routes?: RouteMap;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Storybook decorator that wraps stories in a router context.
|
|
19
|
+
*
|
|
20
|
+
* Uses a hash router so visual tests can navigate without a real server.
|
|
21
|
+
* Pass custom routes to test components that depend on specific route shapes.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* decorators: [withRouter()]
|
|
26
|
+
* decorators: [withRouter({ routes: appRoutes })]
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
const withRouter =
|
|
30
|
+
({ routes = defaultRoutes }: WithRouterOptions = {}) =>
|
|
31
|
+
(Story: ElementType) => {
|
|
32
|
+
const router = createHashRouter(routes);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<HeadProvider>
|
|
36
|
+
<RouterProvider router={router}>
|
|
37
|
+
<Story />
|
|
38
|
+
<Outlet />
|
|
39
|
+
</RouterProvider>
|
|
40
|
+
</HeadProvider>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default withRouter;
|