@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
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Sitemap renderer for the `/sitemap.xml` route.
3
+ *
4
+ * Builds a `SitemapRenderer` from the route getters (`getSitemapItems`) and the
5
+ * site's canonical base URL. Each server entrypoint dispatches `GET /sitemap.xml`
6
+ * to this factory; every other path falls through to the JSX app renderer. The
7
+ * sitemap and app renderers know nothing about each other — the server picks
8
+ * between them. Set `BASE_URL` (e.g. `https://example.com`) in production so
9
+ * relative `loc` values resolve to absolute, crawlable URLs.
10
+ */
11
+ import { SitemapRenderer } from "@canonical/react-ssr/renderer";
12
+ import getSitemapItems from "./getSitemapItems.js";
13
+
14
+ /** True only for an absolute URL — the sitemap base must be one (relative `loc`
15
+ * values are resolved against it). Guards against an empty or relative
16
+ * `BASE_URL` (e.g. the `"/"` some tooling injects), which is not a valid base. */
17
+ function isAbsoluteUrl(value: string): boolean {
18
+ return URL.canParse(value);
19
+ }
20
+
21
+ /** Canonical base URL for the sitemap. Prefer an absolute `BASE_URL`; otherwise
22
+ * fall back to localhost on the active `PORT` (so local preview / e2e `<loc>`
23
+ * URLs point at the running server rather than a stale hard-coded port). */
24
+ const fromEnv = process.env.BASE_URL;
25
+ const baseUrl =
26
+ fromEnv && isAbsoluteUrl(fromEnv)
27
+ ? fromEnv
28
+ : `http://localhost:${process.env.PORT ?? 5174}`;
29
+
30
+ /**
31
+ * Per-request factory mirroring the JSX app's `createRenderer` contract, so the
32
+ * server entrypoints select between them uniformly. A fresh `SitemapRenderer`
33
+ * is created per request: it mutates `statusCode` / `statusReady` while
34
+ * rendering, so a shared instance could interfere across concurrent
35
+ * `/sitemap.xml` requests. The constructor only stores the getters + config, so
36
+ * the allocation is cheap (the data fetch happens later, in `renderTo*`).
37
+ */
38
+ export default function createSitemapRenderer() {
39
+ return new SitemapRenderer([getSitemapItems], { baseUrl });
40
+ }
@@ -0,0 +1,16 @@
1
+ .app-shell {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding-inline: var(--grid-gap, 1.5rem);
5
+ padding-block: var(--container-gap-loose, 1.5rem);
6
+ }
7
+
8
+ .shell-header {
9
+ padding-block-end: var(--container-gap-default, 1rem);
10
+ }
11
+
12
+ .shell-header nav {
13
+ grid-column: 1 / -1;
14
+ display: flex;
15
+ gap: var(--container-gap-default, 1rem);
16
+ }
@@ -0,0 +1,5 @@
1
+ @import url("@canonical/styles");
2
+ <% if (forms) { -%>
3
+ @import url("@canonical/react-ds-global-form/dist/esm/index.css");
4
+ <% } -%>
5
+ @import url("./app.css");
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,153 @@
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+
4
+ /**
5
+ * A spawned dev/preview server under test.
6
+ */
7
+ export interface RunningServer {
8
+ /** Base URL, e.g. `http://localhost:54123`. */
9
+ base: string;
10
+ /** Stop the server and its whole process group. */
11
+ stop: () => Promise<void>;
12
+ }
13
+
14
+ /** Reserve a free TCP port by opening an ephemeral listener and reading it back. */
15
+ export function getFreePort(): Promise<number> {
16
+ return new Promise((resolve, reject) => {
17
+ const srv = createServer();
18
+ srv.unref();
19
+ srv.on("error", reject);
20
+ srv.listen(0, () => {
21
+ const address = srv.address();
22
+ if (address && typeof address === "object") {
23
+ const { port } = address;
24
+ srv.close(() => resolve(port));
25
+ } else {
26
+ srv.close(() => reject(new Error("could not determine a free port")));
27
+ }
28
+ });
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Resolve once the server answers an HTTP request on `localhost:<port>`, or
34
+ * reject after `timeoutMs`. Probing over HTTP against `localhost` (rather than a
35
+ * raw TCP connect to a fixed IP) confirms the server is actually serving and
36
+ * works whether it bound IPv4 (`0.0.0.0`, the SSR servers) or IPv6 (`[::1]`,
37
+ * Vite's default) — `fetch`/`localhost` resolves both families.
38
+ */
39
+ async function waitForServer(
40
+ base: string,
41
+ port: number,
42
+ timeoutMs: number,
43
+ startedAt: number,
44
+ ): Promise<void> {
45
+ const deadline = startedAt + timeoutMs;
46
+ for (;;) {
47
+ try {
48
+ // Any HTTP response (even a 404/500) means the server is up.
49
+ await fetch(`${base}/`, { signal: AbortSignal.timeout(1000) });
50
+ return;
51
+ } catch {
52
+ if (Date.now() > deadline) {
53
+ throw new Error(
54
+ `server did not respond on ${port} within ${timeoutMs}ms`,
55
+ );
56
+ }
57
+ await new Promise((r) => setTimeout(r, 150));
58
+ }
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Spawn an `npm`/`bun` script in `cwd` on a fresh OS-assigned port, wait until
64
+ * it is accepting connections, and return a handle that tears down the whole
65
+ * process group.
66
+ *
67
+ * Flakiness is designed out, not retried away:
68
+ * - **OS-assigned port** (`getFreePort` + `PORT` env) → no collisions across the
69
+ * six matrix servers.
70
+ * - **Poll until the port accepts a connection** (not a fixed sleep) → no
71
+ * readiness race; tolerates Vite's first-boot dep-optimization and the
72
+ * `preview:*` build via a generous timeout.
73
+ * - **Detached spawn + process-group kill** in `stop()` → no zombie servers
74
+ * leaking ports into the next test.
75
+ */
76
+ export async function startServer(
77
+ script: string,
78
+ cwd: string,
79
+ { timeoutMs = 180_000 }: { timeoutMs?: number } = {},
80
+ ): Promise<RunningServer> {
81
+ const port = await getFreePort();
82
+ const base = `http://localhost:${port}`;
83
+ // Capture stderr so a boot crash surfaces the real cause instead of an opaque
84
+ // readiness timeout.
85
+ const child: ChildProcess = spawn("bun", ["run", script], {
86
+ cwd,
87
+ env: { ...process.env, PORT: String(port) },
88
+ detached: true,
89
+ stdio: ["ignore", "ignore", "pipe"],
90
+ });
91
+
92
+ let stderrTail = "";
93
+ child.stderr?.on("data", (chunk: Buffer) => {
94
+ stderrTail = (stderrTail + chunk.toString()).slice(-4000);
95
+ });
96
+
97
+ // Reject readiness immediately if the child exits before it starts serving.
98
+ // Guarded so the exit fired by stop()'s SIGKILL (after readiness) is ignored.
99
+ let ready = false;
100
+ const exited = new Promise<never>((_, reject) => {
101
+ child.once("exit", (code) => {
102
+ if (ready) return;
103
+ reject(
104
+ new Error(
105
+ `\`${script}\` exited early (code ${code}) before serving.\n${stderrTail}`,
106
+ ),
107
+ );
108
+ });
109
+ });
110
+
111
+ const stop = (): Promise<void> =>
112
+ new Promise((resolve) => {
113
+ const { pid } = child;
114
+ if (pid == null || child.exitCode != null) {
115
+ resolve();
116
+ return;
117
+ }
118
+ child.once("exit", () => resolve());
119
+ try {
120
+ if (process.platform === "win32") {
121
+ // `process.kill(-pid)` (process-group kill) is POSIX-only and throws
122
+ // on Windows, which would leave the server holding its port. Use
123
+ // `taskkill /T` to terminate the child and its descendants instead.
124
+ spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
125
+ stdio: "ignore",
126
+ });
127
+ } else {
128
+ // Negative PID → kill the whole process group (the script and the
129
+ // server it spawns), so nothing is left holding the port.
130
+ process.kill(-pid, "SIGKILL");
131
+ }
132
+ } catch {
133
+ resolve();
134
+ }
135
+ });
136
+
137
+ try {
138
+ // Whichever settles first: the server responds, or the child dies.
139
+ await Promise.race([
140
+ waitForServer(base, port, timeoutMs, Date.now()),
141
+ exited,
142
+ ]);
143
+ ready = true;
144
+ } catch (error) {
145
+ await stop();
146
+ throw error;
147
+ }
148
+
149
+ // Surface (and swallow) the now-irrelevant rejection so it isn't unhandled.
150
+ exited.catch(() => {});
151
+
152
+ return { base, stop };
153
+ }
@@ -0,0 +1,99 @@
1
+ // @vitest-environment node
2
+
3
+ /**
4
+ * End-to-end test of the build: boot each of the 2×3 server scripts and assert
5
+ * it serves correctly over HTTP. The defects this guards against live at the
6
+ * HTTP/content-type layer (assets served as text/html; a 500 page), so
7
+ * fetch-level assertions catch them — no browser needed.
8
+ *
9
+ * Each cell runs its real package.json script — including the build that
10
+ * `preview:*` performs — so the test mirrors the actual user experience. The
11
+ * `dev*` cells boot in a second or two (tight readiness budget); the
12
+ * `preview*` cells build the client + compile the renderer first, so they get a
13
+ * larger budget. Kept out of the default `test` run via the `*.e2e.ts` name;
14
+ * invoke with `bun run test:e2e`.
15
+ */
16
+ import { describe, expect, it } from "vitest";
17
+ import { startServer } from "./serverHarness.js";
18
+
19
+ const CWD = process.cwd();
20
+
21
+ // Readiness budgets: dev* boots straight away; preview* builds the client +
22
+ // renderer before serving, so it needs a larger budget.
23
+ const DEV_READY_MS = 20_000;
24
+ const PREVIEW_READY_MS = 90_000;
25
+ const TEST_TIMEOUT_MS = PREVIEW_READY_MS + 30_000;
26
+
27
+ interface Cell {
28
+ /** package.json script to run. */
29
+ script: string;
30
+ /** Readiness budget for this cell's boot (and build, for preview*). */
31
+ timeoutMs: number;
32
+ /**
33
+ * Whether this cell server-renders. The plain `dev`/`preview` cells are the
34
+ * Vite SPA path (no SSR), so they have no `/sitemap.xml` route; the four
35
+ * `*:bun`/`*:express` cells render it from the sitemap renderer.
36
+ */
37
+ ssr: boolean;
38
+ }
39
+
40
+ const MATRIX: Cell[] = [
41
+ { script: "dev", timeoutMs: DEV_READY_MS, ssr: false },
42
+ { script: "dev:bun", timeoutMs: DEV_READY_MS, ssr: true },
43
+ { script: "dev:express", timeoutMs: DEV_READY_MS, ssr: true },
44
+ { script: "preview", timeoutMs: PREVIEW_READY_MS, ssr: false },
45
+ { script: "preview:bun", timeoutMs: PREVIEW_READY_MS, ssr: true },
46
+ { script: "preview:express", timeoutMs: PREVIEW_READY_MS, ssr: true },
47
+ ];
48
+
49
+ /** A JS/TS module or CSS-as-JS asset must never come back as the HTML page. */
50
+ const JS_CONTENT_TYPE = /javascript/;
51
+
52
+ describe("server matrix (2×3) serves correctly", () => {
53
+ for (const cell of MATRIX) {
54
+ it(
55
+ `${cell.script} serves an HTML page and JS assets (not HTML)`,
56
+ async () => {
57
+ const server = await startServer(cell.script, CWD, {
58
+ timeoutMs: cell.timeoutMs,
59
+ });
60
+ try {
61
+ // 1. The document renders.
62
+ const page = await fetch(`${server.base}/`);
63
+ expect(page.status).toBe(200);
64
+ expect(page.headers.get("content-type")).toMatch(/text\/html/);
65
+ const html = await page.text();
66
+ expect(html).toContain('id="root"');
67
+
68
+ // 2. The HTML references at least one client script.
69
+ const scriptSrc = html.match(/<script[^>]+src="([^"]+)"/)?.[1];
70
+ expect(
71
+ scriptSrc,
72
+ "page should reference a client script",
73
+ ).toBeTruthy();
74
+
75
+ // 3. That script is served as JavaScript — never the HTML page.
76
+ // (This is the exact defect: assets returned with a text/html MIME.)
77
+ const asset = await fetch(new URL(scriptSrc as string, server.base));
78
+ expect(asset.status).toBe(200);
79
+ expect(asset.headers.get("content-type")).toMatch(JS_CONTENT_TYPE);
80
+
81
+ // 4. SSR cells render /sitemap.xml as XML from the sitemap renderer —
82
+ // the second renderer, picked by path, never the HTML app. (The SPA
83
+ // dev/preview cells have no SSR route, so they are exempt.)
84
+ if (cell.ssr) {
85
+ const sitemap = await fetch(`${server.base}/sitemap.xml`);
86
+ expect(sitemap.status).toBe(200);
87
+ expect(sitemap.headers.get("content-type")).toMatch(/xml/);
88
+ const xml = await sitemap.text();
89
+ expect(xml).toContain("<urlset");
90
+ expect(xml).toContain("<loc>");
91
+ }
92
+ } finally {
93
+ await server.stop();
94
+ }
95
+ },
96
+ TEST_TIMEOUT_MS,
97
+ );
98
+ }
99
+ });
@@ -0,0 +1,32 @@
1
+ {
2
+ "extends": "@canonical/typescript-config-react",
3
+ "compilerOptions": {
4
+ "baseUrl": "src",
5
+ "skipLibCheck": true,
6
+ "types": [
7
+ "bun-types",
8
+ "node",
9
+ "react",
10
+ "react-dom",
11
+ "vitest/globals",
12
+ "@testing-library/jest-dom"
13
+ ],
14
+ "noEmit": true,
15
+ "paths": {
16
+ "#lib/*": ["./lib/*"],
17
+ "#domains/*": ["./domains/*"],
18
+ "#styles/*": ["./styles/*"]
19
+ }
20
+ },
21
+ "include": [
22
+ "src/**/*.ts",
23
+ "src/**/*.tsx",
24
+ ".storybook/*.ts",
25
+ ".storybook/*.tsx",
26
+ "vite.config.ts",
27
+ "vitest.setup.ts",
28
+ "vitest.config.ts",
29
+ "vitest.e2e.config.ts",
30
+ "test/**/*.ts"
31
+ ]
32
+ }
@@ -0,0 +1,45 @@
1
+ import react from "@vitejs/plugin-react";
2
+ import { defineConfig } from "vite";
3
+
4
+ // Path aliases (#lib, #domains, #styles) are declared as Node subpath imports
5
+ // in package.json "imports" and resolved natively by Vite — no resolver plugin.
6
+ const PORT = Number(process.env.PORT) || undefined;
7
+
8
+ // `build:server` runs `vite build --mode server` to compile the two server
9
+ // renderers in a single pass. Each is a separate, individually-instantiated
10
+ // Lego brick, so they are named Rollup inputs — the build emits
11
+ // `dist/server/renderer.js` (the JSX app) + `dist/server/sitemap.js` (the XML
12
+ // sitemap), which the preview servers import directly. Every other command
13
+ // (`dev`, `build:client`, `preview`) uses the default mode and the SPA client
14
+ // build (`--ssrManifest --outDir dist/client`).
15
+ export default defineConfig(({ mode }) => ({
16
+ plugins: [react()],
17
+ // Honour the PORT env var for `dev` (SPA) and `preview` so all server scripts
18
+ // — including the SSR ones, which already read PORT — respond to it uniformly.
19
+ server: { port: PORT },
20
+ preview: { port: PORT },
21
+ build:
22
+ mode === "server"
23
+ ? {
24
+ ssr: true,
25
+ outDir: "dist/server",
26
+ rollupOptions: {
27
+ input: {
28
+ renderer: "src/server/renderer.tsx",
29
+ sitemap: "src/sitemap/renderer.ts",
30
+ },
31
+ },
32
+ }
33
+ : undefined,
34
+ ssr: {
35
+ // Bundle @canonical/* for SSR rather than externalising them, for two
36
+ // reasons: (1) some packages declare only a "module" entry (no
37
+ // "main"/"exports"), which Vite's SSR (Node-style) resolver ignores —
38
+ // externalising them fails with ERR_RESOLVE_PACKAGE_ENTRY_FAIL; (2) their
39
+ // built output imports CSS as a side effect (e.g. `import "./x.css"`),
40
+ // which Node cannot load (ERR_UNKNOWN_FILE_EXTENSION) but Vite's SSR
41
+ // transform no-ops. The regex covers the whole scope so any current or
42
+ // future @canonical dependency is handled.
43
+ noExternal: [/^@canonical\//],
44
+ },
45
+ }));
@@ -0,0 +1,31 @@
1
+ import { defineConfig, mergeConfig } from "vitest/config";
2
+ import viteConfig from "./vite.config.js";
3
+
4
+ // `vite.config` is a config function (it branches on `--mode server` for the
5
+ // SSR build); resolve it for the default test run before merging.
6
+ export default mergeConfig(
7
+ viteConfig({ command: "serve", mode: "test" }),
8
+ defineConfig({
9
+ test: {
10
+ // Browser-like environment for component tests
11
+ environment: "jsdom",
12
+ // Vitest globals (describe/it/expect) without imports
13
+ globals: true,
14
+ // Extend matchers and clean up the DOM between tests
15
+ setupFiles: ["./vitest.setup.ts"],
16
+ // Repo convention: test files are named *.tests.ts(x)
17
+ include: ["src/**/*.tests.ts", "src/**/*.tests.tsx"],
18
+ coverage: {
19
+ provider: "v8",
20
+ // Thresholds start at 0 — coverage is reported but does not gate.
21
+ // Ratchet these up as the app grows.
22
+ thresholds: {
23
+ statements: 0,
24
+ branches: 0,
25
+ functions: 0,
26
+ lines: 0,
27
+ },
28
+ },
29
+ },
30
+ }),
31
+ );
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // E2e build/server tests: boot each package.json server script and assert it
4
+ // serves over HTTP. Separate from the default unit suite (vitest.config.ts) —
5
+ // node environment, serial (servers bind ports), and a long timeout because
6
+ // `preview:*` builds the client + compiles the renderer before serving.
7
+ export default defineConfig({
8
+ test: {
9
+ environment: "node",
10
+ include: ["test/**/*.e2e.ts"],
11
+ // Servers bind real ports and the preview build is heavy — run serially.
12
+ // fileParallelism:false pins maxWorkers to 1, so cells never run concurrently.
13
+ fileParallelism: false,
14
+ testTimeout: 300_000,
15
+ hookTimeout: 300_000,
16
+ },
17
+ });
@@ -0,0 +1,9 @@
1
+ import * as matchers from "@testing-library/jest-dom/matchers";
2
+ import { cleanup } from "@testing-library/react";
3
+ import { afterEach, expect } from "vitest";
4
+
5
+ expect.extend(matchers as unknown as Parameters<typeof expect.extend>[0]);
6
+
7
+ afterEach(() => {
8
+ cleanup();
9
+ });
@@ -0,0 +1,119 @@
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
+ mkdir,
12
+ sequence_,
13
+ writeFile,
14
+ } from "@canonical/task";
15
+ import { toCamelCase, toTitleCase } from "@canonical/utils";
16
+ import { normalizeCommandPath } from "../shared/casing.js";
17
+ import { printVersions } from "../shared/versions.js";
18
+
19
+ interface DomainAnswers {
20
+ readonly domainName: string;
21
+ }
22
+
23
+ const prompts: PromptDefinition[] = [
24
+ {
25
+ name: "domainName",
26
+ type: "text",
27
+ message: "Domain name (for example billing):",
28
+ default: "example",
29
+ positional: true,
30
+ group: "Domain",
31
+ },
32
+ ];
33
+
34
+ function buildMainPage(domainName: string): string {
35
+ const title = toTitleCase(domainName);
36
+
37
+ return `import { useHead } from "@canonical/react-head";
38
+ import type { ReactElement } from "react";
39
+
40
+ export default function MainPage(): ReactElement {
41
+ useHead({ title: "${title}" });
42
+
43
+ return (
44
+ <section aria-labelledby="main-title">
45
+ <h1 id="main-title">${title}</h1>
46
+ <p>This is the main page for the ${domainName} domain.</p>
47
+ </section>
48
+ );
49
+ }
50
+ `;
51
+ }
52
+
53
+ function buildRoutesFile(domainName: string): string {
54
+ const routeUrl = `/${normalizeCommandPath(domainName)}`;
55
+
56
+ return `import { route } from "@canonical/router-core";
57
+ import MainPage from "./MainPage.js";
58
+
59
+ const routes = {
60
+ ${toCamelCase(domainName)}: route({
61
+ url: "${routeUrl}",
62
+ content: MainPage,
63
+ }),
64
+ } as const;
65
+
66
+ export default routes;
67
+ `;
68
+ }
69
+
70
+ export const generator: GeneratorDefinition<DomainAnswers> = {
71
+ meta: {
72
+ name: "domain",
73
+ displayName: "@canonical/summon-application:domain",
74
+ description: "Create a domain folder with routes and a MainPage",
75
+ version: "0.1.0",
76
+ help: `Creates a domain directory under src/domains/ with:
77
+ - MainPage.tsx — example page component with useHead()
78
+ - routes.ts — route barrel exporting the domain's routes
79
+
80
+ Add more routes with: summon route <domain>/<route-name>`,
81
+ examples: [
82
+ "summon domain billing",
83
+ "summon domain user-settings",
84
+ "summon domain --dry-run billing",
85
+ ],
86
+ },
87
+
88
+ prompts,
89
+
90
+ generate: (answers) => {
91
+ const name = normalizeCommandPath(answers.domainName);
92
+ const domainDir = path.join("src", "domains", name);
93
+
94
+ const scaffold = sequence_([
95
+ printVersions("domain"),
96
+ info(`Creating domain "${name}"...`),
97
+ mkdir(domainDir),
98
+ writeFile(path.join(domainDir, "MainPage.tsx"), buildMainPage(name)),
99
+ writeFile(path.join(domainDir, "routes.ts"), buildRoutesFile(name)),
100
+ info(
101
+ `Domain "${name}" created. Import its routes in src/routes.tsx and wire them with group().`,
102
+ ),
103
+ ]);
104
+
105
+ // Refuse to run when the domain already exists — mkdir/writeFile undos are
106
+ // destructive (DeleteDirectory / DeleteFile), so overwriting then `--undo`
107
+ // could delete a pre-existing domain.
108
+ return flatMap(exists(domainDir), (present) =>
109
+ present
110
+ ? fail({
111
+ code: "DOMAIN_EXISTS",
112
+ message: `Domain "${domainDir}" already exists. Choose a different name or remove it first.`,
113
+ })
114
+ : scaffold,
115
+ );
116
+ },
117
+ };
118
+
119
+ export default generator;