@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
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { dryRun, sequence_ } from "@canonical/task";
|
|
5
|
+
import { describe, expect, it } from "vitest";
|
|
6
|
+
import { generators } from "./index.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The route generator requires an existing domain (it adds to one). In a
|
|
10
|
+
* dry-run the virtual filesystem is empty, so we first run the domain generator
|
|
11
|
+
* in the same sequence to "create" src/domains/<domain>/routes.ts, then add the
|
|
12
|
+
* route — mirroring real usage (`summon domain` then `summon route`).
|
|
13
|
+
*/
|
|
14
|
+
function dryRunRoute(domain: string, route: string) {
|
|
15
|
+
return dryRun(
|
|
16
|
+
sequence_([
|
|
17
|
+
generators.domain.generate({ domainName: domain }),
|
|
18
|
+
generators.route.generate({ routePath: `${domain}/${route}` }),
|
|
19
|
+
]),
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("application/react generator", () => {
|
|
24
|
+
it("produces effects for all expected files", () => {
|
|
25
|
+
const result = dryRun(
|
|
26
|
+
generators["application/react"].generate({
|
|
27
|
+
appPath: "my-app",
|
|
28
|
+
ssr: true,
|
|
29
|
+
router: true,
|
|
30
|
+
forms: false,
|
|
31
|
+
runInstall: false,
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// template() produces WriteFile, copyFile() produces CopyFile
|
|
36
|
+
const filePaths = result.effects
|
|
37
|
+
.filter((e) => e._tag === "WriteFile" || e._tag === "CopyFile")
|
|
38
|
+
.map(
|
|
39
|
+
(e) =>
|
|
40
|
+
(e as { path?: string; dest?: string }).path ??
|
|
41
|
+
(e as { dest?: string }).dest,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
// EJS templates (interpolated)
|
|
45
|
+
expect(filePaths).toContain("my-app/package.json");
|
|
46
|
+
expect(filePaths).toContain("my-app/README.md");
|
|
47
|
+
|
|
48
|
+
// Static copies
|
|
49
|
+
expect(filePaths).toContain("my-app/tsconfig.json");
|
|
50
|
+
expect(filePaths).toContain("my-app/vite.config.ts");
|
|
51
|
+
expect(filePaths).toContain("my-app/biome.json");
|
|
52
|
+
expect(filePaths).toContain("my-app/index.html");
|
|
53
|
+
expect(filePaths).toContain("my-app/.gitignore");
|
|
54
|
+
expect(filePaths).toContain("my-app/src/client/entry.tsx");
|
|
55
|
+
expect(filePaths).toContain("my-app/src/server/entry.tsx");
|
|
56
|
+
expect(filePaths).toContain("my-app/src/server/renderer.tsx");
|
|
57
|
+
expect(filePaths).toContain("my-app/src/server/server.express.ts");
|
|
58
|
+
expect(filePaths).toContain("my-app/src/server/server.bun.ts");
|
|
59
|
+
expect(filePaths).toContain("my-app/src/server/preview.express.ts");
|
|
60
|
+
expect(filePaths).toContain("my-app/src/server/preview.bun.ts");
|
|
61
|
+
expect(filePaths).toContain("my-app/public/robots.txt");
|
|
62
|
+
expect(filePaths).toContain("my-app/src/sitemap/renderer.ts");
|
|
63
|
+
expect(filePaths).toContain("my-app/src/sitemap/getSitemapItems.ts");
|
|
64
|
+
expect(filePaths).toContain("my-app/vitest.e2e.config.ts");
|
|
65
|
+
expect(filePaths).toContain("my-app/test/e2e/serverHarness.ts");
|
|
66
|
+
expect(filePaths).toContain("my-app/test/e2e/servers.e2e.ts");
|
|
67
|
+
expect(filePaths).toContain("my-app/src/domains/marketing/HomePage.tsx");
|
|
68
|
+
expect(filePaths).toContain("my-app/src/domains/marketing/routes.ts");
|
|
69
|
+
expect(filePaths).toContain("my-app/src/routes.tsx");
|
|
70
|
+
expect(filePaths).toContain("my-app/src/lib/Navigation/Navigation.tsx");
|
|
71
|
+
expect(filePaths).toContain("my-app/src/lib/Navigation/index.ts");
|
|
72
|
+
expect(filePaths).toContain("my-app/src/lib/index.ts");
|
|
73
|
+
expect(filePaths).toContain("my-app/src/vite-env.d.ts");
|
|
74
|
+
expect(filePaths).toContain("my-app/src/styles/index.css");
|
|
75
|
+
expect(filePaths).toContain("my-app/src/styles/app.css");
|
|
76
|
+
expect(filePaths).toContain("my-app/.storybook/main.ts");
|
|
77
|
+
expect(filePaths).toContain("my-app/.storybook/preview.ts");
|
|
78
|
+
expect(filePaths).toContain("my-app/.storybook/decorators/withRouter.tsx");
|
|
79
|
+
expect(filePaths).toContain("my-app/.storybook/decorators/index.ts");
|
|
80
|
+
|
|
81
|
+
// Account domain
|
|
82
|
+
expect(filePaths).toContain("my-app/src/domains/account/AccountPage.tsx");
|
|
83
|
+
expect(filePaths).toContain("my-app/src/domains/account/LoginPage.tsx");
|
|
84
|
+
expect(filePaths).toContain("my-app/src/domains/account/routes.ts");
|
|
85
|
+
|
|
86
|
+
// Contact domain NOT included when forms=false
|
|
87
|
+
expect(filePaths).not.toContain(
|
|
88
|
+
"my-app/src/domains/contact/ContactPage.tsx",
|
|
89
|
+
);
|
|
90
|
+
expect(filePaths).not.toContain("my-app/src/domains/contact/routes.ts");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("scaffolds every file present in the templates directory", () => {
|
|
94
|
+
// Authoritative manifest-completeness guard: enumerate the templates dir on
|
|
95
|
+
// disk and assert each file is emitted by the generator. The manifest is a
|
|
96
|
+
// hand-maintained allow-list, so a new template file added without a matching
|
|
97
|
+
// copy()/template() entry would silently never reach generated apps — this
|
|
98
|
+
// test fails loudly when that happens.
|
|
99
|
+
const templatesDir = fileURLToPath(
|
|
100
|
+
new URL("./application/react/templates", import.meta.url),
|
|
101
|
+
);
|
|
102
|
+
const onDisk = readdirSync(templatesDir, { recursive: true })
|
|
103
|
+
.map((entry) => String(entry).split(path.sep).join("/"))
|
|
104
|
+
.filter((rel) => statSync(path.join(templatesDir, rel)).isFile())
|
|
105
|
+
// `.ejs` templates are emitted at the interpolated dest (suffix stripped).
|
|
106
|
+
.map((rel) =>
|
|
107
|
+
rel.endsWith(".ejs") ? rel.slice(0, -".ejs".length) : rel,
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// Generate with all features on so conditionally-included templates emit.
|
|
111
|
+
const result = dryRun(
|
|
112
|
+
generators["application/react"].generate({
|
|
113
|
+
appPath: "my-app",
|
|
114
|
+
ssr: true,
|
|
115
|
+
router: true,
|
|
116
|
+
forms: true,
|
|
117
|
+
runInstall: false,
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
const emitted = new Set(
|
|
121
|
+
result.effects
|
|
122
|
+
.filter((e) => e._tag === "WriteFile" || e._tag === "CopyFile")
|
|
123
|
+
.map(
|
|
124
|
+
(e) =>
|
|
125
|
+
(e as { path?: string; dest?: string }).path ??
|
|
126
|
+
(e as { dest?: string }).dest,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const missing = onDisk.filter((rel) => !emitted.has(`my-app/${rel}`));
|
|
131
|
+
expect(
|
|
132
|
+
missing,
|
|
133
|
+
`templates not wired into the manifest: ${missing}`,
|
|
134
|
+
).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("includes contact domain when forms=true", () => {
|
|
138
|
+
const result = dryRun(
|
|
139
|
+
generators["application/react"].generate({
|
|
140
|
+
appPath: "my-app",
|
|
141
|
+
ssr: true,
|
|
142
|
+
router: true,
|
|
143
|
+
forms: true,
|
|
144
|
+
runInstall: false,
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const filePaths = result.effects
|
|
149
|
+
.filter((e) => e._tag === "WriteFile" || e._tag === "CopyFile")
|
|
150
|
+
.map(
|
|
151
|
+
(e) =>
|
|
152
|
+
(e as { path?: string; dest?: string }).path ??
|
|
153
|
+
(e as { dest?: string }).dest,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(filePaths).toContain("my-app/src/domains/contact/ContactPage.tsx");
|
|
157
|
+
expect(filePaths).toContain("my-app/src/domains/contact/routes.ts");
|
|
158
|
+
|
|
159
|
+
// routes.tsx is generated as an EJS template (WriteFile effect exists)
|
|
160
|
+
// Note: readFile is mocked in dry-run so we can't inspect rendered content,
|
|
161
|
+
// but we verify the template is wired up and the contact files are generated.
|
|
162
|
+
const routesEffect = result.effects.find(
|
|
163
|
+
(e) =>
|
|
164
|
+
e._tag === "WriteFile" &&
|
|
165
|
+
(e as { path: string }).path === "my-app/src/routes.tsx",
|
|
166
|
+
);
|
|
167
|
+
expect(routesEffect).toBeDefined();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("excludes contact domain files when forms=false", () => {
|
|
171
|
+
const result = dryRun(
|
|
172
|
+
generators["application/react"].generate({
|
|
173
|
+
appPath: "my-app",
|
|
174
|
+
ssr: true,
|
|
175
|
+
router: true,
|
|
176
|
+
forms: false,
|
|
177
|
+
runInstall: false,
|
|
178
|
+
}),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const filePaths = result.effects
|
|
182
|
+
.filter((e) => e._tag === "WriteFile" || e._tag === "CopyFile")
|
|
183
|
+
.map(
|
|
184
|
+
(e) =>
|
|
185
|
+
(e as { path?: string; dest?: string }).path ??
|
|
186
|
+
(e as { dest?: string }).dest,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
expect(filePaths).not.toContain(
|
|
190
|
+
"my-app/src/domains/contact/ContactPage.tsx",
|
|
191
|
+
);
|
|
192
|
+
expect(filePaths).not.toContain("my-app/src/domains/contact/routes.ts");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("uses the appPath in file paths", () => {
|
|
196
|
+
const result = dryRun(
|
|
197
|
+
generators["application/react"].generate({
|
|
198
|
+
appPath: "custom-app",
|
|
199
|
+
ssr: true,
|
|
200
|
+
router: true,
|
|
201
|
+
forms: false,
|
|
202
|
+
runInstall: false,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const filePaths = result.effects
|
|
207
|
+
.filter((e) => e._tag === "WriteFile" || e._tag === "CopyFile")
|
|
208
|
+
.map(
|
|
209
|
+
(e) =>
|
|
210
|
+
(e as { path?: string; dest?: string }).path ??
|
|
211
|
+
(e as { dest?: string }).dest,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(filePaths).toContain("custom-app/package.json");
|
|
215
|
+
expect(filePaths).toContain("custom-app/src/client/entry.tsx");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("throws when --ssr is false", () => {
|
|
219
|
+
expect(() =>
|
|
220
|
+
dryRun(
|
|
221
|
+
generators["application/react"].generate({
|
|
222
|
+
appPath: "my-app",
|
|
223
|
+
ssr: false,
|
|
224
|
+
router: true,
|
|
225
|
+
forms: false,
|
|
226
|
+
runInstall: false,
|
|
227
|
+
}),
|
|
228
|
+
),
|
|
229
|
+
).toThrow();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("throws when --router is false", () => {
|
|
233
|
+
expect(() =>
|
|
234
|
+
dryRun(
|
|
235
|
+
generators["application/react"].generate({
|
|
236
|
+
appPath: "my-app",
|
|
237
|
+
ssr: true,
|
|
238
|
+
router: false,
|
|
239
|
+
forms: false,
|
|
240
|
+
runInstall: false,
|
|
241
|
+
}),
|
|
242
|
+
),
|
|
243
|
+
).toThrow();
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("domain generator", () => {
|
|
248
|
+
it("creates MainPage.tsx and routes.ts in src/domains/{name}/", () => {
|
|
249
|
+
const result = dryRun(
|
|
250
|
+
generators.domain.generate({ domainName: "billing" }),
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const writeEffects = result.effects.filter((e) => e._tag === "WriteFile");
|
|
254
|
+
const paths = writeEffects.map((e) => (e as { path: string }).path);
|
|
255
|
+
|
|
256
|
+
expect(paths).toContain("src/domains/billing/MainPage.tsx");
|
|
257
|
+
expect(paths).toContain("src/domains/billing/routes.ts");
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("creates a MakeDir effect for the domain directory", () => {
|
|
261
|
+
const result = dryRun(
|
|
262
|
+
generators.domain.generate({ domainName: "billing" }),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
const mkdirEffects = result.effects.filter((e) => e._tag === "MakeDir");
|
|
266
|
+
const paths = mkdirEffects.map((e) => (e as { path: string }).path);
|
|
267
|
+
|
|
268
|
+
expect(paths).toContain("src/domains/billing");
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("route generator", () => {
|
|
273
|
+
it("creates {Name}Page.tsx and transforms routes.ts", () => {
|
|
274
|
+
const result = dryRunRoute("account", "settings");
|
|
275
|
+
|
|
276
|
+
const writePaths = result.effects
|
|
277
|
+
.filter((e) => e._tag === "WriteFile")
|
|
278
|
+
.map((e) => (e as { path: string }).path);
|
|
279
|
+
expect(writePaths).toContain("src/domains/account/SettingsPage.tsx");
|
|
280
|
+
|
|
281
|
+
// The route is wired into routes.ts via a TransformFile (AST insert), not
|
|
282
|
+
// an AppendFile of a TODO comment. The transform content is covered by
|
|
283
|
+
// insertRoute.test.ts.
|
|
284
|
+
const transformPaths = result.effects
|
|
285
|
+
.filter((e) => e._tag === "TransformFile")
|
|
286
|
+
.map((e) => (e as { path: string }).path);
|
|
287
|
+
expect(transformPaths).toContain("src/domains/account/routes.ts");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("includes correct content in the page component", () => {
|
|
291
|
+
const result = dryRunRoute("account", "settings");
|
|
292
|
+
|
|
293
|
+
const page = result.effects.find(
|
|
294
|
+
(e) =>
|
|
295
|
+
e._tag === "WriteFile" &&
|
|
296
|
+
(e as { path: string }).path === "src/domains/account/SettingsPage.tsx",
|
|
297
|
+
) as { content: string } | undefined;
|
|
298
|
+
|
|
299
|
+
expect(page).toBeDefined();
|
|
300
|
+
expect(page?.content).toContain("export default function SettingsPage()");
|
|
301
|
+
expect(page?.content).toContain('useHead({ title: "Settings" })');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("wires the route via a reversible TransformFile (no TODO append)", () => {
|
|
305
|
+
const result = dryRunRoute("account", "settings");
|
|
306
|
+
|
|
307
|
+
// No AppendFile TODO stub any more.
|
|
308
|
+
expect(result.effects.some((e) => e._tag === "AppendFile")).toBe(false);
|
|
309
|
+
|
|
310
|
+
const transform = result.effects.find(
|
|
311
|
+
(e) =>
|
|
312
|
+
e._tag === "TransformFile" &&
|
|
313
|
+
(e as { path: string }).path === "src/domains/account/routes.ts",
|
|
314
|
+
) as { transform: (s: string) => string; undo?: unknown } | undefined;
|
|
315
|
+
|
|
316
|
+
expect(transform).toBeDefined();
|
|
317
|
+
// It carries an undo (the inverse removeRoute transform).
|
|
318
|
+
expect(transform?.undo).toBeDefined();
|
|
319
|
+
|
|
320
|
+
// The forward transform actually inserts the import + route entry.
|
|
321
|
+
const base = `import { route } from "@canonical/router-core";
|
|
322
|
+
import MainPage from "./MainPage.js";
|
|
323
|
+
|
|
324
|
+
const routes = {
|
|
325
|
+
account: route({ url: "/account", content: MainPage }),
|
|
326
|
+
} as const;
|
|
327
|
+
|
|
328
|
+
export default routes;
|
|
329
|
+
`;
|
|
330
|
+
const out = transform?.transform(base) ?? "";
|
|
331
|
+
expect(out).toContain('import SettingsPage from "./SettingsPage.js";');
|
|
332
|
+
expect(out).toContain("settings: route({");
|
|
333
|
+
expect(out).toContain('url: "/account/settings",');
|
|
334
|
+
expect(out).toContain("content: SettingsPage,");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("throws on single-segment path", () => {
|
|
338
|
+
expect(() =>
|
|
339
|
+
dryRun(generators.route.generate({ routePath: "settings" })),
|
|
340
|
+
).toThrow();
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("fails when the target domain does not exist", () => {
|
|
344
|
+
// No domain created first → the guard rejects before writing anything.
|
|
345
|
+
expect(() =>
|
|
346
|
+
dryRun(generators.route.generate({ routePath: "missing/page" })),
|
|
347
|
+
).toThrow(/not found|Create it first/);
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe("wrapper generator", () => {
|
|
352
|
+
it("creates {Name}Layout.tsx and index.ts in src/lib/{Name}Layout/", () => {
|
|
353
|
+
const result = dryRun(
|
|
354
|
+
generators.wrapper.generate({ wrapperName: "settings" }),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const writeEffects = result.effects.filter((e) => e._tag === "WriteFile");
|
|
358
|
+
const paths = writeEffects.map((e) => (e as { path: string }).path);
|
|
359
|
+
|
|
360
|
+
expect(paths).toContain("src/lib/SettingsLayout/SettingsLayout.tsx");
|
|
361
|
+
expect(paths).toContain("src/lib/SettingsLayout/index.ts");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("generates correct layout content", () => {
|
|
365
|
+
const result = dryRun(
|
|
366
|
+
generators.wrapper.generate({ wrapperName: "settings" }),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const writeEffects = result.effects.filter((e) => e._tag === "WriteFile");
|
|
370
|
+
const layout = writeEffects.find(
|
|
371
|
+
(e) =>
|
|
372
|
+
(e as { path: string }).path ===
|
|
373
|
+
"src/lib/SettingsLayout/SettingsLayout.tsx",
|
|
374
|
+
) as { content: string } | undefined;
|
|
375
|
+
|
|
376
|
+
expect(layout).toBeDefined();
|
|
377
|
+
expect(layout?.content).toContain(
|
|
378
|
+
"export default function SettingsLayout(",
|
|
379
|
+
);
|
|
380
|
+
expect(layout?.content).toContain('className="settings-layout"');
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("generates correct barrel export", () => {
|
|
384
|
+
const result = dryRun(
|
|
385
|
+
generators.wrapper.generate({ wrapperName: "settings" }),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const writeEffects = result.effects.filter((e) => e._tag === "WriteFile");
|
|
389
|
+
const barrel = writeEffects.find(
|
|
390
|
+
(e) => (e as { path: string }).path === "src/lib/SettingsLayout/index.ts",
|
|
391
|
+
) as { content: string } | undefined;
|
|
392
|
+
|
|
393
|
+
expect(barrel).toBeDefined();
|
|
394
|
+
expect(barrel?.content).toContain(
|
|
395
|
+
'export { default } from "./SettingsLayout.js"',
|
|
396
|
+
);
|
|
397
|
+
});
|
|
398
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AnyGenerator } from "@canonical/summon-core";
|
|
2
|
+
import { generator as applicationReactGenerator } from "./application/react/index.js";
|
|
3
|
+
import { generator as domainGenerator } from "./domain/index.js";
|
|
4
|
+
import { generator as routeGenerator } from "./route/index.js";
|
|
5
|
+
import { generator as wrapperGenerator } from "./wrapper/index.js";
|
|
6
|
+
|
|
7
|
+
export const generators = {
|
|
8
|
+
"application/react": applicationReactGenerator,
|
|
9
|
+
domain: domainGenerator,
|
|
10
|
+
route: routeGenerator,
|
|
11
|
+
wrapper: wrapperGenerator,
|
|
12
|
+
} as const satisfies Record<string, AnyGenerator>;
|
|
13
|
+
|
|
14
|
+
export default generators;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import type {
|
|
3
|
+
GeneratorDefinition,
|
|
4
|
+
PromptDefinition,
|
|
5
|
+
} from "@canonical/summon-core";
|
|
6
|
+
import {
|
|
7
|
+
exists,
|
|
8
|
+
fail,
|
|
9
|
+
flatMap,
|
|
10
|
+
info,
|
|
11
|
+
sequence_,
|
|
12
|
+
transformFile,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "@canonical/task";
|
|
15
|
+
import { toCamelCase, toPascalCase, toTitleCase } from "@canonical/utils";
|
|
16
|
+
import { normalizeCommandPath } from "../shared/casing.js";
|
|
17
|
+
import { insertRoute, removeRoute } from "./insertRoute.js";
|
|
18
|
+
|
|
19
|
+
interface RouteAnswers {
|
|
20
|
+
readonly routePath: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const prompts: PromptDefinition[] = [
|
|
24
|
+
{
|
|
25
|
+
name: "routePath",
|
|
26
|
+
type: "text",
|
|
27
|
+
message: "Route path (for example account/settings):",
|
|
28
|
+
default: "example/page",
|
|
29
|
+
positional: true,
|
|
30
|
+
group: "Route",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function buildPage(routeName: string): string {
|
|
35
|
+
const pageName = `${toPascalCase(routeName)}Page`;
|
|
36
|
+
const title = toTitleCase(routeName);
|
|
37
|
+
const slugId = toCamelCase(routeName);
|
|
38
|
+
|
|
39
|
+
return `import { useHead } from "@canonical/react-head";
|
|
40
|
+
import type { ReactElement } from "react";
|
|
41
|
+
|
|
42
|
+
export default function ${pageName}(): ReactElement {
|
|
43
|
+
useHead({ title: "${title}" });
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section aria-labelledby="${slugId}-title">
|
|
47
|
+
<h1 id="${slugId}-title">${title}</h1>
|
|
48
|
+
</section>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const generator: GeneratorDefinition<RouteAnswers> = {
|
|
55
|
+
meta: {
|
|
56
|
+
name: "route",
|
|
57
|
+
displayName: "@canonical/summon-application:route",
|
|
58
|
+
description: "Add a route page to an existing domain",
|
|
59
|
+
version: "0.1.0",
|
|
60
|
+
help: `Creates a page component inside an existing domain directory.
|
|
61
|
+
|
|
62
|
+
Given a path like "account/settings":
|
|
63
|
+
- Domain = first segment ("account")
|
|
64
|
+
- Route = last segment ("settings")
|
|
65
|
+
|
|
66
|
+
Creates:
|
|
67
|
+
- src/domains/<domain>/<RouteName>Page.tsx
|
|
68
|
+
- Inserts the import + route entry into src/domains/<domain>/routes.ts
|
|
69
|
+
|
|
70
|
+
Refuses to overwrite an existing page file.
|
|
71
|
+
|
|
72
|
+
Create the domain first with: summon domain <name>
|
|
73
|
+
|
|
74
|
+
Note on --undo: undo removes the route entry and import it added. It assumes the
|
|
75
|
+
route was newly created — running --undo after an insert that was a no-op (the
|
|
76
|
+
route key already existed) would remove a route you already had.`,
|
|
77
|
+
examples: [
|
|
78
|
+
"summon route account/settings",
|
|
79
|
+
"summon route billing/invoices",
|
|
80
|
+
"summon route --dry-run user/profile",
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
prompts,
|
|
85
|
+
|
|
86
|
+
generate: (answers) => {
|
|
87
|
+
const normalized = normalizeCommandPath(answers.routePath);
|
|
88
|
+
const segments = normalized.split("/");
|
|
89
|
+
|
|
90
|
+
if (segments.length < 2) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Route path must include a domain and route name (e.g. "account/settings"), got "${normalized}"`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const domainName = segments[0];
|
|
97
|
+
const routeName = segments[segments.length - 1];
|
|
98
|
+
const pageName = `${toPascalCase(routeName)}Page`;
|
|
99
|
+
const domainDir = path.join("src", "domains", domainName);
|
|
100
|
+
const pageFile = path.join(domainDir, `${pageName}.tsx`);
|
|
101
|
+
const routesFile = path.join(domainDir, "routes.ts");
|
|
102
|
+
const url = `/${normalized}`;
|
|
103
|
+
const routeKey = toCamelCase(routeName);
|
|
104
|
+
|
|
105
|
+
const scaffold = sequence_([
|
|
106
|
+
info(`Adding route "${routeName}" to domain "${domainName}"...`),
|
|
107
|
+
// No mkdir: this generator targets an *existing* domain (guarded below).
|
|
108
|
+
// mkdir's default undo is DeleteDirectory, which on `--undo` could remove
|
|
109
|
+
// a pre-existing domain folder.
|
|
110
|
+
writeFile(pageFile, buildPage(routeName)),
|
|
111
|
+
// Insert the import + route entry into the domain's routes object via a
|
|
112
|
+
// pure AST-located transform (no manual edit needed afterwards). Undo
|
|
113
|
+
// removes exactly the lines we added, rather than restoring a snapshot.
|
|
114
|
+
transformFile(
|
|
115
|
+
routesFile,
|
|
116
|
+
(source) =>
|
|
117
|
+
insertRoute(source, {
|
|
118
|
+
pageName,
|
|
119
|
+
importPath: `./${pageName}.js`,
|
|
120
|
+
routeKey,
|
|
121
|
+
url,
|
|
122
|
+
}),
|
|
123
|
+
{
|
|
124
|
+
undo: transformFile(routesFile, (source) =>
|
|
125
|
+
removeRoute(source, { pageName, routeKey }),
|
|
126
|
+
),
|
|
127
|
+
},
|
|
128
|
+
),
|
|
129
|
+
info(`Route "${routeName}" wired into ${routesFile}.`),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
// Guard before touching anything:
|
|
133
|
+
// - the domain must exist (this generator adds to an existing domain);
|
|
134
|
+
// - the page must NOT exist (its write's undo is a delete, so overwriting a
|
|
135
|
+
// hand-authored page then `--undo` would destroy the original).
|
|
136
|
+
return flatMap(exists(routesFile), (domainPresent) =>
|
|
137
|
+
!domainPresent
|
|
138
|
+
? fail({
|
|
139
|
+
code: "ROUTE_DOMAIN_MISSING",
|
|
140
|
+
message: `Domain "${domainName}" not found (${routesFile} missing). Create it first with: summon domain ${domainName}`,
|
|
141
|
+
})
|
|
142
|
+
: flatMap(exists(pageFile), (pagePresent) =>
|
|
143
|
+
pagePresent
|
|
144
|
+
? fail({
|
|
145
|
+
code: "ROUTE_PAGE_EXISTS",
|
|
146
|
+
message: `Page "${pageFile}" already exists. Choose a different route name or remove the file first.`,
|
|
147
|
+
})
|
|
148
|
+
: scaffold,
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export default generator;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { insertRoute, removeRoute } from "./insertRoute.js";
|
|
4
|
+
|
|
5
|
+
const BASE = `import { route } from "@canonical/router-core";
|
|
6
|
+
import MainPage from "./MainPage.js";
|
|
7
|
+
|
|
8
|
+
const routes = {
|
|
9
|
+
billing: route({
|
|
10
|
+
url: "/billing",
|
|
11
|
+
content: MainPage,
|
|
12
|
+
}),
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export default routes;
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const INS = {
|
|
19
|
+
pageName: "InvoicesPage",
|
|
20
|
+
importPath: "./InvoicesPage.js",
|
|
21
|
+
routeKey: "invoices",
|
|
22
|
+
url: "/billing/invoices",
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
describe("insertRoute", () => {
|
|
26
|
+
it("adds the import and the route entry", () => {
|
|
27
|
+
const out = insertRoute(BASE, INS);
|
|
28
|
+
expect(out).toContain('import InvoicesPage from "./InvoicesPage.js";');
|
|
29
|
+
expect(out).toContain("invoices: route({");
|
|
30
|
+
expect(out).toContain('url: "/billing/invoices",');
|
|
31
|
+
expect(out).toContain("content: InvoicesPage,");
|
|
32
|
+
// existing entry untouched
|
|
33
|
+
expect(out).toContain("billing: route({");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("is idempotent — re-inserting the same route is a no-op", () => {
|
|
37
|
+
const once = insertRoute(BASE, INS);
|
|
38
|
+
expect(insertRoute(once, INS)).toBe(once);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("throws when there is no routes object literal", () => {
|
|
42
|
+
expect(() => insertRoute("export const x = 1;\n", INS)).toThrow(/routes/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Bug fixes from the adversarial review: the entry must be spliced using the
|
|
46
|
+
// object literal's own AST bounds (not a brace text-scan), and a separating
|
|
47
|
+
// comma added when the previous property lacks one — so non-canonical routes
|
|
48
|
+
// shapes still produce parseable output.
|
|
49
|
+
it.each([
|
|
50
|
+
[
|
|
51
|
+
"single-line object",
|
|
52
|
+
`const routes = { billing: route({ url: "/billing", content: MainPage }) } as const;\n`,
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
"no trailing comma",
|
|
56
|
+
`const routes = {\n billing: route({ url: "/billing", content: MainPage })\n} as const;\n`,
|
|
57
|
+
],
|
|
58
|
+
["empty object", "const routes = {} as const;\n"],
|
|
59
|
+
])("produces parseable output for a %s", (_label, src) => {
|
|
60
|
+
const out = insertRoute(src, INS);
|
|
61
|
+
const sf = ts.createSourceFile("r.ts", out, ts.ScriptTarget.Latest, true);
|
|
62
|
+
// @ts-expect-error parseDiagnostics is internal but reliable for this check
|
|
63
|
+
expect(sf.parseDiagnostics).toHaveLength(0);
|
|
64
|
+
expect(out).toContain("invoices: route({");
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("removeRoute", () => {
|
|
69
|
+
it("is the exact inverse of insertRoute (round-trip)", () => {
|
|
70
|
+
const added = insertRoute(BASE, INS);
|
|
71
|
+
expect(removeRoute(added, INS)).toBe(BASE);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("is idempotent — removing an absent route is a no-op", () => {
|
|
75
|
+
expect(removeRoute(BASE, INS)).toBe(BASE);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("leaves other routes and imports intact", () => {
|
|
79
|
+
const added = insertRoute(BASE, INS);
|
|
80
|
+
const back = removeRoute(added, INS);
|
|
81
|
+
expect(back).toContain("billing: route({");
|
|
82
|
+
expect(back).toContain('import MainPage from "./MainPage.js";');
|
|
83
|
+
expect(back).not.toContain("InvoicesPage");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("does NOT delete a merged import that has named bindings", () => {
|
|
87
|
+
// A user may have merged named bindings into the generated import. removing
|
|
88
|
+
// the whole line would discard them, so removeRoute leaves it alone.
|
|
89
|
+
const merged = insertRoute(BASE, INS).replace(
|
|
90
|
+
'import InvoicesPage from "./InvoicesPage.js";',
|
|
91
|
+
'import InvoicesPage, { helper } from "./InvoicesPage.js";',
|
|
92
|
+
);
|
|
93
|
+
const back = removeRoute(merged, INS);
|
|
94
|
+
expect(back).toContain("{ helper }");
|
|
95
|
+
// the route entry is still removed
|
|
96
|
+
expect(back).not.toContain("invoices: route({");
|
|
97
|
+
});
|
|
98
|
+
});
|