@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.
- package/README.md +439 -0
- package/generators/example/hello/index.ts +132 -0
- package/generators/example/hello/templates/README.md.ejs +20 -0
- package/generators/example/hello/templates/index.ts.ejs +9 -0
- package/generators/example/webapp/index.ts +509 -0
- package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
- package/generators/example/webapp/templates/App.tsx.ejs +86 -0
- package/generators/example/webapp/templates/README.md.ejs +154 -0
- package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
- package/generators/example/webapp/templates/app.ts.ejs +132 -0
- package/generators/example/webapp/templates/feature.ts.ejs +264 -0
- package/generators/example/webapp/templates/index.html.ejs +20 -0
- package/generators/example/webapp/templates/main.tsx.ejs +43 -0
- package/generators/example/webapp/templates/styles.css.ejs +135 -0
- package/generators/init/index.ts +124 -0
- package/generators/init/templates/generator.ts.ejs +85 -0
- package/generators/init/templates/template-index.ts.ejs +9 -0
- package/generators/init/templates/template-test.ts.ejs +8 -0
- package/package.json +64 -0
- package/src/__tests__/combinators.test.ts +895 -0
- package/src/__tests__/dry-run.test.ts +927 -0
- package/src/__tests__/effect.test.ts +816 -0
- package/src/__tests__/interpreter.test.ts +673 -0
- package/src/__tests__/primitives.test.ts +970 -0
- package/src/__tests__/task.test.ts +929 -0
- package/src/__tests__/template.test.ts +666 -0
- package/src/cli-format.ts +165 -0
- package/src/cli-types.ts +53 -0
- package/src/cli.tsx +1322 -0
- package/src/combinators.ts +294 -0
- package/src/completion.ts +488 -0
- package/src/components/App.tsx +960 -0
- package/src/components/ExecutionProgress.tsx +205 -0
- package/src/components/FileTreePreview.tsx +97 -0
- package/src/components/PromptSequence.tsx +483 -0
- package/src/components/Spinner.tsx +36 -0
- package/src/components/index.ts +16 -0
- package/src/dry-run.ts +434 -0
- package/src/effect.ts +224 -0
- package/src/index.ts +266 -0
- package/src/interpreter.ts +463 -0
- package/src/primitives.ts +442 -0
- package/src/task.ts +245 -0
- package/src/template.ts +537 -0
- package/src/types.ts +453 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { dryRun } from "../dry-run.js";
|
|
3
|
+
import {
|
|
4
|
+
generatorComment,
|
|
5
|
+
generatorHtmlComment,
|
|
6
|
+
renderString,
|
|
7
|
+
template,
|
|
8
|
+
templateDir,
|
|
9
|
+
templateHelpers,
|
|
10
|
+
withHelpers,
|
|
11
|
+
} from "../template.js";
|
|
12
|
+
|
|
13
|
+
// =============================================================================
|
|
14
|
+
// String Rendering
|
|
15
|
+
// =============================================================================
|
|
16
|
+
|
|
17
|
+
describe("Template - renderString", () => {
|
|
18
|
+
it("renders simple variables", () => {
|
|
19
|
+
const result = renderString("Hello, <%= name %>!", { name: "World" });
|
|
20
|
+
expect(result).toBe("Hello, World!");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders multiple variables", () => {
|
|
24
|
+
const result = renderString("<%= greeting %>, <%= name %>!", {
|
|
25
|
+
greeting: "Hello",
|
|
26
|
+
name: "World",
|
|
27
|
+
});
|
|
28
|
+
expect(result).toBe("Hello, World!");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders with no variables", () => {
|
|
32
|
+
const result = renderString("No variables here", {});
|
|
33
|
+
expect(result).toBe("No variables here");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("renders embedded JavaScript expressions", () => {
|
|
37
|
+
const result = renderString("<%= 2 + 2 %>", {});
|
|
38
|
+
expect(result).toBe("4");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders conditionals", () => {
|
|
42
|
+
const template = "<% if (show) { %>Visible<% } %>";
|
|
43
|
+
expect(renderString(template, { show: true })).toBe("Visible");
|
|
44
|
+
expect(renderString(template, { show: false })).toBe("");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("renders loops", () => {
|
|
48
|
+
const template = "<% items.forEach(item => { %><%= item %> <% }) %>";
|
|
49
|
+
const result = renderString(template, { items: ["a", "b", "c"] });
|
|
50
|
+
expect(result).toBe("a b c ");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("escapes HTML by default with <%= %>", () => {
|
|
54
|
+
const result = renderString("<%= html %>", {
|
|
55
|
+
html: "<script>alert('xss')</script>",
|
|
56
|
+
});
|
|
57
|
+
expect(result).toContain("<script>");
|
|
58
|
+
expect(result).not.toContain("<script>");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("renders unescaped HTML with <%- %>", () => {
|
|
62
|
+
const result = renderString("<%- html %>", { html: "<div>content</div>" });
|
|
63
|
+
expect(result).toBe("<div>content</div>");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("handles nested objects", () => {
|
|
67
|
+
const result = renderString("<%= user.name %> (<%= user.age %>)", {
|
|
68
|
+
user: { name: "John", age: 30 },
|
|
69
|
+
});
|
|
70
|
+
expect(result).toBe("John (30)");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("handles array access", () => {
|
|
74
|
+
const result = renderString("<%= items[0] %> and <%= items[1] %>", {
|
|
75
|
+
items: ["first", "second"],
|
|
76
|
+
});
|
|
77
|
+
expect(result).toBe("first and second");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles functions in variables", () => {
|
|
81
|
+
const result = renderString("<%= format(name) %>", {
|
|
82
|
+
name: "hello",
|
|
83
|
+
format: (s: string) => s.toUpperCase(),
|
|
84
|
+
});
|
|
85
|
+
expect(result).toBe("HELLO");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("handles multiline templates", () => {
|
|
89
|
+
const template = `
|
|
90
|
+
line 1
|
|
91
|
+
<%= middle %>
|
|
92
|
+
line 3
|
|
93
|
+
`;
|
|
94
|
+
const result = renderString(template, { middle: "line 2" });
|
|
95
|
+
expect(result).toContain("line 1");
|
|
96
|
+
expect(result).toContain("line 2");
|
|
97
|
+
expect(result).toContain("line 3");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("handles empty template", () => {
|
|
101
|
+
const result = renderString("", {});
|
|
102
|
+
expect(result).toBe("");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("handles template with only whitespace", () => {
|
|
106
|
+
const result = renderString(" \n\t ", {});
|
|
107
|
+
expect(result).toBe(" \n\t ");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("handles unicode in template", () => {
|
|
111
|
+
const result = renderString("<%= emoji %>", { emoji: "\u{1F600}" });
|
|
112
|
+
expect(result).toBe("\u{1F600}");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("handles special characters in variables", () => {
|
|
116
|
+
const result = renderString("<%= special %>", {
|
|
117
|
+
special: 'Special: @#$%^&*()[]{}|\\;"<>',
|
|
118
|
+
});
|
|
119
|
+
// HTML escaped version
|
|
120
|
+
expect(result).toContain("Special:");
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// =============================================================================
|
|
125
|
+
// Template Task
|
|
126
|
+
// =============================================================================
|
|
127
|
+
|
|
128
|
+
describe("Template - template task", () => {
|
|
129
|
+
it("creates mkdir and writeFile effects", () => {
|
|
130
|
+
const t = template({
|
|
131
|
+
source: "/templates/component.tsx.ejs",
|
|
132
|
+
dest: "/output/Button.tsx",
|
|
133
|
+
vars: { name: "Button" },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const { effects } = dryRun(t);
|
|
137
|
+
|
|
138
|
+
// Should have: mkdir, readFile, writeFile
|
|
139
|
+
expect(effects.some((e) => e._tag === "MakeDir")).toBe(true);
|
|
140
|
+
expect(effects.some((e) => e._tag === "ReadFile")).toBe(true);
|
|
141
|
+
expect(effects.some((e) => e._tag === "WriteFile")).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("renders destination path with variables", () => {
|
|
145
|
+
const t = template({
|
|
146
|
+
source: "/templates/component.tsx.ejs",
|
|
147
|
+
dest: "/output/<%= name %>.tsx",
|
|
148
|
+
vars: { name: "Button" },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const { effects } = dryRun(t);
|
|
152
|
+
const writeEffect = effects.find((e) => e._tag === "WriteFile");
|
|
153
|
+
|
|
154
|
+
expect((writeEffect as { path: string }).path).toBe("/output/Button.tsx");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("creates parent directory", () => {
|
|
158
|
+
const t = template({
|
|
159
|
+
source: "/templates/test.txt.ejs",
|
|
160
|
+
dest: "/deep/nested/path/file.txt",
|
|
161
|
+
vars: {},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
const { effects } = dryRun(t);
|
|
165
|
+
const mkdirEffect = effects.find((e) => e._tag === "MakeDir");
|
|
166
|
+
|
|
167
|
+
expect((mkdirEffect as { path: string }).path).toBe("/deep/nested/path");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// =============================================================================
|
|
172
|
+
// templateDir Task
|
|
173
|
+
// =============================================================================
|
|
174
|
+
|
|
175
|
+
describe("Template - templateDir task", () => {
|
|
176
|
+
it("creates effects for directory templating", () => {
|
|
177
|
+
const t = templateDir({
|
|
178
|
+
source: "/templates",
|
|
179
|
+
dest: "/output",
|
|
180
|
+
vars: { name: "MyComponent" },
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const { effects } = dryRun(t);
|
|
184
|
+
|
|
185
|
+
// Should have glob effect to find templates
|
|
186
|
+
expect(effects.some((e) => e._tag === "Glob")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("handles empty directory (no files matched)", () => {
|
|
190
|
+
const t = templateDir({
|
|
191
|
+
source: "/empty-templates",
|
|
192
|
+
dest: "/output",
|
|
193
|
+
vars: {},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Should not throw
|
|
197
|
+
expect(() => dryRun(t)).not.toThrow();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// =============================================================================
|
|
202
|
+
// Template Helpers
|
|
203
|
+
// =============================================================================
|
|
204
|
+
|
|
205
|
+
describe("Template - templateHelpers", () => {
|
|
206
|
+
describe("camelCase", () => {
|
|
207
|
+
it("converts hyphenated strings", () => {
|
|
208
|
+
expect(templateHelpers.camelCase("my-component")).toBe("myComponent");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("converts underscored strings", () => {
|
|
212
|
+
expect(templateHelpers.camelCase("my_component")).toBe("myComponent");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("converts space-separated strings", () => {
|
|
216
|
+
expect(templateHelpers.camelCase("my component")).toBe("myComponent");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("converts PascalCase to camelCase", () => {
|
|
220
|
+
expect(templateHelpers.camelCase("MyComponent")).toBe("myComponent");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("handles multiple separators", () => {
|
|
224
|
+
expect(templateHelpers.camelCase("my-component_name")).toBe(
|
|
225
|
+
"myComponentName",
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("handles single word", () => {
|
|
230
|
+
expect(templateHelpers.camelCase("component")).toBe("component");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("handles empty string", () => {
|
|
234
|
+
expect(templateHelpers.camelCase("")).toBe("");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("handles consecutive separators", () => {
|
|
238
|
+
expect(templateHelpers.camelCase("my--component")).toBe("myComponent");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("pascalCase", () => {
|
|
243
|
+
it("converts hyphenated strings", () => {
|
|
244
|
+
expect(templateHelpers.pascalCase("my-component")).toBe("MyComponent");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("converts underscored strings", () => {
|
|
248
|
+
expect(templateHelpers.pascalCase("my_component")).toBe("MyComponent");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("converts space-separated strings", () => {
|
|
252
|
+
expect(templateHelpers.pascalCase("my component")).toBe("MyComponent");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("preserves PascalCase", () => {
|
|
256
|
+
expect(templateHelpers.pascalCase("MyComponent")).toBe("MyComponent");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("handles single word", () => {
|
|
260
|
+
expect(templateHelpers.pascalCase("component")).toBe("Component");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("handles empty string", () => {
|
|
264
|
+
expect(templateHelpers.pascalCase("")).toBe("");
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("kebabCase", () => {
|
|
269
|
+
it("converts camelCase", () => {
|
|
270
|
+
expect(templateHelpers.kebabCase("myComponent")).toBe("my-component");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("converts PascalCase", () => {
|
|
274
|
+
expect(templateHelpers.kebabCase("MyComponent")).toBe("my-component");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("converts underscored strings", () => {
|
|
278
|
+
expect(templateHelpers.kebabCase("my_component")).toBe("my-component");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("converts space-separated strings", () => {
|
|
282
|
+
expect(templateHelpers.kebabCase("my component")).toBe("my-component");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("preserves kebab-case", () => {
|
|
286
|
+
expect(templateHelpers.kebabCase("my-component")).toBe("my-component");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("handles single word", () => {
|
|
290
|
+
expect(templateHelpers.kebabCase("Component")).toBe("component");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("handles empty string", () => {
|
|
294
|
+
expect(templateHelpers.kebabCase("")).toBe("");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("handles consecutive uppercase (only splits on lowercase-uppercase boundary)", () => {
|
|
298
|
+
// The implementation only splits on [a-z][A-Z] boundaries
|
|
299
|
+
// So "XMLParser" becomes "xmlparser" (no lowercase before X, M, L)
|
|
300
|
+
expect(templateHelpers.kebabCase("XMLParser")).toBe("xmlparser");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("handles mixed case with lowercase-uppercase boundaries", () => {
|
|
304
|
+
// Only splits where lowercase is followed by uppercase
|
|
305
|
+
// "parseXMLDocument": only eX is a lowercase→uppercase boundary
|
|
306
|
+
// So it becomes "parse-xmldocument" (ML, LD are uppercase→uppercase)
|
|
307
|
+
expect(templateHelpers.kebabCase("parseXMLDocument")).toBe(
|
|
308
|
+
"parse-xmldocument",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("splits on each lowercase to uppercase transition", () => {
|
|
313
|
+
// "parseXmlDocument" has e→X and l→D transitions
|
|
314
|
+
expect(templateHelpers.kebabCase("parseXmlDocument")).toBe(
|
|
315
|
+
"parse-xml-document",
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe("snakeCase", () => {
|
|
321
|
+
it("converts camelCase", () => {
|
|
322
|
+
expect(templateHelpers.snakeCase("myComponent")).toBe("my_component");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("converts PascalCase", () => {
|
|
326
|
+
expect(templateHelpers.snakeCase("MyComponent")).toBe("my_component");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("converts hyphenated strings", () => {
|
|
330
|
+
expect(templateHelpers.snakeCase("my-component")).toBe("my_component");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("converts space-separated strings", () => {
|
|
334
|
+
expect(templateHelpers.snakeCase("my component")).toBe("my_component");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("preserves snake_case", () => {
|
|
338
|
+
expect(templateHelpers.snakeCase("my_component")).toBe("my_component");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("handles single word", () => {
|
|
342
|
+
expect(templateHelpers.snakeCase("Component")).toBe("component");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("handles empty string", () => {
|
|
346
|
+
expect(templateHelpers.snakeCase("")).toBe("");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("constantCase", () => {
|
|
351
|
+
it("converts camelCase", () => {
|
|
352
|
+
expect(templateHelpers.constantCase("myComponent")).toBe("MY_COMPONENT");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("converts PascalCase", () => {
|
|
356
|
+
expect(templateHelpers.constantCase("MyComponent")).toBe("MY_COMPONENT");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("converts hyphenated strings", () => {
|
|
360
|
+
expect(templateHelpers.constantCase("my-component")).toBe("MY_COMPONENT");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("converts space-separated strings", () => {
|
|
364
|
+
expect(templateHelpers.constantCase("my component")).toBe("MY_COMPONENT");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("handles single word", () => {
|
|
368
|
+
expect(templateHelpers.constantCase("component")).toBe("COMPONENT");
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("handles empty string", () => {
|
|
372
|
+
expect(templateHelpers.constantCase("")).toBe("");
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe("capitalize", () => {
|
|
377
|
+
it("capitalizes first letter", () => {
|
|
378
|
+
expect(templateHelpers.capitalize("hello")).toBe("Hello");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("preserves rest of string", () => {
|
|
382
|
+
expect(templateHelpers.capitalize("hELLO")).toBe("HELLO");
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("handles single character", () => {
|
|
386
|
+
expect(templateHelpers.capitalize("h")).toBe("H");
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("handles empty string", () => {
|
|
390
|
+
expect(templateHelpers.capitalize("")).toBe("");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("handles already capitalized", () => {
|
|
394
|
+
expect(templateHelpers.capitalize("Hello")).toBe("Hello");
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe("isoDate", () => {
|
|
399
|
+
it("returns ISO date string", () => {
|
|
400
|
+
const result = templateHelpers.isoDate();
|
|
401
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe("year", () => {
|
|
406
|
+
it("returns current year", () => {
|
|
407
|
+
const result = templateHelpers.year();
|
|
408
|
+
expect(result).toBe(new Date().getFullYear());
|
|
409
|
+
});
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
describe("indent", () => {
|
|
413
|
+
it("indents single line", () => {
|
|
414
|
+
expect(templateHelpers.indent("hello", 2)).toBe(" hello");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("indents multiple lines", () => {
|
|
418
|
+
const input = "line1\nline2\nline3";
|
|
419
|
+
const expected = " line1\n line2\n line3";
|
|
420
|
+
expect(templateHelpers.indent(input, 2)).toBe(expected);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("handles zero indent", () => {
|
|
424
|
+
expect(templateHelpers.indent("hello", 0)).toBe("hello");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("handles large indent", () => {
|
|
428
|
+
expect(templateHelpers.indent("hello", 10)).toBe(" hello");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("handles empty string", () => {
|
|
432
|
+
expect(templateHelpers.indent("", 2)).toBe(" ");
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("handles empty lines", () => {
|
|
436
|
+
expect(templateHelpers.indent("a\n\nb", 2)).toBe(" a\n \n b");
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("join", () => {
|
|
441
|
+
it("joins array with default separator", () => {
|
|
442
|
+
expect(templateHelpers.join(["a", "b", "c"])).toBe("a, b, c");
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("joins array with custom separator", () => {
|
|
446
|
+
expect(templateHelpers.join(["a", "b", "c"], " | ")).toBe("a | b | c");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("handles empty array", () => {
|
|
450
|
+
expect(templateHelpers.join([])).toBe("");
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("handles single item", () => {
|
|
454
|
+
expect(templateHelpers.join(["single"])).toBe("single");
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("converts non-string items", () => {
|
|
458
|
+
expect(templateHelpers.join([1, 2, 3])).toBe("1, 2, 3");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("handles mixed types", () => {
|
|
462
|
+
expect(templateHelpers.join([1, "two", true])).toBe("1, two, true");
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
describe("pluralize", () => {
|
|
467
|
+
it("returns singular for count of 1", () => {
|
|
468
|
+
expect(templateHelpers.pluralize("item", 1)).toBe("item");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it("returns plural for count of 0", () => {
|
|
472
|
+
expect(templateHelpers.pluralize("item", 0)).toBe("items");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("returns plural for count > 1", () => {
|
|
476
|
+
expect(templateHelpers.pluralize("item", 5)).toBe("items");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("handles negative counts", () => {
|
|
480
|
+
expect(templateHelpers.pluralize("item", -1)).toBe("items");
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// =============================================================================
|
|
486
|
+
// withHelpers
|
|
487
|
+
// =============================================================================
|
|
488
|
+
|
|
489
|
+
describe("Template - withHelpers", () => {
|
|
490
|
+
it("includes all template helpers", () => {
|
|
491
|
+
const vars = withHelpers({ name: "test" });
|
|
492
|
+
|
|
493
|
+
expect(vars.camelCase).toBe(templateHelpers.camelCase);
|
|
494
|
+
expect(vars.pascalCase).toBe(templateHelpers.pascalCase);
|
|
495
|
+
expect(vars.kebabCase).toBe(templateHelpers.kebabCase);
|
|
496
|
+
expect(vars.snakeCase).toBe(templateHelpers.snakeCase);
|
|
497
|
+
expect(vars.constantCase).toBe(templateHelpers.constantCase);
|
|
498
|
+
expect(vars.capitalize).toBe(templateHelpers.capitalize);
|
|
499
|
+
expect(vars.indent).toBe(templateHelpers.indent);
|
|
500
|
+
expect(vars.join).toBe(templateHelpers.join);
|
|
501
|
+
expect(vars.pluralize).toBe(templateHelpers.pluralize);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it("preserves user variables", () => {
|
|
505
|
+
const vars = withHelpers({ name: "MyComponent", version: "1.0.0" });
|
|
506
|
+
|
|
507
|
+
expect(vars.name).toBe("MyComponent");
|
|
508
|
+
expect(vars.version).toBe("1.0.0");
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
it("user variables override helpers", () => {
|
|
512
|
+
const customCapitalize = (s: string) => `CUSTOM: ${s}`;
|
|
513
|
+
const vars = withHelpers({ capitalize: customCapitalize });
|
|
514
|
+
|
|
515
|
+
expect(vars.capitalize).toBe(customCapitalize);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
it("works with renderString", () => {
|
|
519
|
+
const vars = withHelpers({ name: "my-component" });
|
|
520
|
+
const result = renderString("<%= pascalCase(name) %>", vars);
|
|
521
|
+
|
|
522
|
+
expect(result).toBe("MyComponent");
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
// =============================================================================
|
|
527
|
+
// Generator Comments
|
|
528
|
+
// =============================================================================
|
|
529
|
+
|
|
530
|
+
describe("Template - generatorComment", () => {
|
|
531
|
+
it("generates basic comment", () => {
|
|
532
|
+
const result = generatorComment("ComponentGenerator");
|
|
533
|
+
expect(result).toBe("// Generated by ComponentGenerator");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it("includes version when specified", () => {
|
|
537
|
+
const result = generatorComment("ComponentGenerator", { version: "1.0.0" });
|
|
538
|
+
expect(result).toBe("// Generated by ComponentGenerator v1.0.0");
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("includes timestamp when specified", () => {
|
|
542
|
+
const result = generatorComment("ComponentGenerator", { timestamp: true });
|
|
543
|
+
expect(result).toMatch(
|
|
544
|
+
/^\/\/ Generated by ComponentGenerator on \d{4}-\d{2}-\d{2}/,
|
|
545
|
+
);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("includes both version and timestamp", () => {
|
|
549
|
+
const result = generatorComment("ComponentGenerator", {
|
|
550
|
+
version: "2.0.0",
|
|
551
|
+
timestamp: true,
|
|
552
|
+
});
|
|
553
|
+
expect(result).toMatch(
|
|
554
|
+
/^\/\/ Generated by ComponentGenerator v2\.0\.0 on \d{4}/,
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it("handles generator name with spaces", () => {
|
|
559
|
+
const result = generatorComment("My Generator");
|
|
560
|
+
expect(result).toBe("// Generated by My Generator");
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
describe("Template - generatorHtmlComment", () => {
|
|
565
|
+
it("generates basic HTML comment", () => {
|
|
566
|
+
const result = generatorHtmlComment("ComponentGenerator");
|
|
567
|
+
expect(result).toBe("<!-- Generated by ComponentGenerator -->");
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("includes version when specified", () => {
|
|
571
|
+
const result = generatorHtmlComment("ComponentGenerator", {
|
|
572
|
+
version: "1.0.0",
|
|
573
|
+
});
|
|
574
|
+
expect(result).toBe("<!-- Generated by ComponentGenerator v1.0.0 -->");
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("includes timestamp when specified", () => {
|
|
578
|
+
const result = generatorHtmlComment("ComponentGenerator", {
|
|
579
|
+
timestamp: true,
|
|
580
|
+
});
|
|
581
|
+
expect(result).toMatch(
|
|
582
|
+
/^<!-- Generated by ComponentGenerator on \d{4}-\d{2}-\d{2}/,
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("includes both version and timestamp", () => {
|
|
587
|
+
const result = generatorHtmlComment("ComponentGenerator", {
|
|
588
|
+
version: "2.0.0",
|
|
589
|
+
timestamp: true,
|
|
590
|
+
});
|
|
591
|
+
expect(result).toMatch(
|
|
592
|
+
/^<!-- Generated by ComponentGenerator v2\.0\.0 on \d{4}/,
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// =============================================================================
|
|
598
|
+
// Integration with Templates
|
|
599
|
+
// =============================================================================
|
|
600
|
+
|
|
601
|
+
describe("Template - Integration", () => {
|
|
602
|
+
it("can use helpers in templates", () => {
|
|
603
|
+
const vars = withHelpers({
|
|
604
|
+
componentName: "my-button",
|
|
605
|
+
properties: ["label", "onClick", "disabled"],
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const template = `
|
|
609
|
+
export const <%= pascalCase(componentName) %> = () => {
|
|
610
|
+
// Props: <%= join(properties) %>
|
|
611
|
+
};
|
|
612
|
+
`;
|
|
613
|
+
|
|
614
|
+
const result = renderString(template, vars);
|
|
615
|
+
|
|
616
|
+
expect(result).toContain("export const MyButton = () => {");
|
|
617
|
+
expect(result).toContain("// Props: label, onClick, disabled");
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("can generate TypeScript interfaces", () => {
|
|
621
|
+
const vars = withHelpers({
|
|
622
|
+
interfaceName: "button-props",
|
|
623
|
+
properties: [
|
|
624
|
+
{ name: "label", type: "string" },
|
|
625
|
+
{ name: "onClick", type: "() => void" },
|
|
626
|
+
{ name: "disabled", type: "boolean" },
|
|
627
|
+
],
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Use <%- for unescaped output since types can contain < and >
|
|
631
|
+
const template = `
|
|
632
|
+
interface <%= pascalCase(interfaceName) %> {
|
|
633
|
+
<% properties.forEach(prop => { %>
|
|
634
|
+
<%= prop.name %>: <%- prop.type %>;
|
|
635
|
+
<% }) %>
|
|
636
|
+
}
|
|
637
|
+
`;
|
|
638
|
+
|
|
639
|
+
const result = renderString(template, vars);
|
|
640
|
+
|
|
641
|
+
expect(result).toContain("interface ButtonProps {");
|
|
642
|
+
expect(result).toContain("label: string;");
|
|
643
|
+
expect(result).toContain("onClick: () => void;");
|
|
644
|
+
expect(result).toContain("disabled: boolean;");
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("can generate import statements", () => {
|
|
648
|
+
const vars = withHelpers({
|
|
649
|
+
imports: [
|
|
650
|
+
{ from: "react", names: ["useState", "useEffect"] },
|
|
651
|
+
{ from: "./types", names: ["Props"] },
|
|
652
|
+
],
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const template = `
|
|
656
|
+
<% imports.forEach(imp => { %>
|
|
657
|
+
import { <%= join(imp.names) %> } from '<%= imp.from %>';
|
|
658
|
+
<% }) %>
|
|
659
|
+
`;
|
|
660
|
+
|
|
661
|
+
const result = renderString(template, vars);
|
|
662
|
+
|
|
663
|
+
expect(result).toContain("import { useState, useEffect } from 'react';");
|
|
664
|
+
expect(result).toContain("import { Props } from './types';");
|
|
665
|
+
});
|
|
666
|
+
});
|