@canonical/summon 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.
Files changed (45) hide show
  1. package/README.md +439 -0
  2. package/generators/example/hello/index.ts +132 -0
  3. package/generators/example/hello/templates/README.md.ejs +20 -0
  4. package/generators/example/hello/templates/index.ts.ejs +9 -0
  5. package/generators/example/webapp/index.ts +509 -0
  6. package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
  7. package/generators/example/webapp/templates/App.tsx.ejs +86 -0
  8. package/generators/example/webapp/templates/README.md.ejs +154 -0
  9. package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
  10. package/generators/example/webapp/templates/app.ts.ejs +132 -0
  11. package/generators/example/webapp/templates/feature.ts.ejs +264 -0
  12. package/generators/example/webapp/templates/index.html.ejs +20 -0
  13. package/generators/example/webapp/templates/main.tsx.ejs +43 -0
  14. package/generators/example/webapp/templates/styles.css.ejs +135 -0
  15. package/generators/init/index.ts +124 -0
  16. package/generators/init/templates/generator.ts.ejs +85 -0
  17. package/generators/init/templates/template-index.ts.ejs +9 -0
  18. package/generators/init/templates/template-test.ts.ejs +8 -0
  19. package/package.json +64 -0
  20. package/src/__tests__/combinators.test.ts +895 -0
  21. package/src/__tests__/dry-run.test.ts +927 -0
  22. package/src/__tests__/effect.test.ts +816 -0
  23. package/src/__tests__/interpreter.test.ts +673 -0
  24. package/src/__tests__/primitives.test.ts +970 -0
  25. package/src/__tests__/task.test.ts +929 -0
  26. package/src/__tests__/template.test.ts +666 -0
  27. package/src/cli-format.ts +165 -0
  28. package/src/cli-types.ts +53 -0
  29. package/src/cli.tsx +1322 -0
  30. package/src/combinators.ts +294 -0
  31. package/src/completion.ts +488 -0
  32. package/src/components/App.tsx +960 -0
  33. package/src/components/ExecutionProgress.tsx +205 -0
  34. package/src/components/FileTreePreview.tsx +97 -0
  35. package/src/components/PromptSequence.tsx +483 -0
  36. package/src/components/Spinner.tsx +36 -0
  37. package/src/components/index.ts +16 -0
  38. package/src/dry-run.ts +434 -0
  39. package/src/effect.ts +224 -0
  40. package/src/index.ts +266 -0
  41. package/src/interpreter.ts +463 -0
  42. package/src/primitives.ts +442 -0
  43. package/src/task.ts +245 -0
  44. package/src/template.ts +537 -0
  45. package/src/types.ts +453 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Template Engine
