@funkai/cli 0.1.3 → 0.2.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.
@@ -6,11 +6,11 @@ import { flattenPartials } from "@/lib/prompts/flatten.js";
6
6
 
7
7
  const PARTIALS_DIR = resolve(import.meta.dirname, "../../../../../prompts/src/prompts");
8
8
 
9
- describe("flattenPartials", () => {
9
+ describe(flattenPartials, () => {
10
10
  describe("param parsing", () => {
11
11
  it("resolves a single literal param", () => {
12
12
  const template = "{% render 'identity', role: 'Bot' %}";
13
- const result = flattenPartials(template, [PARTIALS_DIR]);
13
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
14
14
 
15
15
  expect(result).toContain("<identity>");
16
16
  expect(result).toContain("You are Bot, .");
@@ -19,14 +19,14 @@ describe("flattenPartials", () => {
19
19
 
20
20
  it("resolves multiple literal params", () => {
21
21
  const template = "{% render 'identity', role: 'TestBot', desc: 'a test agent' %}";
22
- const result = flattenPartials(template, [PARTIALS_DIR]);
22
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
23
23
 
24
24
  expect(result).toContain("You are TestBot, a test agent.");
25
25
  });
26
26
 
27
27
  it("accepts an empty string as a valid literal param value", () => {
28
28
  const template = "{% render 'identity', role: '', desc: 'helper' %}";
29
- const result = flattenPartials(template, [PARTIALS_DIR]);
29
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
30
30
 
31
31
  expect(result).toContain("You are , helper.");
32
32
  expect(result).not.toContain("{% render");
@@ -35,7 +35,7 @@ describe("flattenPartials", () => {
35
35
  it("throws when the first param uses a variable reference", () => {
36
36
  const template = "{% render 'identity', role: agentRole, desc: 'helper' %}";
37
37
 
38
- expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
38
+ expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
39
39
  'parameter "role" uses a variable reference',
40
40
  );
41
41
  });
@@ -43,7 +43,7 @@ describe("flattenPartials", () => {
43
43
  it("throws when a non-first param uses a variable reference", () => {
44
44
  const template = "{% render 'identity', role: 'Bot', desc: myDesc %}";
45
45
 
46
- expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow(
46
+ expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
47
47
  'parameter "desc" uses a variable reference',
48
48
  );
49
49
  });
@@ -51,19 +51,21 @@ describe("flattenPartials", () => {
51
51
  it("throws when all params are variable references", () => {
52
52
  const template = "{% render 'identity', role: agentRole, desc: agentDesc %}";
53
53
 
54
- expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow("uses a variable reference");
54
+ expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow(
55
+ "uses a variable reference",
56
+ );
55
57
  });
56
58
 
57
59
  it("handles extra whitespace around colons in params", () => {
58
60
  const template = "{% render 'identity', role : 'Bot', desc : 'helper' %}";
59
- const result = flattenPartials(template, [PARTIALS_DIR]);
61
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
60
62
 
61
63
  expect(result).toContain("You are Bot, helper.");
62
64
  });
63
65
 
64
66
  it("handles param values containing spaces", () => {
65
67
  const template = "{% render 'identity', role: 'Test Bot', desc: 'a helpful assistant' %}";
66
- const result = flattenPartials(template, [PARTIALS_DIR]);
68
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
67
69
 
68
70
  expect(result).toContain("You are Test Bot, a helpful assistant.");
69
71
  });
@@ -72,12 +74,12 @@ describe("flattenPartials", () => {
72
74
  describe("render tag parsing", () => {
73
75
  it("returns template unchanged when no render tags exist", () => {
74
76
  const template = "<identity>\nYou are a bot.\n</identity>";
75
- expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
77
+ expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
76
78
  });
77
79
 
78
80
  it("parses a render tag with no params", () => {
79
81
  const template = "{% render 'identity' %}\n\nDone.";
80
- const result = flattenPartials(template, [PARTIALS_DIR]);
82
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
81
83
 
82
84
  expect(result).toContain("<identity>");
83
85
  expect(result).toContain("</identity>");
@@ -86,7 +88,7 @@ describe("flattenPartials", () => {
86
88
 
87
89
  it("parses left-only whitespace trim {%-", () => {
88
90
  const template = "{%- render 'identity', role: 'Bot', desc: 'helper' %}\nDone.";
89
- const result = flattenPartials(template, [PARTIALS_DIR]);
91
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
90
92
 
91
93
  expect(result).toContain("You are Bot, helper.");
92
94
  expect(result).not.toContain("{%-");
@@ -94,7 +96,7 @@ describe("flattenPartials", () => {
94
96
 
95
97
  it("parses right-only whitespace trim -%}", () => {
96
98
  const template = "{% render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
97
- const result = flattenPartials(template, [PARTIALS_DIR]);
99
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
98
100
 
99
101
  expect(result).toContain("You are Bot, helper.");
100
102
  expect(result).not.toContain("-%}");
@@ -102,7 +104,7 @@ describe("flattenPartials", () => {
102
104
 
103
105
  it("parses both-side whitespace trim {%- -%}", () => {
104
106
  const template = "{%- render 'identity', role: 'Bot', desc: 'helper' -%}\nDone.";
105
- const result = flattenPartials(template, [PARTIALS_DIR]);
107
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
106
108
 
107
109
  expect(result).toContain("You are Bot, helper.");
108
110
  expect(result).not.toContain("{%");
@@ -110,19 +112,19 @@ describe("flattenPartials", () => {
110
112
 
111
113
  it("handles extra whitespace between {% and render keyword", () => {
112
114
  const template = "{% render 'identity', role: 'Bot', desc: 'helper' %}";
113
- const result = flattenPartials(template, [PARTIALS_DIR]);
115
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
114
116
 
115
117
  expect(result).toContain("You are Bot, helper.");
116
118
  });
117
119
 
118
120
  it("does not match render tags with double quotes", () => {
119
121
  const template = '{% render "identity" %}';
120
- expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
122
+ expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
121
123
  });
122
124
 
123
125
  it("does not match malformed render tags without closing %}", () => {
124
126
  const template = "{% render 'identity'";
125
- expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
127
+ expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
126
128
  });
127
129
  });
128
130
 
@@ -130,7 +132,7 @@ describe("flattenPartials", () => {
130
132
  it("flattens identity partial with literal params", () => {
131
133
  const template =
132
134
  "{% render 'identity', role: 'TestBot', desc: 'a test agent' %}\n\nFollow instructions.";
133
- const result = flattenPartials(template, [PARTIALS_DIR]);
135
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
134
136
 
135
137
  expect(result).toContain("<identity>");
136
138
  expect(result).toContain("You are TestBot, a test agent.");
@@ -141,7 +143,7 @@ describe("flattenPartials", () => {
141
143
 
142
144
  it("flattens constraints partial with no bindings", () => {
143
145
  const template = "{% render 'constraints' %}";
144
- const result = flattenPartials(template, [PARTIALS_DIR]);
146
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
145
147
 
146
148
  expect(result).toContain("<constraints>");
147
149
  expect(result).toContain("</constraints>");
@@ -153,7 +155,7 @@ describe("flattenPartials", () => {
153
155
 
154
156
  it("flattens tools partial with no bindings (else branch)", () => {
155
157
  const template = "{% render 'tools' %}";
156
- const result = flattenPartials(template, [PARTIALS_DIR]);
158
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
157
159
 
158
160
  expect(result).toContain("<tools>");
159
161
  expect(result).toContain("</tools>");
@@ -168,7 +170,7 @@ describe("flattenPartials", () => {
168
170
  "{% render 'identity', role: 'Agent', desc: 'analyzer' %}",
169
171
  ].join("\n");
170
172
 
171
- const result = flattenPartials(template, [PARTIALS_DIR]);
173
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
172
174
 
173
175
  expect(result).toContain("You are Bot, helper.");
174
176
  expect(result).toContain("You are Agent, analyzer.");
@@ -178,7 +180,7 @@ describe("flattenPartials", () => {
178
180
  it("preserves surrounding markdown content", () => {
179
181
  const template =
180
182
  "# System Prompt\n\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n\n## Instructions\n\nDo the thing.";
181
- const result = flattenPartials(template, [PARTIALS_DIR]);
183
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
182
184
 
183
185
  expect(result).toMatch(/^# System Prompt/);
184
186
  expect(result).toContain("You are Bot, helper.");
@@ -188,13 +190,13 @@ describe("flattenPartials", () => {
188
190
  it("throws when partial file does not exist", () => {
189
191
  const template = "{% render 'nonexistent' %}";
190
192
 
191
- expect(() => flattenPartials(template, [PARTIALS_DIR])).toThrow();
193
+ expect(() => flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toThrow();
192
194
  });
193
195
 
194
196
  it("searches multiple partialsDirs in order", () => {
195
197
  const emptyDir = resolve(import.meta.dirname);
196
198
  const template = "{% render 'identity', role: 'Bot', desc: 'test' %}";
197
- const result = flattenPartials(template, [emptyDir, PARTIALS_DIR]);
199
+ const result = flattenPartials({ template, partialsDirs: [emptyDir, PARTIALS_DIR] });
198
200
 
199
201
  expect(result).toContain("You are Bot, test.");
200
202
  });
@@ -203,13 +205,13 @@ describe("flattenPartials", () => {
203
205
  describe("template preservation", () => {
204
206
  it("preserves {{ var }} and {% if %} expressions", () => {
205
207
  const template = "Hello {{ name }}.\n{% if context %}{{ context }}{% endif %}";
206
- expect(flattenPartials(template, [PARTIALS_DIR])).toBe(template);
208
+ expect(flattenPartials({ template, partialsDirs: [PARTIALS_DIR] })).toBe(template);
207
209
  });
208
210
 
209
211
  it("flattens render tag while preserving surrounding Liquid blocks", () => {
210
212
  const template =
211
213
  "{% if show_identity %}\n{% render 'identity', role: 'Bot', desc: 'helper' %}\n{% endif %}\n\n{{ instructions }}";
212
- const result = flattenPartials(template, [PARTIALS_DIR]);
214
+ const result = flattenPartials({ template, partialsDirs: [PARTIALS_DIR] });
213
215
 
214
216
  expect(result).toContain("{% if show_identity %}");
215
217
  expect(result).toContain("{% endif %}");
@@ -219,12 +221,12 @@ describe("flattenPartials", () => {
219
221
  });
220
222
 
221
223
  it("returns empty string unchanged", () => {
222
- expect(flattenPartials("", [PARTIALS_DIR])).toBe("");
224
+ expect(flattenPartials({ template: "", partialsDirs: [PARTIALS_DIR] })).toBe("");
223
225
  });
224
226
 
225
227
  it("returns whitespace-only template unchanged", () => {
226
228
  const ws = " \n\n ";
227
- expect(flattenPartials(ws, [PARTIALS_DIR])).toBe(ws);
229
+ expect(flattenPartials({ template: ws, partialsDirs: [PARTIALS_DIR] })).toBe(ws);
228
230
  });
229
231
  });
230
232
  });
@@ -2,23 +2,23 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { parseFrontmatter } from "@/lib/prompts/frontmatter.js";
4
4
 
5
- describe("parseFrontmatter", () => {
5
+ describe(parseFrontmatter, () => {
6
6
  it("parses name from frontmatter", () => {
7
7
  const content = "---\nname: my-prompt\n---\nHello";
8
- const result = parseFrontmatter(content, "test.prompt");
8
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
9
9
  expect(result.name).toBe("my-prompt");
10
10
  });
11
11
 
12
12
  it("parses group and version", () => {
13
13
  const content = "---\nname: test\ngroup: agents/test\nversion: 2\n---\nBody";
14
- const result = parseFrontmatter(content, "test.prompt");
14
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
15
15
  expect(result.group).toBe("agents/test");
16
16
  expect(result.version).toBe("2");
17
17
  });
18
18
 
19
19
  it("parses schema with shorthand type strings", () => {
20
20
  const content = "---\nname: test\nschema:\n scope: string\n target: string\n---\n";
21
- const result = parseFrontmatter(content, "test.prompt");
21
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
22
22
  expect(result.schema).toEqual([
23
23
  { name: "scope", type: "string", required: true },
24
24
  { name: "target", type: "string", required: true },
@@ -39,7 +39,7 @@ describe("parseFrontmatter", () => {
39
39
  "---",
40
40
  "",
41
41
  ].join("\n");
42
- const result = parseFrontmatter(content, "test.prompt");
42
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
43
43
  expect(result.schema).toEqual([
44
44
  { name: "scope", type: "string", required: true, description: "The scope" },
45
45
  { name: "target", type: "string", required: false },
@@ -48,42 +48,44 @@ describe("parseFrontmatter", () => {
48
48
 
49
49
  it("returns empty schema when no schema field", () => {
50
50
  const content = "---\nname: test\n---\nBody";
51
- const result = parseFrontmatter(content, "test.prompt");
51
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
52
52
  expect(result.schema).toEqual([]);
53
53
  });
54
54
 
55
55
  it("throws on missing frontmatter", () => {
56
- expect(() => parseFrontmatter("No frontmatter", "test.prompt")).toThrow("No frontmatter");
56
+ expect(() => parseFrontmatter({ content: "No frontmatter", filePath: "test.prompt" })).toThrow(
57
+ "No frontmatter",
58
+ );
57
59
  });
58
60
 
59
61
  it("throws on missing name", () => {
60
- expect(() => parseFrontmatter("---\nversion: 1\n---\n", "test.prompt")).toThrow(
61
- 'Missing or empty "name"',
62
- );
62
+ expect(() =>
63
+ parseFrontmatter({ content: "---\nversion: 1\n---\n", filePath: "test.prompt" }),
64
+ ).toThrow('Missing or empty "name"');
63
65
  });
64
66
 
65
67
  it("throws on invalid name format", () => {
66
- expect(() => parseFrontmatter("---\nname: My Prompt\n---\n", "test.prompt")).toThrow(
67
- "Invalid prompt name",
68
- );
68
+ expect(() =>
69
+ parseFrontmatter({ content: "---\nname: My Prompt\n---\n", filePath: "test.prompt" }),
70
+ ).toThrow("Invalid prompt name");
69
71
  });
70
72
 
71
73
  it("returns undefined group when not specified", () => {
72
74
  const content = "---\nname: test\n---\nBody";
73
- const result = parseFrontmatter(content, "test.prompt");
75
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
74
76
  expect(result.group).toBeUndefined();
75
77
  });
76
78
 
77
79
  it("should throw on invalid group segment", () => {
78
80
  const content = "---\nname: test\ngroup: agents/INVALID\n---\nBody";
79
- expect(() => parseFrontmatter(content, "test.prompt")).toThrow(
81
+ expect(() => parseFrontmatter({ content, filePath: "test.prompt" })).toThrow(
80
82
  'Invalid group segment "INVALID"',
81
83
  );
82
84
  });
83
85
 
84
86
  it("should accept valid multi-segment group", () => {
85
87
  const content = "---\nname: test\ngroup: agents/specialized\n---\nBody";
86
- const result = parseFrontmatter(content, "test.prompt");
88
+ const result = parseFrontmatter({ content, filePath: "test.prompt" });
87
89
  expect(result.group).toBe("agents/specialized");
88
90
  });
89
91
  });
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
 
3
3
  import { hasLintErrors, lintPrompt } from "@/lib/prompts/lint.js";
4
4
 
5
- describe("lintPrompt", () => {
5
+ describe(lintPrompt, () => {
6
6
  it("returns no diagnostics when vars match schema", () => {
7
7
  const result = lintPrompt(
8
8
  "test",
@@ -50,10 +50,10 @@ describe("lintPrompt", () => {
50
50
  });
51
51
  });
52
52
 
53
- describe("hasLintErrors", () => {
53
+ describe(hasLintErrors, () => {
54
54
  it("returns false when no errors", () => {
55
55
  const results = [{ name: "test", filePath: "test.prompt", diagnostics: [] }];
56
- expect(hasLintErrors(results)).toBe(false);
56
+ expect(hasLintErrors(results)).toBeFalsy();
57
57
  });
58
58
 
59
59
  it("returns true when errors exist", () => {
@@ -64,7 +64,7 @@ describe("hasLintErrors", () => {
64
64
  diagnostics: [{ level: "error" as const, message: "oops" }],
65
65
  },
66
66
  ];
67
- expect(hasLintErrors(results)).toBe(true);
67
+ expect(hasLintErrors(results)).toBeTruthy();
68
68
  });
69
69
 
70
70
  it("returns false when only warnings", () => {
@@ -75,6 +75,6 @@ describe("hasLintErrors", () => {
75
75
  diagnostics: [{ level: "warn" as const, message: "hmm" }],
76
76
  },
77
77
  ];
78
- expect(hasLintErrors(results)).toBe(false);
78
+ expect(hasLintErrors(results)).toBeFalsy();
79
79
  });
80
80
  });
@@ -13,6 +13,10 @@ export interface ParsedPrompt {
13
13
  readonly sourcePath: string;
14
14
  }
15
15
 
16
+ // ---------------------------------------------------------------------------
17
+ // Private
18
+ // ---------------------------------------------------------------------------
19
+
16
20
  /**
17
21
  * Convert a kebab-case name to PascalCase.
18
22
  *
@@ -24,7 +28,7 @@ export interface ParsedPrompt {
24
28
  function toPascalCase(name: string): string {
25
29
  return name
26
30
  .split("-")
27
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
31
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
28
32
  .join("");
29
33
  }
30
34
 
@@ -38,7 +42,7 @@ function toPascalCase(name: string): string {
38
42
  */
39
43
  function toCamelCase(name: string): string {
40
44
  const pascal = toPascalCase(name);
41
- return pascal.charAt(0).toLowerCase() + pascal.slice(1);
45
+ return `${pascal.charAt(0).toLowerCase()}${pascal.slice(1)}`;
42
46
  }
43
47
 
44
48
  /**
@@ -49,7 +53,26 @@ function toCamelCase(name: string): string {
49
53
  * @private
50
54
  */
51
55
  function escapeTemplateLiteral(str: string): string {
52
- return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
56
+ return str
57
+ .replaceAll("\\", String.raw`\\`)
58
+ .replaceAll("`", "\\`")
59
+ .replaceAll("${", "\\${");
60
+ }
61
+
62
+ /** @private */
63
+ function formatGroupValue(group: string | undefined): string {
64
+ if (group) {
65
+ return `'${group}' as const`;
66
+ }
67
+ return "undefined";
68
+ }
69
+
70
+ /** @private */
71
+ function parseGroupSegments(group: string | undefined): readonly string[] {
72
+ if (group) {
73
+ return group.split("/").map(toCamelCase);
74
+ }
75
+ return [];
53
76
  }
54
77
 
55
78
  /**
@@ -65,7 +88,9 @@ function generateSchemaExpression(vars: readonly SchemaVariable[]): string {
65
88
  const fields = vars
66
89
  .map((v) => {
67
90
  const base = "z.string()";
68
- const expr = v.required ? base : `${base}.optional()`;
91
+ const expr = match(v.required)
92
+ .with(true, () => base)
93
+ .otherwise(() => `${base}.optional()`);
69
94
  return ` ${v.name}: ${expr},`;
70
95
  })
71
96
  .join("\n");
@@ -93,9 +118,9 @@ const HEADER = [
93
118
  export function generatePromptModule(prompt: ParsedPrompt): string {
94
119
  const escaped = escapeTemplateLiteral(prompt.template);
95
120
  const schemaExpr = generateSchemaExpression(prompt.schema);
96
- const groupValue = prompt.group != null ? `'${prompt.group}' as const` : "undefined";
121
+ const groupValue = formatGroupValue(prompt.group);
97
122
 
98
- const lines: string[] = [
123
+ const lines: readonly string[] = [
99
124
  HEADER,
100
125
  `// Source: ${prompt.sourcePath}`,
101
126
  "",
@@ -140,9 +165,9 @@ export function generatePromptModule(prompt: ParsedPrompt): string {
140
165
  * A tree node used during registry code generation.
141
166
  * Leaves hold the camelCase import name; branches hold nested nodes.
142
167
  */
143
- type TreeNode = {
168
+ interface TreeNode {
144
169
  readonly [key: string]: string | TreeNode;
145
- };
170
+ }
146
171
 
147
172
  /**
148
173
  * Build a nested tree from sorted prompts, grouped by their `group` field.
@@ -156,17 +181,16 @@ type TreeNode = {
156
181
  function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
157
182
  return prompts.reduce<Record<string, unknown>>((root, prompt) => {
158
183
  const importName = toCamelCase(prompt.name);
159
- const segments = prompt.group ? prompt.group.split("/").map(toCamelCase) : [];
184
+ const segments = parseGroupSegments(prompt.group);
160
185
 
161
186
  const target = segments.reduce<Record<string, unknown>>((current, segment) => {
162
187
  const existing = current[segment];
163
188
  if (typeof existing === "string") {
164
- throw new Error(
165
- `Collision: prompt "${existing}" and group namespace "${segment}" ` +
166
- "share the same key at the same level.",
189
+ throw new TypeError(
190
+ `Collision: prompt "${existing}" and group namespace "${segment}" share the same key at the same level.`,
167
191
  );
168
192
  }
169
- if (existing == null) {
193
+ if (existing === null || existing === undefined) {
170
194
  current[segment] = {};
171
195
  }
172
196
  return current[segment] as Record<string, unknown>;
@@ -174,8 +198,7 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
174
198
 
175
199
  if (typeof target[importName] === "object" && target[importName] !== null) {
176
200
  throw new Error(
177
- `Collision: prompt "${importName}" conflicts with existing group namespace ` +
178
- `"${importName}" at the same level.`,
201
+ `Collision: prompt "${importName}" conflicts with existing group namespace "${importName}" at the same level.`,
179
202
  );
180
203
  }
181
204
 
@@ -193,13 +216,17 @@ function buildTree(prompts: readonly ParsedPrompt[]): TreeNode {
193
216
  *
194
217
  * @private
195
218
  */
196
- function serializeTree(node: TreeNode, indent: number): string[] {
219
+ function serializeTree(node: TreeNode, indent: number): readonly string[] {
197
220
  const pad = " ".repeat(indent);
198
221
 
199
222
  return Object.entries(node).flatMap(([key, value]) =>
200
- typeof value === "string"
201
- ? [`${pad}${key},`]
202
- : [`${pad}${key}: {`, ...serializeTree(value, indent + 1), `${pad}},`],
223
+ match(typeof value)
224
+ .with("string", () => [`${pad}${key},`])
225
+ .otherwise(() => [
226
+ `${pad}${key}: {`,
227
+ ...serializeTree(value as TreeNode, indent + 1),
228
+ `${pad}},`,
229
+ ]),
203
230
  );
204
231
  }
205
232
 
@@ -220,7 +247,7 @@ export function generateRegistry(prompts: readonly ParsedPrompt[]): string {
220
247
  const tree = buildTree(sorted);
221
248
  const treeLines = serializeTree(tree, 1);
222
249
 
223
- const lines: string[] = [
250
+ const lines: readonly string[] = [
224
251
  HEADER,
225
252
  "",
226
253
  "import { createPromptRegistry } from '@funkai/prompts'",
@@ -2,6 +2,17 @@ import { Liquid } from "liquidjs";
2
2
 
3
3
  const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
4
4
 
5
+ /** Module-level engine — no config needed for variable extraction. */
6
+ const engine = new Liquid();
7
+
8
+ /** @private */
9
+ function extractRoot(variable: unknown): string {
10
+ if (Array.isArray(variable)) {
11
+ return String(variable[0]);
12
+ }
13
+ return String(variable);
14
+ }
15
+
5
16
  /**
6
17
  * Extract top-level variable names from a Liquid template string.
7
18
  *
@@ -9,16 +20,22 @@ const DANGEROUS_NAMES = new Set(["constructor", "__proto__", "prototype"]);
9
20
  * and extract all referenced variable names. Only returns the root
10
21
  * variable name (e.g. `user` from `{{ user.name }}`).
11
22
  *
23
+ * @param template - The Liquid template string to parse.
24
+ * @returns Sorted, deduplicated array of top-level variable names.
12
25
  * @throws {Error} If a variable name is dangerous (e.g. `__proto__`)
26
+ * @example
27
+ * ```ts
28
+ * extractVariables("Hello {{ user.name }}, you have {{ count }} items");
29
+ * // ["count", "user"]
30
+ * ```
13
31
  */
14
- export function extractVariables(template: string): string[] {
15
- const engine = new Liquid();
32
+ export function extractVariables(template: string): readonly string[] {
16
33
  const parsed = engine.parse(template);
17
34
  const variables = engine.variablesSync(parsed);
18
35
 
19
36
  const roots = new Set(
20
37
  variables.map((variable) => {
21
- const root = Array.isArray(variable) ? String(variable[0]) : String(variable);
38
+ const root = extractRoot(variable);
22
39
 
23
40
  if (DANGEROUS_NAMES.has(root)) {
24
41
  throw new Error(`Dangerous variable name "${root}" is not allowed in prompt templates`);