@canonical/summon-package 0.1.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.
@@ -0,0 +1,330 @@
1
+ /**
2
+ * Shared utilities for package generator
3
+ */
4
+
5
+ import * as path from "node:path";
6
+ import {
7
+ exists,
8
+ flatMap,
9
+ ifElseM,
10
+ pure,
11
+ readFile,
12
+ type Task,
13
+ } from "@canonical/summon";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ export type PackageType = "tool-ts" | "library" | "css";
20
+
21
+ export type PackageManager = "bun" | "npm" | "yarn" | "pnpm";
22
+
23
+ export interface PackageAnswers {
24
+ /** Full package name (e.g., @canonical/my-package or my-package) */
25
+ name: string;
26
+ /** Package type */
27
+ type: PackageType;
28
+ /** Package description */
29
+ description: string;
30
+ /** Include React dependencies */
31
+ withReact: boolean;
32
+ /** Include Storybook setup */
33
+ withStorybook: boolean;
34
+ /** Include CLI binary entry point */
35
+ withCli: boolean;
36
+ /** Run package manager install after creation */
37
+ runInstall: boolean;
38
+ }
39
+
40
+ export interface MonorepoInfo {
41
+ isMonorepo: boolean;
42
+ version?: string;
43
+ }
44
+
45
+ // =============================================================================
46
+ // Validation
47
+ // =============================================================================
48
+
49
+ /**
50
+ * Validate npm package name
51
+ * Supports scoped packages (@scope/name) and unscoped packages
52
+ * Rules: lowercase, can contain hyphens, can't start/end with hyphen
53
+ */
54
+ export const validatePackageName = (value: unknown): true | string => {
55
+ if (!value || typeof value !== "string") {
56
+ return "Package name is required";
57
+ }
58
+
59
+ // Extract the package name (handle scoped packages)
60
+ const name = getPackageShortName(value);
61
+
62
+ if (!/^[a-z][a-z0-9-]*[a-z0-9]$|^[a-z]$/.test(name)) {
63
+ return "Package name must be lowercase, can contain hyphens, but cannot start or end with a hyphen";
64
+ }
65
+
66
+ if (value.length > 214) {
67
+ return "Package name cannot be longer than 214 characters";
68
+ }
69
+
70
+ return true;
71
+ };
72
+
73
+ /**
74
+ * Extract the short name from a package name (removes scope if present)
75
+ * @canonical/my-package -> my-package
76
+ * my-package -> my-package
77
+ */
78
+ export const getPackageShortName = (fullName: string): string => {
79
+ const match = fullName.match(/^@[^/]+\/(.+)$/);
80
+ return match ? match[1] : fullName;
81
+ };
82
+
83
+ /**
84
+ * Get the directory name for a package (short name, no scope)
85
+ */
86
+ export const getPackageDir = (fullName: string): string => {
87
+ return getPackageShortName(fullName);
88
+ };
89
+
90
+ // =============================================================================
91
+ // String Helpers
92
+ // =============================================================================
93
+
94
+ /**
95
+ * Convert string to kebab-case
96
+ */
97
+ export const kebabCase = (str: string): string => {
98
+ return str
99
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
100
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
101
+ .replace(/[\s_]+/g, "-")
102
+ .toLowerCase();
103
+ };
104
+
105
+ /**
106
+ * Convert string to PascalCase
107
+ */
108
+ export const pascalCase = (str: string): string => {
109
+ return str
110
+ .split(/[-_\s]+/)
111
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
112
+ .join("");
113
+ };
114
+
115
+ // =============================================================================
116
+ // Detection Utilities
117
+ // =============================================================================
118
+
119
+ /**
120
+ * Detect if running in a monorepo and get the version
121
+ */
122
+ export const detectMonorepo = (cwd: string): Task<MonorepoInfo> => {
123
+ // Look for lerna.json in parent directories
124
+ const lernaPath = path.join(cwd, "lerna.json");
125
+ const parentLernaPath = path.join(cwd, "..", "lerna.json");
126
+ const grandparentLernaPath = path.join(cwd, "..", "..", "lerna.json");
127
+
128
+ const parseLerna = (content: string): MonorepoInfo => {
129
+ const lerna = JSON.parse(content);
130
+ return { isMonorepo: true, version: lerna.version };
131
+ };
132
+
133
+ const notMonorepo: MonorepoInfo = { isMonorepo: false };
134
+
135
+ return flatMap(exists(lernaPath), (hasLerna) => {
136
+ if (hasLerna) {
137
+ return flatMap(readFile(lernaPath), (content) =>
138
+ pure(parseLerna(content)),
139
+ );
140
+ }
141
+ return flatMap(exists(parentLernaPath), (hasParent) => {
142
+ if (hasParent) {
143
+ return flatMap(readFile(parentLernaPath), (content) =>
144
+ pure(parseLerna(content)),
145
+ );
146
+ }
147
+ return flatMap(exists(grandparentLernaPath), (hasGrandparent) => {
148
+ if (hasGrandparent) {
149
+ return flatMap(readFile(grandparentLernaPath), (content) =>
150
+ pure(parseLerna(content)),
151
+ );
152
+ }
153
+ return pure(notMonorepo);
154
+ });
155
+ });
156
+ });
157
+ };
158
+
159
+ /**
160
+ * Detect the package manager in use
161
+ */
162
+ export const detectPackageManager = (cwd: string): Task<PackageManager> => {
163
+ const bunLock = path.join(cwd, "bun.lockb");
164
+ const bunLock2 = path.join(cwd, "bun.lock");
165
+ const yarnLock = path.join(cwd, "yarn.lock");
166
+ const pnpmLock = path.join(cwd, "pnpm-lock.yaml");
167
+
168
+ // Also check parent directories for monorepo setup
169
+ const parentBunLock = path.join(cwd, "..", "..", "bun.lockb");
170
+ const parentBunLock2 = path.join(cwd, "..", "..", "bun.lock");
171
+
172
+ return ifElseM(
173
+ exists(bunLock),
174
+ pure("bun" as const),
175
+ ifElseM(
176
+ exists(bunLock2),
177
+ pure("bun" as const),
178
+ ifElseM(
179
+ exists(parentBunLock),
180
+ pure("bun" as const),
181
+ ifElseM(
182
+ exists(parentBunLock2),
183
+ pure("bun" as const),
184
+ ifElseM(
185
+ exists(yarnLock),
186
+ pure("yarn" as const),
187
+ ifElseM(
188
+ exists(pnpmLock),
189
+ pure("pnpm" as const),
190
+ pure("bun" as const),
191
+ ),
192
+ ),
193
+ ),
194
+ ),
195
+ ),
196
+ );
197
+ };
198
+
199
+ // =============================================================================
200
+ // Configuration Helpers
201
+ // =============================================================================
202
+
203
+ /**
204
+ * Get the webarchitect ruleset based on package type and options
205
+ */
206
+ export const getRuleset = (type: PackageType, withReact: boolean): string => {
207
+ if (withReact) return "package-react";
208
+ if (type === "css") return "base"; // CSS packages use base ruleset
209
+ return type; // "tool-ts" or "library"
210
+ };
211
+
212
+ /**
213
+ * Get license based on package type
214
+ */
215
+ export const getLicense = (type: PackageType): string => {
216
+ if (type === "tool-ts") return "GPL-3.0";
217
+ return "LGPL-3.0"; // library and css use LGPL
218
+ };
219
+
220
+ /**
221
+ * Get package entry points based on type
222
+ */
223
+ export const getEntryPoints = (
224
+ type: PackageType,
225
+ ): {
226
+ module: string;
227
+ types: string | null;
228
+ files: string[];
229
+ needsBuild: boolean;
230
+ } => {
231
+ if (type === "tool-ts") {
232
+ return {
233
+ module: "src/index.ts",
234
+ types: "src/index.ts",
235
+ files: ["src"],
236
+ needsBuild: false,
237
+ };
238
+ }
239
+ if (type === "css") {
240
+ return {
241
+ module: "src/index.css",
242
+ types: null, // CSS packages don't have types
243
+ files: ["src"],
244
+ needsBuild: false,
245
+ };
246
+ }
247
+ // library
248
+ return {
249
+ module: "dist/esm/index.js",
250
+ types: "dist/types/index.d.ts",
251
+ files: ["dist"],
252
+ needsBuild: true,
253
+ };
254
+ };
255
+
256
+ // =============================================================================
257
+ // Template Context
258
+ // =============================================================================
259
+
260
+ export interface TemplateContext {
261
+ /** Package short name (without scope) */
262
+ shortName: string;
263
+ /** Full package name (as entered, e.g., @canonical/my-package) */
264
+ name: string;
265
+ /** Package description */
266
+ description: string;
267
+ /** Package type */
268
+ type: PackageType;
269
+ /** Package version */
270
+ version: string;
271
+ /** License */
272
+ license: string;
273
+ /** Module entry point */
274
+ module: string;
275
+ /** Types entry point (null for CSS packages) */
276
+ types: string | null;
277
+ /** Files to include */
278
+ files: string[];
279
+ /** Whether this package type needs a build step */
280
+ needsBuild: boolean;
281
+ /** Webarchitect ruleset */
282
+ ruleset: string;
283
+ /** Include React */
284
+ withReact: boolean;
285
+ /** Include Storybook */
286
+ withStorybook: boolean;
287
+ /** Include CLI */
288
+ withCli: boolean;
289
+ /** Monorepo version (if applicable) */
290
+ monorepoVersion?: string;
291
+ /** Generator name */
292
+ generatorName: string;
293
+ /** Generator version */
294
+ generatorVersion: string;
295
+ /** Index signature for EJS compatibility */
296
+ [key: string]: unknown;
297
+ }
298
+
299
+ /**
300
+ * Create template context from answers
301
+ */
302
+ export const createTemplateContext = (
303
+ answers: PackageAnswers,
304
+ monorepoInfo: MonorepoInfo,
305
+ ): TemplateContext => {
306
+ const entryPoints = getEntryPoints(answers.type);
307
+ const version = monorepoInfo.isMonorepo
308
+ ? (monorepoInfo.version ?? "0.1.0")
309
+ : "0.1.0";
310
+
311
+ return {
312
+ shortName: getPackageShortName(answers.name),
313
+ name: answers.name,
314
+ description: answers.description,
315
+ type: answers.type,
316
+ version,
317
+ license: getLicense(answers.type),
318
+ module: entryPoints.module,
319
+ types: entryPoints.types,
320
+ files: entryPoints.files,
321
+ needsBuild: entryPoints.needsBuild,
322
+ ruleset: getRuleset(answers.type, answers.withReact),
323
+ withReact: answers.withReact,
324
+ withStorybook: answers.withStorybook,
325
+ withCli: answers.withCli,
326
+ monorepoVersion: monorepoInfo.version,
327
+ generatorName: "@canonical/summon-package",
328
+ generatorVersion: "0.1.0",
329
+ };
330
+ };
@@ -0,0 +1,29 @@
1
+ # <%= name %>
2
+
3
+ <%= description || 'TODO: Add description' %>
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ bun add <%= name %>
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```typescript
14
+ import { } from "<%= name %>";
15
+ ```
16
+
17
+ ## Development
18
+
19
+ ```bash
20
+ # Run checks
21
+ bun run check
22
+
23
+ # Run tests
24
+ bun run test
25
+ ```
26
+
27
+ ## License
28
+
29
+ <%= license %>
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": ["@canonical/biome-config"],
3
+ "files": {
4
+ "includes": ["src", "*.json"]
5
+ }
6
+ }
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * <%= name %> CLI
4
+ *
5
+ * <%= description || 'TODO: Add description' %>
6
+ */
7
+
8
+ console.log("Hello from <%= shortName %>!");
@@ -0,0 +1,5 @@
1
+ /**
2
+ * <%= name %>
3
+ *
4
+ * <%= description || 'TODO: Add description' %>
5
+ */
@@ -0,0 +1,7 @@
1
+ /**
2
+ * <%= name %>
3
+ *
4
+ * <%= description || 'TODO: Add description' %>
5
+ */
6
+
7
+ export {};
@@ -0,0 +1,112 @@
1
+ {
2
+ "name": "<%= name %>",
3
+ "description": "<%= description %>",
4
+ "version": "<%= version %>",
5
+ "type": "module",
6
+ <% if (type === 'tool-ts') { -%>
7
+ "module": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ <% } else if (type === 'css') { -%>
10
+ "module": "src/index.css",
11
+ <% } else { -%>
12
+ "module": "dist/esm/index.js",
13
+ "types": "dist/types/index.d.ts",
14
+ <% } -%>
15
+ <% if (withCli && type !== 'css') { -%>
16
+ "bin": {
17
+ "<%= shortName %>": "src/cli.ts"
18
+ },
19
+ <% } -%>
20
+ "files": [
21
+ <% if (type === 'tool-ts' || type === 'css') { -%>
22
+ "src"
23
+ <% } else { -%>
24
+ "dist"
25
+ <% } -%>
26
+ ],
27
+ "author": {
28
+ "email": "webteam@canonical.com",
29
+ "name": "Canonical Webteam"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "https://github.com/canonical/pragma"
34
+ },
35
+ "license": "<%= license %>",
36
+ "bugs": {
37
+ "url": "https://github.com/canonical/pragma/issues"
38
+ },
39
+ "homepage": "https://github.com/canonical/pragma#readme",
40
+ "scripts": {
41
+ <% if (type === 'tool-ts' || type === 'css') { -%>
42
+ "build": "echo 'No build needed'",
43
+ <% } else { -%>
44
+ "build": "tsc -p tsconfig.build.json",
45
+ <% } -%>
46
+ <% if (withStorybook) { -%>
47
+ "build:all": "bun run build && bun run build:storybook",
48
+ "build:storybook": "storybook build",
49
+ <% } else { -%>
50
+ "build:all": "bun run build",
51
+ <% } -%>
52
+ <% if (type === 'css') { -%>
53
+ "check": "bun run check:biome && bun run check:webarchitect",
54
+ "check:fix": "bun run check:biome:fix",
55
+ <% } else { -%>
56
+ "check": "bun run check:biome && bun run check:ts && bun run check:webarchitect",
57
+ "check:fix": "bun run check:biome:fix && bun run check:ts",
58
+ <% } -%>
59
+ "check:biome": "biome check",
60
+ "check:biome:fix": "biome check --write",
61
+ <% if (type !== 'css') { -%>
62
+ "check:ts": "tsc --noEmit",
63
+ <% } -%>
64
+ "check:webarchitect": "webarchitect <%= ruleset %>",
65
+ <% if (withStorybook) { -%>
66
+ "storybook": "storybook dev -p 6006 --no-open --host 0.0.0.0",
67
+ <% } -%>
68
+ <% if (type === 'css') { -%>
69
+ "test": "echo 'No tests for CSS package'"
70
+ <% } else { -%>
71
+ "test": "vitest run"
72
+ <% } -%>
73
+ },
74
+ <% if (type !== 'css') { -%>
75
+ "devDependencies": {
76
+ "@biomejs/biome": "2.3.11",
77
+ "@canonical/biome-config": "^<%= monorepoVersion || '0.1.0' %>",
78
+ <% if (withReact) { -%>
79
+ "@canonical/typescript-config-react": "^<%= monorepoVersion || '0.1.0' %>",
80
+ <% } else { -%>
81
+ "@canonical/typescript-config-base": "^<%= monorepoVersion || '0.1.0' %>",
82
+ <% } -%>
83
+ <% if (withStorybook) { -%>
84
+ "@chromatic-com/storybook": "^5.0.0",
85
+ <% } -%>
86
+ <% if (withReact) { -%>
87
+ "@types/react": "^19.0.0",
88
+ "@types/react-dom": "^19.0.0",
89
+ <% } -%>
90
+ "bun-types": "^1.0.0",
91
+ <% if (withStorybook) { -%>
92
+ "storybook": "^10.1.11",
93
+ <% } -%>
94
+ "typescript": "^5.9.3",
95
+ "vitest": "^3.2.4"
96
+ },
97
+ "dependencies": {
98
+ <% if (withStorybook) { -%>
99
+ "@canonical/storybook-config": "^<%= monorepoVersion || '0.1.0' %>",
100
+ <% } -%>
101
+ <% if (withReact) { -%>
102
+ "react": "^19.0.0",
103
+ "react-dom": "^19.0.0"
104
+ <% } -%>
105
+ }
106
+ <% } else { -%>
107
+ "devDependencies": {
108
+ "@biomejs/biome": "2.3.11",
109
+ "@canonical/biome-config": "^<%= monorepoVersion || '0.1.0' %>"
110
+ }
111
+ <% } -%>
112
+ }
@@ -0,0 +1,5 @@
1
+ import { createConfig } from "@canonical/storybook-config";
2
+
3
+ export default createConfig({
4
+ staticDirs: ["../src/assets", "../public"],
5
+ });
@@ -0,0 +1,26 @@
1
+ import { withThemeByClassName } from "@storybook/addon-themes";
2
+ import type { Preview, ReactRenderer } from "@storybook/react-vite";
3
+
4
+ import "index.css";
5
+ import "@canonical/styles-debug/baseline-grid";
6
+
7
+ const preview: Preview = {
8
+ tags: ["autodocs"],
9
+ decorators: [
10
+ withThemeByClassName<ReactRenderer>({
11
+ themes: {
12
+ light: "is-light",
13
+ dark: "is-dark",
14
+ paper: "is-paper",
15
+ },
16
+ defaultTheme: "light",
17
+ }),
18
+ ],
19
+ parameters: {
20
+ docs: {
21
+ codePanel: true,
22
+ },
23
+ },
24
+ };
25
+
26
+ export default preview;
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@canonical/typescript-config-react",
3
+ "compilerOptions": {
4
+ "baseUrl": "src",
5
+ "types": ["bun-types"]
6
+ },
7
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
8
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "@canonical/typescript-config-base",
3
+ "compilerOptions": {
4
+ "baseUrl": "src",
5
+ "types": ["bun-types"]
6
+ },
7
+ "include": ["src/**/*.ts"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "@canonical/typescript-config-base",
3
+ "compilerOptions": {
4
+ "types": ["node", "bun-types"],
5
+ "jsx": "react-jsx",
6
+ "skipLibCheck": true
7
+ },
8
+ "include": ["src/**/*.ts"]
9
+ }