3
+ *
4
+ * This module provides template rendering utilities for generators.
5
+ * Templates are a key part of code generation, allowing dynamic file creation.
6
+ *
7
+ * By default uses EJS, but supports custom templating engines via the
8
+ * TemplatingEngine interface.
9
+ */
10
+
11
+ import * as path from "node:path";
12
+ import * as ejs from "ejs";
13
+ import { sequence_ } from "./combinators.js";
14
+ import { glob, mkdir, readFile, writeFile } from "./primitives.js";
15
+ import { task } from "./task.js";
16
+ import type { Task } from "./types.js";
17
+
18
+ // =============================================================================
19
+ // Templating Engine Interface
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Abstract interface for templating engines.
24
+ *
25
+ * Implement this interface to use alternative template engines
26
+ * (e.g., Handlebars, Mustache, Nunjucks) with the summon generator.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const handlebarsEngine: TemplatingEngine = {
31
+ * render: (tpl, vars) => Handlebars.compile(tpl)(vars),
32
+ * renderAsync: async (tpl, vars) => Handlebars.compile(tpl)(vars),
33
+ * renderFile: async (path, vars) => {
34
+ * const tpl = await fs.readFile(path, "utf-8");
35
+ * return Handlebars.compile(tpl)(vars);
36
+ * },
37
+ * };
38
+ * ```
39
+ */
40
+ export interface TemplatingEngine {
41
+ /**
42
+ * Render a template string with variables (synchronous).
43
+ * Used for quick inline rendering like destination paths.
44
+ */
45
+ render(template: string, vars: Record<string, unknown>): string;
46
+
47
+ /**
48
+ * Render a template string with variables (asynchronous).
49
+ * Useful when templates may include async operations.
50
+ */
51
+ renderAsync(template: string, vars: Record<string, unknown>): Promise<string>;
52
+
53
+ /**
54
+ * Render a template file with variables (asynchronous).
55
+ * Implementations may leverage caching or streaming.
56
+ */
57
+ renderFile(
58
+ templatePath: string,
59
+ vars: Record<string, unknown>,
60
+ ): Promise<string>;
61
+ }
62
+
63
+ // =============================================================================
64
+ // EJS Engine Implementation
65
+ // =============================================================================
66
+
67
+ /**
68
+ * Default EJS templating engine implementation.
69
+ */
70
+ export const ejsEngine: TemplatingEngine = {
71
+ render(template, vars) {
72
+ return ejs.render(template, vars, { async: false });
73
+ },
74
+
75
+ async renderAsync(template, vars) {
76
+ return ejs.render(template, vars, { async: true }) as Promise<string>;
77
+ },
78
+
79
+ async renderFile(templatePath, vars) {
80
+ return ejs.renderFile(templatePath, vars, { async: true });
81
+ },
82
+ };
83
+
84
+ // =============================================================================
85
+ // Stamp Options
86
+ // =============================================================================
87
+
88
+ export interface StampOptions {
89
+ /** Generator name (e.g., "@canonical/summon-package") */
90
+ generator: string;
91
+ /** Generator version (e.g., "0.1.0") */
92
+ version: string;
93
+ }
94
+
95
+ // =============================================================================
96
+ // Template Options
97
+ // =============================================================================
98
+
99
+ export interface TemplateOptions {
100
+ /** Path to the template file */
101
+ source: string;
102
+ /** Destination path (can contain template variables) */
103
+ dest: string;
104
+ /** Variables to pass to the template */
105
+ vars: Record<string, unknown>;
106
+ /** Templating engine to use (defaults to EJS) */
107
+ engine?: TemplatingEngine;
108
+ }
109
+
110
+ export interface TemplateDirOptions {
111
+ /** Source directory containing templates */
112
+ source: string;
113
+ /** Destination directory */
114
+ dest: string;
115
+ /** Variables to pass to all templates */
116
+ vars: Record<string, unknown>;
117
+ /** File rename mappings (original -> new name, supports template vars) */
118
+ rename?: Record<string, string>;
119
+ /** Glob patterns to ignore */
120
+ ignore?: string[];
121
+ /** Content transformers by file extension or name */
122
+ transform?: Record<string, (content: string) => string>;
123
+ /** Templating engine to use (defaults to EJS) */
124
+ engine?: TemplatingEngine;
125
+ }
126
+
127
+ // =============================================================================
128
+ // String Rendering
129
+ // =============================================================================
130
+
131
+ /**
132
+ * Render a template string with variables.
133
+ * @param engine - Templating engine to use (defaults to EJS)
134
+ */
135
+ export const renderString = (
136
+ template: string,
137
+ vars: Record<string, unknown>,
138
+ engine: TemplatingEngine = ejsEngine,
139
+ ): string => {
140
+ return engine.render(template, vars);
141
+ };
142
+
143
+ /**
144
+ * Render a template string asynchronously.
145
+ * @param engine - Templating engine to use (defaults to EJS)
146
+ */
147
+ export const renderStringAsync = async (
148
+ template: string,
149
+ vars: Record<string, unknown>,
150
+ engine: TemplatingEngine = ejsEngine,
151
+ ): Promise<string> => {
152
+ return engine.renderAsync(template, vars);
153
+ };
154
+
155
+ // =============================================================================
156
+ // File Rendering
157
+ // =============================================================================
158
+
159
+ /**
160
+ * Render a template file with variables.
161
+ * @param engine - Templating engine to use (defaults to EJS)
162
+ */
163
+ export const renderFile = async (
164
+ templatePath: string,
165
+ vars: Record<string, unknown>,
166
+ engine: TemplatingEngine = ejsEngine,
167
+ ): Promise<string> => {
168
+ return engine.renderFile(templatePath, vars);
169
+ };
170
+
171
+ // =============================================================================
172
+ // Template Tasks
173
+ // =============================================================================
174
+
175
+ /**
176
+ * Render a single template file to a destination.
177
+ */
178
+ export const template = (options: TemplateOptions): Task<void> => {
179
+ const engine = options.engine ?? ejsEngine;
180
+
181
+ // Render destination path with variables
182
+ const destPath = renderString(options.dest, options.vars, engine);
183
+ const destDir = path.dirname(destPath);
184
+
185
+ return task(mkdir(destDir))
186
+ .chain(() => task(readFile(options.source)))
187
+ .map((content) => renderString(content, options.vars, engine))
188
+ .chain((rendered) => task(writeFile(destPath, rendered)))
189
+ .unwrap();
190
+ };
191
+
192
+ /**
193
+ * Render a directory of templates to a destination.
194
+ */
195
+ export const templateDir = (options: TemplateDirOptions): Task<void> => {
196
+ const engine = options.engine ?? ejsEngine;
197
+
198
+ return task(glob("**/*", options.source))
199
+ .chain((files) => {
200
+ const tasks = files
201
+ .filter((file) => {
202
+ // Filter out ignored patterns
203
+ if (options.ignore) {
204
+ return !options.ignore.some((pattern) => minimatch(file, pattern));
205
+ }
206
+ return true;
207
+ })
208
+ .map((file) => {
209
+ const sourcePath = path.join(options.source, file);
210
+
211
+ // Remove .ejs extension if present
212
+ let destFile = file.replace(/\.ejs$/, "");
213
+
214
+ // Apply renames
215
+ if (options.rename?.[destFile]) {
216
+ destFile = options.rename[destFile];
217
+ }
218
+
219
+ // Render destination path with variables
220
+ destFile = renderString(destFile, options.vars, engine);
221
+ const destPath = path.join(options.dest, destFile);
222
+
223
+ return template({
224
+ source: sourcePath,
225
+ dest: destPath,
226
+ vars: options.vars,
227
+ engine,
228
+ });
229
+ });
230
+
231
+ return task(sequence_(tasks));
232
+ })
233
+ .unwrap();
234
+ };
235
+
236
+ /**
237
+ * Simple minimatch implementation for common patterns.
238
+ */
239
+ const minimatch = (filepath: string, pattern: string): boolean => {
240
+ // Convert glob pattern to regex
241
+ const regex = pattern
242
+ .replace(/\./g, "\\.")
243
+ .replace(/\*\*/g, "<<GLOBSTAR>>")
244
+ .replace(/\*/g, "[^/]*")
245
+ .replace(/<<GLOBSTAR>>/g, ".*");
246
+
247
+ return new RegExp(`^${regex}$`).test(filepath);
248
+ };
249
+
250
+ // =============================================================================
251
+ // Template Helpers
252
+ // =============================================================================
253
+
254
+ /**
255
+ * Common template helpers that can be passed to templates.
256
+ */
257
+ export const templateHelpers = {
258
+ /**
259
+ * Convert a string to camelCase.
260
+ */
261
+ camelCase: (str: string): string => {
262
+ return str
263
+ .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : ""))
264
+ .replace(/^[A-Z]/, (c) => c.toLowerCase());
265
+ },
266
+
267
+ /**
268
+ * Convert a string to PascalCase.
269
+ */
270
+ pascalCase: (str: string): string => {
271
+ const camel = templateHelpers.camelCase(str);
272
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
273
+ },
274
+
275
+ /**
276
+ * Convert a string to kebab-case.
277
+ */
278
+ kebabCase: (str: string): string => {
279
+ return str
280
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
281
+ .replace(/[\s_]+/g, "-")
282
+ .toLowerCase();
283
+ },
284
+
285
+ /**
286
+ * Convert a string to snake_case.
287
+ */
288
+ snakeCase: (str: string): string => {
289
+ return str
290
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
291
+ .replace(/[\s-]+/g, "_")
292
+ .toLowerCase();
293
+ },
294
+
295
+ /**
296
+ * Convert a string to CONSTANT_CASE.
297
+ */
298
+ constantCase: (str: string): string => {
299
+ return templateHelpers.snakeCase(str).toUpperCase();
300
+ },
301
+
302
+ /**
303
+ * Capitalize the first letter of a string.
304
+ */
305
+ capitalize: (str: string): string => {
306
+ return str.charAt(0).toUpperCase() + str.slice(1);
307
+ },
308
+
309
+ /**
310
+ * Get the current date in ISO format.
311
+ */
312
+ isoDate: (): string => new Date().toISOString(),
313
+
314
+ /**
315
+ * Get the current year.
316
+ */
317
+ year: (): number => new Date().getFullYear(),
318
+
319
+ /**
320
+ * Indent a multi-line string.
321
+ */
322
+ indent: (str: string, spaces: number): string => {
323
+ const pad = " ".repeat(spaces);
324
+ return str
325
+ .split("\n")
326
+ .map((line) => pad + line)
327
+ .join("\n");
328
+ },
329
+
330
+ /**
331
+ * Join array items with a separator.
332
+ */
333
+ join: (arr: unknown[], separator = ", "): string => {
334
+ return arr.map(String).join(separator);
335
+ },
336
+
337
+ /**
338
+ * Pluralize a word based on count.
339
+ */
340
+ pluralize: (word: string, count: number): string => {
341
+ return count === 1 ? word : `${word}s`;
342
+ },
343
+ };
344
+
345
+ /**
346
+ * Create a vars object with helpers included.
347
+ */
348
+ export const withHelpers = (
349
+ vars: Record<string, unknown>,
350
+ ): Record<string, unknown> => ({
351
+ ...templateHelpers,
352
+ ...vars,
353
+ });
354
+
355
+ // =============================================================================
356
+ // Generator Metadata Comment
357
+ // =============================================================================
358
+
359
+ /**
360
+ * Generate a metadata comment for generated files.
361
+ */
362
+ export const generatorComment = (
363
+ generatorName: string,
364
+ options?: {
365
+ version?: string;
366
+ timestamp?: boolean;
367
+ },
368
+ ): string => {
369
+ const parts = [`Generated by ${generatorName}`];
370
+
371
+ if (options?.version) {
372
+ parts.push(`v${options.version}`);
373
+ }
374
+
375
+ if (options?.timestamp) {
376
+ parts.push(`on ${new Date().toISOString()}`);
377
+ }
378
+
379
+ return `// ${parts.join(" ")}`;
380
+ };
381
+
382
+ /**
383
+ * Generate a metadata comment as an HTML comment.
384
+ */
385
+ export const generatorHtmlComment = (
386
+ generatorName: string,
387
+ options?: {
388
+ version?: string;
389
+ timestamp?: boolean;
390
+ },
391
+ ): string => {
392
+ const parts = [`Generated by ${generatorName}`];
393
+
394
+ if (options?.version) {
395
+ parts.push(`v${options.version}`);
396
+ }
397
+
398
+ if (options?.timestamp) {
399
+ parts.push(`on ${new Date().toISOString()}`);
400
+ }
401
+
402
+ return `<!-- ${parts.join(" ")} -->`;
403
+ };
404
+
405
+ // =============================================================================
406
+ // Generated File Stamp
407
+ // =============================================================================
408
+
409
+ /**
410
+ * Comment style configuration for different file types.
411
+ */
412
+ interface CommentStyle {
413
+ /** Single line comment prefix (e.g., "//") */
414
+ single?: string;
415
+ /** Block comment start (e.g., slash-star) */
416
+ blockStart?: string;
417
+ /** Block comment end (e.g., star-slash) */
418
+ blockEnd?: string;
419
+ /** Use block style even for single line (e.g., for CSS) */
420
+ preferBlock?: boolean;
421
+ }
422
+
423
+ /**
424
+ * Map of file extensions to comment styles.
425
+ */
426
+ const COMMENT_STYLES: Record<string, CommentStyle> = {
427
+ // JavaScript/TypeScript family
428
+ ".ts": { single: "//", blockStart: "/*", blockEnd: "*/" },
429
+ ".tsx": { single: "//", blockStart: "/*", blockEnd: "*/" },
430
+ ".js": { single: "//", blockStart: "/*", blockEnd: "*/" },
431
+ ".jsx": { single: "//", blockStart: "/*", blockEnd: "*/" },
432
+ ".mjs": { single: "//", blockStart: "/*", blockEnd: "*/" },
433
+ ".cjs": { single: "//", blockStart: "/*", blockEnd: "*/" },
434
+
435
+ // CSS family (prefer block comments)
436
+ ".css": { blockStart: "/*", blockEnd: "*/", preferBlock: true },
437
+ ".scss": { single: "//", blockStart: "/*", blockEnd: "*/" },
438
+ ".sass": { single: "//" },
439
+ ".less": { single: "//", blockStart: "/*", blockEnd: "*/" },
440
+
441
+ // HTML/XML family
442
+ ".html": { blockStart: "<!--", blockEnd: "-->" },
443
+ ".htm": { blockStart: "<!--", blockEnd: "-->" },
444
+ ".xml": { blockStart: "<!--", blockEnd: "-->" },
445
+ ".svg": { blockStart: "<!--", blockEnd: "-->" },
446
+ ".vue": { blockStart: "<!--", blockEnd: "-->" },
447
+ ".svelte": { blockStart: "<!--", blockEnd: "-->" },
448
+
449
+ // Config files
450
+ ".json": {}, // JSON doesn't support comments - skip stamp
451
+ ".yaml": { single: "#" },
452
+ ".yml": { single: "#" },
453
+ ".toml": { single: "#" },
454
+
455
+ // Shell/scripting
456
+ ".sh": { single: "#" },
457
+ ".bash": { single: "#" },
458
+ ".zsh": { single: "#" },
459
+ ".fish": { single: "#" },
460
+ ".py": { single: "#" },
461
+ ".rb": { single: "#" },
462
+ ".pl": { single: "#" },
463
+
464
+ // Other languages
465
+ ".go": { single: "//", blockStart: "/*", blockEnd: "*/" },
466
+ ".rs": { single: "//", blockStart: "/*", blockEnd: "*/" },
467
+ ".java": { single: "//", blockStart: "/*", blockEnd: "*/" },
468
+ ".kt": { single: "//", blockStart: "/*", blockEnd: "*/" },
469
+ ".swift": { single: "//", blockStart: "/*", blockEnd: "*/" },
470
+ ".c": { single: "//", blockStart: "/*", blockEnd: "*/" },
471
+ ".cpp": { single: "//", blockStart: "/*", blockEnd: "*/" },
472
+ ".h": { single: "//", blockStart: "/*", blockEnd: "*/" },
473
+ ".hpp": { single: "//", blockStart: "/*", blockEnd: "*/" },
474
+ ".php": { single: "//", blockStart: "/*", blockEnd: "*/" },
475
+
476
+ // Documentation
477
+ ".md": { blockStart: "<!--", blockEnd: "-->" },
478
+ ".mdx": { blockStart: "{/*", blockEnd: "*/}" },
479
+
480
+ // SQL
481
+ ".sql": { single: "--", blockStart: "/*", blockEnd: "*/" },
482
+ };
483
+
484
+ /**
485
+ * Get the comment style for a given file path.
486
+ */
487
+ export const getCommentStyle = (filePath: string): CommentStyle | null => {
488
+ const ext = path.extname(filePath).toLowerCase();
489
+ return COMMENT_STYLES[ext] ?? null;
490
+ };
491
+
492
+ /**
493
+ * Generate a stamp comment for a generated file.
494
+ * Returns null if the file type doesn't support comments.
495
+ */
496
+ export const generateStamp = (
497
+ filePath: string,
498
+ options: StampOptions,
499
+ ): string | null => {
500
+ const style = getCommentStyle(filePath);
501
+ if (!style) return null;
502
+
503
+ // Skip if no comment syntax available
504
+ if (!style.single && !style.blockStart) return null;
505
+
506
+ const stampText = `Generated by ${options.generator} v${options.version}`;
507
+
508
+ // Use block style if preferred or single not available
509
+ if (style.preferBlock || (!style.single && style.blockStart)) {
510
+ return `${style.blockStart} ${stampText} ${style.blockEnd}`;
511
+ }
512
+
513
+ // Use single line style
514
+ if (style.single) {
515
+ return `${style.single} ${stampText}`;
516
+ }
517
+
518
+ return null;
519
+ };
520
+
521
+ /**
522
+ * Prepend a stamp to file content.
523
+ * Handles shebang lines (#!/...) by placing stamp after them.
524
+ */
525
+ export const prependStamp = (content: string, stamp: string): string => {
526
+ // Check for shebang
527
+ if (content.startsWith("#!")) {
528
+ const firstNewline = content.indexOf("\n");
529
+ if (firstNewline !== -1) {
530
+ const shebang = content.slice(0, firstNewline + 1);
531
+ const rest = content.slice(firstNewline + 1);
532
+ return `${shebang}${stamp}\n${rest}`;
533
+ }
534
+ }
535
+
536
+ return `${stamp}\n${content}`;
537
+ };