@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.
Files changed (65) hide show
  1. package/README.md +264 -0
  2. package/package.json +50 -0
  3. package/src/application/react/index.ts +294 -0
  4. package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
  5. package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
  6. package/src/application/react/templates/.storybook/main.ts +5 -0
  7. package/src/application/react/templates/.storybook/preview.ts +10 -0
  8. package/src/application/react/templates/README.md.ejs +82 -0
  9. package/src/application/react/templates/biome.json.ejs +6 -0
  10. package/src/application/react/templates/index.html.ejs +14 -0
  11. package/src/application/react/templates/package.json.ejs +72 -0
  12. package/src/application/react/templates/public/.gitkeep +0 -0
  13. package/src/application/react/templates/public/robots.txt +2 -0
  14. package/src/application/react/templates/src/assets/.gitkeep +0 -0
  15. package/src/application/react/templates/src/client/entry.tsx +25 -0
  16. package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
  17. package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
  18. package/src/application/react/templates/src/domains/account/routes.ts +44 -0
  19. package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
  20. package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
  21. package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
  22. package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
  23. package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
  24. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
  25. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
  26. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
  27. package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
  28. package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
  29. package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
  30. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
  31. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
  32. package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
  33. package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
  34. package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
  35. package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
  36. package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
  37. package/src/application/react/templates/src/lib/index.ts +4 -0
  38. package/src/application/react/templates/src/routes.tsx.ejs +129 -0
  39. package/src/application/react/templates/src/server/entry.tsx +45 -0
  40. package/src/application/react/templates/src/server/preview.bun.ts +79 -0
  41. package/src/application/react/templates/src/server/preview.express.ts +69 -0
  42. package/src/application/react/templates/src/server/renderer.tsx +50 -0
  43. package/src/application/react/templates/src/server/server.bun.ts +105 -0
  44. package/src/application/react/templates/src/server/server.express.ts +102 -0
  45. package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
  46. package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
  47. package/src/application/react/templates/src/styles/app.css +16 -0
  48. package/src/application/react/templates/src/styles/index.css.ejs +5 -0
  49. package/src/application/react/templates/src/vite-env.d.ts +1 -0
  50. package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
  51. package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
  52. package/src/application/react/templates/tsconfig.json +32 -0
  53. package/src/application/react/templates/vite.config.ts +45 -0
  54. package/src/application/react/templates/vitest.config.ts +31 -0
  55. package/src/application/react/templates/vitest.e2e.config.ts +17 -0
  56. package/src/application/react/templates/vitest.setup.ts +9 -0
  57. package/src/domain/index.ts +119 -0
  58. package/src/index.test.ts +398 -0
  59. package/src/index.ts +14 -0
  60. package/src/route/index.ts +154 -0
  61. package/src/route/insertRoute.test.ts +98 -0
  62. package/src/route/insertRoute.ts +236 -0
  63. package/src/shared/casing.ts +14 -0
  64. package/src/shared/versions.ts +48 -0
  65. 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;
@@ -0,0 +1,5 @@
1
+ import { createConfig } from "@canonical/storybook-config";
2
+
3
+ export default createConfig("react", {
4
+ staticDirs: ["../src/assets", "../public"],
5
+ });
@@ -0,0 +1,10 @@
1
+ import previewConfig from "@canonical/storybook-config/preview";
2
+ import type { Preview } from "@storybook/react-vite";
3
+
4
+ import "../src/styles/index.css";
5
+
6
+ const preview: Preview = {
7
+ ...previewConfig,
8
+ };
9
+
10
+ export default preview;