@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,236 @@
1
+ import ts from "typescript";
2
+
3
+ /**
4
+ * Peel `as const` / type assertions / parentheses off an initializer to reach
5
+ * the underlying object literal (e.g. `{ ... } as const`), if any.
6
+ */
7
+ function unwrapObjectLiteral(
8
+ expr: ts.Expression,
9
+ ): ts.ObjectLiteralExpression | undefined {
10
+ let node: ts.Expression = expr;
11
+ while (
12
+ ts.isAsExpression(node) ||
13
+ ts.isParenthesizedExpression(node) ||
14
+ ts.isTypeAssertionExpression(node) ||
15
+ ts.isSatisfiesExpression(node)
16
+ ) {
17
+ node = node.expression;
18
+ }
19
+ return ts.isObjectLiteralExpression(node) ? node : undefined;
20
+ }
21
+
22
+ export interface RouteInsertion {
23
+ /** Imported page component, e.g. "InvoicesPage". */
24
+ readonly pageName: string;
25
+ /** Import specifier, e.g. "./InvoicesPage.js". */
26
+ readonly importPath: string;
27
+ /** Route key in the routes object, e.g. "invoices". */
28
+ readonly routeKey: string;
29
+ /** Route URL, e.g. "/billing/invoices". */
30
+ readonly url: string;
31
+ }
32
+
33
+ /**
34
+ * Insert a route into a domain `routes.ts` source.
35
+ *
36
+ * Uses the TypeScript compiler API only to *locate* the insertion points (the
37
+ * last import declaration and the `routes` object literal), then splices text
38
+ * at those offsets. Locating with the AST is robust to formatting; editing by
39
+ * string keeps the rest of the file byte-for-byte unchanged (no full re-print,
40
+ * so it doesn't fight the formatter).
41
+ *
42
+ * Idempotent: if `routeKey` already exists in the routes object, the source is
43
+ * returned unchanged. Throws if the `routes` object literal cannot be found.
44
+ */
45
+ export function insertRoute(source: string, ins: RouteInsertion): string {
46
+ const sf = ts.createSourceFile(
47
+ "routes.ts",
48
+ source,
49
+ ts.ScriptTarget.Latest,
50
+ /* setParentNodes */ true,
51
+ );
52
+
53
+ // Locate the `routes` object literal and the last import declaration.
54
+ let routesObject: ts.ObjectLiteralExpression | undefined;
55
+ let lastImportEnd: number | undefined;
56
+
57
+ for (const stmt of sf.statements) {
58
+ if (ts.isImportDeclaration(stmt)) {
59
+ lastImportEnd = stmt.end;
60
+ continue;
61
+ }
62
+ if (ts.isVariableStatement(stmt)) {
63
+ for (const decl of stmt.declarationList.declarations) {
64
+ if (
65
+ ts.isIdentifier(decl.name) &&
66
+ decl.name.text === "routes" &&
67
+ decl.initializer
68
+ ) {
69
+ const obj = unwrapObjectLiteral(decl.initializer);
70
+ if (obj) routesObject = obj;
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ if (!routesObject) {
77
+ throw new Error(
78
+ "insertRoute: could not find a `const routes = { ... }` object literal",
79
+ );
80
+ }
81
+
82
+ // Idempotency: skip if the key is already present.
83
+ const exists = routesObject.properties.some(
84
+ (p) =>
85
+ p.name &&
86
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
87
+ p.name.text === ins.routeKey,
88
+ );
89
+ if (exists) return source;
90
+
91
+ // Indentation of the existing properties (fall back to two spaces).
92
+ const indent = detectIndent(source, routesObject) ?? " ";
93
+
94
+ const importLine = `import ${ins.pageName} from "${ins.importPath}";\n`;
95
+ const entry =
96
+ `${indent}${ins.routeKey}: route({\n` +
97
+ `${indent}${indent}url: "${ins.url}",\n` +
98
+ `${indent}${indent}content: ${ins.pageName},\n` +
99
+ `${indent}}),\n`;
100
+
101
+ // Insert after the last existing property, or — for an empty object — just
102
+ // inside the braces. Use AST node bounds, never a brace text-scan (which can
103
+ // match a nested `route({ ... })` brace on a single-line object).
104
+ const props = routesObject.properties;
105
+ let out: string;
106
+ if (props.length > 0) {
107
+ const last = props[props.length - 1];
108
+ // Find the insertion point: just after the last property, including its
109
+ // trailing comma if it has one (so the existing comma stays attached to the
110
+ // existing property). If there's no trailing comma, add one.
111
+ let at = last.end;
112
+ let prefix = ",\n";
113
+ if (source[at] === ",") {
114
+ at += 1; // step past the existing comma
115
+ prefix = "\n";
116
+ }
117
+ out = `${source.slice(0, at)}${prefix}${entry.replace(/\n$/, "")}${source.slice(at)}`;
118
+ } else {
119
+ // Empty object: insert between the braces.
120
+ const openBrace =
121
+ routesObject.getStart(sf) +
122
+ source.slice(routesObject.getStart(sf)).indexOf("{") +
123
+ 1;
124
+ out = `${source.slice(0, openBrace)}\n${entry}${source.slice(openBrace)}`;
125
+ }
126
+
127
+ // Insert the import after the last existing import (offsets in the original
128
+ // source are still valid because the route entry was added later in the file).
129
+ if (lastImportEnd !== undefined) {
130
+ out =
131
+ out.slice(0, lastImportEnd) +
132
+ `\n${importLine.trimEnd()}` +
133
+ out.slice(lastImportEnd);
134
+ } else {
135
+ out = importLine + out;
136
+ }
137
+
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * Inverse of {@link insertRoute}: remove the route entry and its import.
143
+ *
144
+ * Used as the route generator's undo. Rather than restoring a stored copy of
145
+ * the file, it deletes exactly the lines that {@link insertRoute} added — the
146
+ * `routeKey` property in the `routes` object and the `import <pageName> ...`
147
+ * line. Idempotent: returns the source unchanged if neither is present, and
148
+ * leaves any other content (including manual edits) intact.
149
+ */
150
+ export function removeRoute(
151
+ source: string,
152
+ ins: Pick<RouteInsertion, "pageName" | "routeKey">,
153
+ ): string {
154
+ const sf = ts.createSourceFile(
155
+ "routes.ts",
156
+ source,
157
+ ts.ScriptTarget.Latest,
158
+ /* setParentNodes */ true,
159
+ );
160
+
161
+ let result = source;
162
+
163
+ // 1. Remove the route entry (the property whose name === routeKey).
164
+ for (const stmt of sf.statements) {
165
+ if (!ts.isVariableStatement(stmt)) continue;
166
+ for (const decl of stmt.declarationList.declarations) {
167
+ if (
168
+ !ts.isIdentifier(decl.name) ||
169
+ decl.name.text !== "routes" ||
170
+ !decl.initializer
171
+ ) {
172
+ continue;
173
+ }
174
+ const obj = unwrapObjectLiteral(decl.initializer);
175
+ if (!obj) continue;
176
+ const prop = obj.properties.find(
177
+ (p) =>
178
+ p.name &&
179
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
180
+ p.name.text === ins.routeKey,
181
+ );
182
+ if (prop) {
183
+ result = removeFullLines(result, prop.getStart(sf), prop.end);
184
+ }
185
+ }
186
+ }
187
+
188
+ // 2. Remove the import line for the page component.
189
+ // Re-parse because offsets shifted after the entry removal.
190
+ const sf2 = ts.createSourceFile(
191
+ "routes.ts",
192
+ result,
193
+ ts.ScriptTarget.Latest,
194
+ true,
195
+ );
196
+ for (const stmt of sf2.statements) {
197
+ if (
198
+ ts.isImportDeclaration(stmt) &&
199
+ stmt.importClause?.name?.text === ins.pageName &&
200
+ // Only remove a pure default import (`import Page from "..."`), which is
201
+ // exactly what insertRoute creates. If a user merged named bindings into
202
+ // it (`import Page, { x } from "..."`), leave it — removing the whole line
203
+ // would discard their bindings.
204
+ !stmt.importClause.namedBindings
205
+ ) {
206
+ result = removeFullLines(result, stmt.getStart(sf2), stmt.end);
207
+ break;
208
+ }
209
+ }
210
+
211
+ return result;
212
+ }
213
+
214
+ /**
215
+ * Remove the whole-line span covering [start, end), including the node's
216
+ * leading indentation and its trailing newline, so no blank line is left behind.
217
+ */
218
+ function removeFullLines(source: string, start: number, end: number): string {
219
+ const lineStart = source.lastIndexOf("\n", start - 1) + 1;
220
+ let lineEnd = source.indexOf("\n", end);
221
+ if (lineEnd === -1) lineEnd = source.length;
222
+ else lineEnd += 1; // include the newline
223
+ return source.slice(0, lineStart) + source.slice(lineEnd);
224
+ }
225
+
226
+ /** Detect the indentation used by the first property of the object literal. */
227
+ function detectIndent(
228
+ source: string,
229
+ obj: ts.ObjectLiteralExpression,
230
+ ): string | undefined {
231
+ const first = obj.properties[0];
232
+ if (!first) return undefined;
233
+ const lineStart = source.lastIndexOf("\n", first.getStart()) + 1;
234
+ const ws = source.slice(lineStart, first.getStart());
235
+ return /^\s+$/.test(ws) ? ws : undefined;
236
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Normalize a command path argument: trim, remove leading/trailing slashes,
3
+ * convert backslashes to forward slashes.
4
+ *
5
+ * This is generator-specific — it normalizes CLI input like
6
+ * `" /billing/invoices/ "` → `"billing/invoices"`.
7
+ */
8
+ export function normalizeCommandPath(value: string): string {
9
+ return value
10
+ .trim()
11
+ .replace(/\\/g, "/")
12
+ .replace(/^\/+/, "")
13
+ .replace(/\/+$/, "");
14
+ }
@@ -0,0 +1,48 @@
1
+ import { createRequire } from "node:module";
2
+ import type { Task } from "@canonical/task";
3
+ import { info } from "@canonical/task";
4
+
5
+ /**
6
+ * Version range for the pragma workspace packages a generated app depends on
7
+ * directly — react-ds-global(-form), react-head, react-hooks, react-ssr,
8
+ * router-core, router-react, styles, storybook-config, biome-config,
9
+ * typescript-config-react. These share one lerna-managed release line.
10
+ *
11
+ * Scope notes:
12
+ * - Third-party deps (react, express, vite…) keep their own ranges.
13
+ * - `@canonical/design-tokens` is versioned SEPARATELY and is NOT pinned here —
14
+ * it reaches the app transitively via `@canonical/styles`, which owns its range.
15
+ *
16
+ * A single hand-maintained constant — NOT read from a package.json at runtime,
17
+ * because the generator ships as a compiled binary where such a read would
18
+ * resolve to "unknown". Bump in lockstep with the lerna release.
19
+ *
20
+ * Open question: inject this at binary-build time once the compile pipeline
21
+ * supports it, so it cannot drift from the release. See README "Open questions".
22
+ */
23
+ export const PRAGMA_WORKSPACE_VERSION = "^0.27.1-experimental.0";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ function readVersion(packageName: string): string {
28
+ try {
29
+ const pkg = require(`${packageName}/package.json`);
30
+
31
+ return pkg.version ?? "unknown";
32
+ } catch {
33
+ return "unknown";
34
+ }
35
+ }
36
+
37
+ const coreVersion = readVersion("@canonical/summon-core");
38
+ const appVersion = readVersion("@canonical/summon-application");
39
+
40
+ /**
41
+ * Print a version table for the current generator run.
42
+ */
43
+ export function printVersions(generatorName: string): Task<void> {
44
+ return info(
45
+ `@canonical/summon-core ${coreVersion}\n` +
46
+ `@canonical/summon-application ${appVersion} (${generatorName})`,
47
+ );
48
+ }
@@ -0,0 +1,100 @@
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 { toKebabCase, toPascalCase } from "@canonical/utils";
16
+ import { normalizeCommandPath } from "../shared/casing.js";
17
+
18
+ interface WrapperAnswers {
19
+ readonly wrapperName: string;
20
+ }
21
+
22
+ const prompts: PromptDefinition[] = [
23
+ {
24
+ name: "wrapperName",
25
+ type: "text",
26
+ message: "Wrapper name (for example settings):",
27
+ default: "example",
28
+ positional: true,
29
+ group: "Wrapper",
30
+ },
31
+ ];
32
+
33
+ function buildLayout(wrapperName: string): string {
34
+ const layoutName = `${toPascalCase(wrapperName)}Layout`;
35
+ const className = `${toKebabCase(wrapperName)}-layout`;
36
+
37
+ return `import type { ReactNode, ReactElement } from "react";
38
+
39
+ export default function ${layoutName}({
40
+ children,
41
+ }: { children: ReactNode }): ReactElement {
42
+ return <div className="${className}">{children}</div>;
43
+ }
44
+ `;
45
+ }
46
+
47
+ function buildBarrel(wrapperName: string): string {
48
+ const layoutName = `${toPascalCase(wrapperName)}Layout`;
49
+
50
+ return `export { default } from "./${layoutName}.js";
51
+ `;
52
+ }
53
+
54
+ export const generator: GeneratorDefinition<WrapperAnswers> = {
55
+ meta: {
56
+ name: "wrapper",
57
+ displayName: "@canonical/summon-application:wrapper",
58
+ description: "Create a layout wrapper component",
59
+ version: "0.1.0",
60
+ help: `Creates a layout wrapper component under src/lib/.
61
+
62
+ Given a name like "settings", creates:
63
+ - src/lib/SettingsLayout/SettingsLayout.tsx
64
+ - src/lib/SettingsLayout/index.ts (barrel export)`,
65
+ examples: [
66
+ "summon wrapper settings",
67
+ "summon wrapper sidebar",
68
+ "summon wrapper --dry-run dashboard",
69
+ ],
70
+ },
71
+
72
+ prompts,
73
+
74
+ generate: (answers) => {
75
+ const name = normalizeCommandPath(answers.wrapperName);
76
+ const layoutName = `${toPascalCase(name)}Layout`;
77
+ const layoutDir = path.join("src", "lib", layoutName);
78
+
79
+ const scaffold = sequence_([
80
+ info(`Creating wrapper "${layoutName}"...`),
81
+ mkdir(layoutDir),
82
+ writeFile(path.join(layoutDir, `${layoutName}.tsx`), buildLayout(name)),
83
+ writeFile(path.join(layoutDir, "index.ts"), buildBarrel(name)),
84
+ info(`Wrapper "${layoutName}" created at ${layoutDir}.`),
85
+ ]);
86
+
87
+ // Refuse to run when the layout already exists — mkdir/writeFile undos are
88
+ // destructive, so overwriting then `--undo` could delete a pre-existing one.
89
+ return flatMap(exists(layoutDir), (present) =>
90
+ present
91
+ ? fail({
92
+ code: "WRAPPER_EXISTS",
93
+ message: `Wrapper "${layoutDir}" already exists. Choose a different name or remove it first.`,
94
+ })
95
+ : scaffold,
96
+ );
97
+ },
98
+ };
99
+
100
+ export default generator;