prompt-tree 1.0.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/index.js +209 -0
- package/index.test.js +360 -0
- package/package.json +12 -0
package/index.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel value returned by `when()` for falsy conditions with no else branch.
|
|
3
|
+
* Filtered out at render time.
|
|
4
|
+
*/
|
|
5
|
+
export const EMPTY = Symbol("EMPTY");
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {{ type: 'section'; title: string; content: Block[] }} SectionNode
|
|
9
|
+
* @typedef {{ type: 'raw'; value: string }} RawNode
|
|
10
|
+
* @typedef {string | SectionNode | RawNode | typeof EMPTY | null | undefined | false} Block
|
|
11
|
+
* @typedef {{ headingDepth?: number }} MarkdownOptions
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Conditional block helper. Evaluates eagerly — just a ternary
|
|
16
|
+
* that returns EMPTY instead of undefined for the missing else branch.
|
|
17
|
+
*
|
|
18
|
+
* @param {*} condition
|
|
19
|
+
* @param {Block} ifTrue
|
|
20
|
+
* @param {Block} [ifFalse]
|
|
21
|
+
* @returns {Block}
|
|
22
|
+
*/
|
|
23
|
+
export function when(condition, ifTrue, ifFalse) {
|
|
24
|
+
return condition ? ifTrue : (ifFalse !== undefined ? ifFalse : EMPTY);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Creates a section node with a title and flat content array.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} title
|
|
31
|
+
* @param {Block[]} content
|
|
32
|
+
* @returns {SectionNode}
|
|
33
|
+
*/
|
|
34
|
+
export function section(title, content) {
|
|
35
|
+
return { type: "section", title, content };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wraps a string so it passes through renderers without escaping.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} value
|
|
42
|
+
* @returns {RawNode}
|
|
43
|
+
*/
|
|
44
|
+
export function raw(value) {
|
|
45
|
+
return { type: "raw", value };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {Block} block
|
|
50
|
+
* @returns {block is SectionNode}
|
|
51
|
+
*/
|
|
52
|
+
function isSection(block) {
|
|
53
|
+
return block != null && typeof block === "object" && block.type === "section";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Block} block
|
|
58
|
+
* @returns {block is RawNode}
|
|
59
|
+
*/
|
|
60
|
+
function isRaw(block) {
|
|
61
|
+
return block != null && typeof block === "object" && block.type === "raw";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Block} block
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isRenderable(block) {
|
|
69
|
+
if (block === EMPTY || block == null || block === false || block === "") {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @param {Block[]} blocks
|
|
77
|
+
* @returns {Block[]}
|
|
78
|
+
*/
|
|
79
|
+
function filter(blocks) {
|
|
80
|
+
return blocks.filter(isRenderable);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Markdown renderer ──────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {Block[]} blocks
|
|
87
|
+
* @param {number} depth
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
function renderMarkdown(blocks, depth) {
|
|
91
|
+
/** @type {{ text: string; kind: 'string' | 'section' }[]} */
|
|
92
|
+
const parts = [];
|
|
93
|
+
const filtered = filter(blocks);
|
|
94
|
+
|
|
95
|
+
for (const block of filtered) {
|
|
96
|
+
if (isSection(block)) {
|
|
97
|
+
const heading = "#".repeat(depth) + " " + block.title;
|
|
98
|
+
const body = renderMarkdown(block.content, depth + 1);
|
|
99
|
+
if (body) {
|
|
100
|
+
parts.push({ text: heading + "\n\n" + body, kind: "section" });
|
|
101
|
+
}
|
|
102
|
+
// Empty section — omit entirely
|
|
103
|
+
} else if (typeof block === "string" || isRaw(block)) {
|
|
104
|
+
const text = isRaw(block) ? block.value : block;
|
|
105
|
+
const prev = parts.length ? parts[parts.length - 1] : null;
|
|
106
|
+
if (prev && prev.kind === "string") {
|
|
107
|
+
// Consecutive strings merge with single newline (tight lists)
|
|
108
|
+
prev.text += "\n" + text;
|
|
109
|
+
} else {
|
|
110
|
+
parts.push({ text, kind: "string" });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return parts.map((p) => p.text).join("\n\n");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── XML renderer ───────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {string} str
|
|
122
|
+
* @returns {string}
|
|
123
|
+
*/
|
|
124
|
+
function escapeXml(str) {
|
|
125
|
+
return str
|
|
126
|
+
.replace(/&/g, "&")
|
|
127
|
+
.replace(/</g, "<")
|
|
128
|
+
.replace(/>/g, ">")
|
|
129
|
+
.replace(/"/g, """);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} text
|
|
134
|
+
* @param {number} indent
|
|
135
|
+
* @returns {string}
|
|
136
|
+
*/
|
|
137
|
+
function indentText(text, indent) {
|
|
138
|
+
const prefix = " ".repeat(indent);
|
|
139
|
+
return text
|
|
140
|
+
.split("\n")
|
|
141
|
+
.map((line) => (line ? prefix + line : line))
|
|
142
|
+
.join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* @param {string} title
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
function toTagName(title) {
|
|
150
|
+
return title
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
153
|
+
.replace(/^-|-$/g, "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {Block[]} blocks
|
|
158
|
+
* @param {number} indent
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
function renderXml(blocks, indent) {
|
|
162
|
+
const parts = [];
|
|
163
|
+
const filtered = filter(blocks);
|
|
164
|
+
|
|
165
|
+
for (const block of filtered) {
|
|
166
|
+
if (isSection(block)) {
|
|
167
|
+
const body = renderXml(block.content, indent + 1);
|
|
168
|
+
if (!body) continue; // empty section — omit
|
|
169
|
+
|
|
170
|
+
const tag = toTagName(block.title);
|
|
171
|
+
parts.push(`<${tag}>\n` + indentText(body, 1) + `\n</${tag}>`);
|
|
172
|
+
} else if (isRaw(block)) {
|
|
173
|
+
parts.push(block.value);
|
|
174
|
+
} else if (typeof block === "string") {
|
|
175
|
+
parts.push(escapeXml(block));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return parts.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Entry point ────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Builds a prompt tree from a variadic list of blocks (strings, sections, when results).
|
|
186
|
+
* Call `.markdown()` or `.xml()` on the result to render.
|
|
187
|
+
*
|
|
188
|
+
* @param {...Block} blocks
|
|
189
|
+
* @returns {{ markdown(options?: MarkdownOptions): string, xml(options?: XmlOptions): string }}
|
|
190
|
+
*/
|
|
191
|
+
export default function prompt(...blocks) {
|
|
192
|
+
return {
|
|
193
|
+
/**
|
|
194
|
+
* Render as Markdown with `#` headings.
|
|
195
|
+
* @param {MarkdownOptions} [options]
|
|
196
|
+
*/
|
|
197
|
+
markdown(options = {}) {
|
|
198
|
+
const { headingDepth = 2 } = options;
|
|
199
|
+
return renderMarkdown(blocks, headingDepth);
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Render as XML with semantic tag names derived from section titles.
|
|
204
|
+
*/
|
|
205
|
+
xml() {
|
|
206
|
+
return renderXml(blocks, 0);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
package/index.test.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import prompt, { section, when, raw, EMPTY } from "./index.js";
|
|
4
|
+
|
|
5
|
+
describe("when()", () => {
|
|
6
|
+
it("returns ifTrue when condition is truthy", () => {
|
|
7
|
+
assert.equal(when(true, "yes", "no"), "yes");
|
|
8
|
+
assert.equal(when(1, "yes"), "yes");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("returns ifFalse when condition is falsy and ifFalse provided", () => {
|
|
12
|
+
assert.equal(when(false, "yes", "no"), "no");
|
|
13
|
+
assert.equal(when(0, "yes", "no"), "no");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns EMPTY when condition is falsy and no ifFalse", () => {
|
|
17
|
+
assert.equal(when(false, "yes"), EMPTY);
|
|
18
|
+
assert.equal(when(null, "yes"), EMPTY);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("returns section nodes", () => {
|
|
22
|
+
const s = section("T", ["content"]);
|
|
23
|
+
assert.equal(when(true, s), s);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("markdown renderer", () => {
|
|
28
|
+
it("renders plain strings", () => {
|
|
29
|
+
const result = prompt("Hello.", "World.").markdown();
|
|
30
|
+
assert.equal(result, "Hello.\nWorld.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders a single section", () => {
|
|
34
|
+
const result = prompt(section("RULES", ["Be concise."])).markdown();
|
|
35
|
+
assert.equal(result, "## RULES\n\nBe concise.");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders nested sections with incrementing depth", () => {
|
|
39
|
+
const result = prompt(
|
|
40
|
+
section("OUTER", [
|
|
41
|
+
"Intro.",
|
|
42
|
+
section("INNER", ["Detail."]),
|
|
43
|
+
])
|
|
44
|
+
).markdown();
|
|
45
|
+
assert.equal(result, "## OUTER\n\nIntro.\n\n### INNER\n\nDetail.");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("renders deeply nested sections", () => {
|
|
49
|
+
const result = prompt(
|
|
50
|
+
section("L1", [
|
|
51
|
+
section("L2", [
|
|
52
|
+
section("L3", ["Deep."]),
|
|
53
|
+
]),
|
|
54
|
+
])
|
|
55
|
+
).markdown();
|
|
56
|
+
assert.equal(result, "## L1\n\n### L2\n\n#### L3\n\nDeep.");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("joins consecutive strings with single newline (tight lists)", () => {
|
|
60
|
+
const result = prompt(
|
|
61
|
+
section("LIST", [
|
|
62
|
+
"- Item one",
|
|
63
|
+
"- Item two",
|
|
64
|
+
"- Item three",
|
|
65
|
+
])
|
|
66
|
+
).markdown();
|
|
67
|
+
assert.equal(result, "## LIST\n\n- Item one\n- Item two\n- Item three");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("separates sections from strings with double newline", () => {
|
|
71
|
+
const result = prompt(
|
|
72
|
+
section("PARENT", [
|
|
73
|
+
"Before subsection.",
|
|
74
|
+
section("CHILD", ["In child."]),
|
|
75
|
+
"After subsection.",
|
|
76
|
+
])
|
|
77
|
+
).markdown();
|
|
78
|
+
assert.equal(
|
|
79
|
+
result,
|
|
80
|
+
"## PARENT\n\nBefore subsection.\n\n### CHILD\n\nIn child.\n\nAfter subsection."
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("mixes top-level strings and sections", () => {
|
|
85
|
+
const result = prompt(
|
|
86
|
+
"You are helpful.",
|
|
87
|
+
section("RULES", ["Be concise."]),
|
|
88
|
+
).markdown();
|
|
89
|
+
assert.equal(result, "You are helpful.\n\n## RULES\n\nBe concise.");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("respects headingDepth option", () => {
|
|
93
|
+
const result = prompt(section("A", ["text"])).markdown({ headingDepth: 1 });
|
|
94
|
+
assert.equal(result, "# A\n\ntext");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("xml renderer", () => {
|
|
99
|
+
it("renders plain strings", () => {
|
|
100
|
+
const result = prompt("Hello.").xml();
|
|
101
|
+
assert.equal(result, "Hello.");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("renders a section with semantic tag name", () => {
|
|
105
|
+
const result = prompt(section("RULES", ["Be concise."])).xml();
|
|
106
|
+
assert.equal(result, "<rules>\n Be concise.\n</rules>");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders nested sections with indentation", () => {
|
|
110
|
+
const result = prompt(
|
|
111
|
+
section("OUTER", [
|
|
112
|
+
"Intro.",
|
|
113
|
+
section("INNER", ["Detail."]),
|
|
114
|
+
])
|
|
115
|
+
).xml();
|
|
116
|
+
assert.equal(
|
|
117
|
+
result,
|
|
118
|
+
"<outer>\n" +
|
|
119
|
+
" Intro.\n" +
|
|
120
|
+
" <inner>\n" +
|
|
121
|
+
" Detail.\n" +
|
|
122
|
+
" </inner>\n" +
|
|
123
|
+
"</outer>"
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("converts multi-word titles to kebab-case tags", () => {
|
|
128
|
+
const result = prompt(
|
|
129
|
+
section("MY RULES", ["Be concise."])
|
|
130
|
+
).xml();
|
|
131
|
+
assert.equal(result, "<my-rules>\n Be concise.\n</my-rules>");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("mixes top-level strings and sections", () => {
|
|
135
|
+
const result = prompt(
|
|
136
|
+
"System intro.",
|
|
137
|
+
section("RULES", ["Be nice."]),
|
|
138
|
+
).xml();
|
|
139
|
+
assert.equal(
|
|
140
|
+
result,
|
|
141
|
+
"System intro.\n<rules>\n Be nice.\n</rules>"
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("conditional blocks", () => {
|
|
147
|
+
it("includes content when condition is true", () => {
|
|
148
|
+
const result = prompt(when(true, "Visible.")).markdown();
|
|
149
|
+
assert.equal(result, "Visible.");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("excludes content when condition is false (no else)", () => {
|
|
153
|
+
const result = prompt(when(false, "Hidden.")).markdown();
|
|
154
|
+
assert.equal(result, "");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("uses ifFalse when condition is false", () => {
|
|
158
|
+
const result = prompt(when(false, "A", "B")).markdown();
|
|
159
|
+
assert.equal(result, "B");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("handles conditional sections", () => {
|
|
163
|
+
const result = prompt(
|
|
164
|
+
when(true, section("VISIBLE", ["Yes."])),
|
|
165
|
+
when(false, section("HIDDEN", ["No."])),
|
|
166
|
+
).markdown();
|
|
167
|
+
assert.equal(result, "## VISIBLE\n\nYes.");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("handles when() inside section content", () => {
|
|
171
|
+
const result = prompt(
|
|
172
|
+
section("RULES", [
|
|
173
|
+
"Always applies.",
|
|
174
|
+
when(true, "Conditionally applies."),
|
|
175
|
+
when(false, "Never applies."),
|
|
176
|
+
])
|
|
177
|
+
).markdown();
|
|
178
|
+
assert.equal(result, "## RULES\n\nAlways applies.\nConditionally applies.");
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("empty section elimination", () => {
|
|
183
|
+
it("omits sections with no renderable content", () => {
|
|
184
|
+
const result = prompt(
|
|
185
|
+
section("EMPTY", [when(false, "nope")]),
|
|
186
|
+
section("FULL", ["present"]),
|
|
187
|
+
).markdown();
|
|
188
|
+
assert.equal(result, "## FULL\n\npresent");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("omits sections whose children are all empty", () => {
|
|
192
|
+
const result = prompt(
|
|
193
|
+
section("PARENT", [
|
|
194
|
+
section("CHILD", [when(false, "nope")]),
|
|
195
|
+
])
|
|
196
|
+
).markdown();
|
|
197
|
+
assert.equal(result, "");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("omits empty sections in XML too", () => {
|
|
201
|
+
const result = prompt(
|
|
202
|
+
section("GONE", [null, false, "", EMPTY]),
|
|
203
|
+
section("HERE", ["text"]),
|
|
204
|
+
).xml();
|
|
205
|
+
assert.equal(result, "<here>\n text\n</here>");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe("falsy value filtering", () => {
|
|
210
|
+
it("filters null, undefined, false, empty string, and EMPTY", () => {
|
|
211
|
+
const result = prompt(
|
|
212
|
+
null,
|
|
213
|
+
undefined,
|
|
214
|
+
false,
|
|
215
|
+
"",
|
|
216
|
+
EMPTY,
|
|
217
|
+
"Survivor.",
|
|
218
|
+
).markdown();
|
|
219
|
+
assert.equal(result, "Survivor.");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("filters falsy values inside sections", () => {
|
|
223
|
+
const result = prompt(
|
|
224
|
+
section("S", [null, "Keep.", false, "", undefined, EMPTY])
|
|
225
|
+
).markdown();
|
|
226
|
+
assert.equal(result, "## S\n\nKeep.");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("mixed static and dynamic content", () => {
|
|
231
|
+
it("handles the full example from the spec", () => {
|
|
232
|
+
const hasTickets = true;
|
|
233
|
+
const templates = [
|
|
234
|
+
{
|
|
235
|
+
tag: "BUG",
|
|
236
|
+
fields: [
|
|
237
|
+
{ key: "severity", value: "high" },
|
|
238
|
+
{ key: "component", value: "auth" },
|
|
239
|
+
],
|
|
240
|
+
instructions: "Prioritize this.",
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
tag: "FEATURE",
|
|
244
|
+
fields: [{ key: "scope", value: "dashboard" }],
|
|
245
|
+
instructions: null,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
const sys = prompt(
|
|
250
|
+
"You are a helpful assistant.",
|
|
251
|
+
|
|
252
|
+
section("RULES", [
|
|
253
|
+
"Always be concise.",
|
|
254
|
+
when(
|
|
255
|
+
hasTickets,
|
|
256
|
+
section("TICKETS", [
|
|
257
|
+
"Use the ticket system.",
|
|
258
|
+
...templates.map((t) =>
|
|
259
|
+
section(t.tag, [
|
|
260
|
+
...t.fields.map((f) => `- ${f.key}: ${f.value}`),
|
|
261
|
+
when(t.instructions, `Custom instructions: ${t.instructions}`),
|
|
262
|
+
])
|
|
263
|
+
),
|
|
264
|
+
])
|
|
265
|
+
),
|
|
266
|
+
]),
|
|
267
|
+
|
|
268
|
+
section("BEHAVIOUR", [
|
|
269
|
+
when(hasTickets, "Offer to escalate tickets.", "Show contact info."),
|
|
270
|
+
"Keep answers short.",
|
|
271
|
+
])
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const md = sys.markdown();
|
|
275
|
+
// Top-level sections at ##, nested at ###, etc.
|
|
276
|
+
assert.ok(md.includes("## RULES"));
|
|
277
|
+
assert.ok(md.includes("### TICKETS"));
|
|
278
|
+
assert.ok(md.includes("#### BUG"));
|
|
279
|
+
assert.ok(md.includes("- severity: high"));
|
|
280
|
+
assert.ok(md.includes("Custom instructions: Prioritize this."));
|
|
281
|
+
assert.ok(md.includes("#### FEATURE"));
|
|
282
|
+
assert.ok(md.includes("- scope: dashboard"));
|
|
283
|
+
assert.ok(!md.includes("Custom instructions: null"));
|
|
284
|
+
assert.ok(md.includes("## BEHAVIOUR"));
|
|
285
|
+
assert.ok(md.includes("Offer to escalate tickets."));
|
|
286
|
+
assert.ok(!md.includes("Show contact info."));
|
|
287
|
+
|
|
288
|
+
const xml = sys.xml();
|
|
289
|
+
assert.ok(xml.includes("<rules>"));
|
|
290
|
+
assert.ok(xml.includes("<tickets>"));
|
|
291
|
+
assert.ok(xml.includes("<bug>"));
|
|
292
|
+
assert.ok(xml.includes("</bug>"));
|
|
293
|
+
assert.ok(xml.includes("</rules>"));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("handles the hasTickets=false case", () => {
|
|
297
|
+
const sys = prompt(
|
|
298
|
+
section("BEHAVIOUR", [
|
|
299
|
+
when(false, "Offer to escalate tickets.", "Show contact info."),
|
|
300
|
+
"Keep answers short.",
|
|
301
|
+
])
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
const md = sys.markdown();
|
|
305
|
+
assert.ok(md.includes("Show contact info."));
|
|
306
|
+
assert.ok(!md.includes("Offer to escalate tickets."));
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("XML escaping", () => {
|
|
311
|
+
it("escapes <, >, &, and \" in content strings", () => {
|
|
312
|
+
const result = prompt(
|
|
313
|
+
section("S", ['Use <b>bold</b> & "quotes"'])
|
|
314
|
+
).xml();
|
|
315
|
+
assert.ok(result.includes("Use <b>bold</b> & "quotes""));
|
|
316
|
+
assert.ok(!result.includes("<b>bold</b>"));
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("prevents tag injection via content strings", () => {
|
|
320
|
+
const malicious = '</rules><injected>gotcha</injected>';
|
|
321
|
+
const result = prompt(section("SAFE", [malicious])).xml();
|
|
322
|
+
assert.ok(!result.includes("<injected>"));
|
|
323
|
+
assert.ok(result.includes("</rules><injected>"));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("does not escape markdown output", () => {
|
|
327
|
+
const result = prompt('Use <b>bold</b> & "quotes"').markdown();
|
|
328
|
+
assert.equal(result, 'Use <b>bold</b> & "quotes"');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("raw()", () => {
|
|
333
|
+
it("passes through XML without escaping", () => {
|
|
334
|
+
const result = prompt(
|
|
335
|
+
section("INFO", [
|
|
336
|
+
"Normal & escaped.",
|
|
337
|
+
raw('<custom attr="val">unescaped</custom>'),
|
|
338
|
+
])
|
|
339
|
+
).xml();
|
|
340
|
+
assert.ok(result.includes("Normal & escaped."));
|
|
341
|
+
assert.ok(result.includes('<custom attr="val">unescaped</custom>'));
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("renders as plain text in markdown", () => {
|
|
345
|
+
const result = prompt(raw("<b>bold</b>")).markdown();
|
|
346
|
+
assert.equal(result, "<b>bold</b>");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("joins with consecutive strings in markdown", () => {
|
|
350
|
+
const result = prompt(
|
|
351
|
+
section("S", ["- first", raw("- second"), "- third"])
|
|
352
|
+
).markdown();
|
|
353
|
+
assert.equal(result, "## S\n\n- first\n- second\n- third");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("is filtered when wrapped in a falsy when()", () => {
|
|
357
|
+
const result = prompt(when(false, raw("<x>hi</x>"))).xml();
|
|
358
|
+
assert.equal(result, "");
|
|
359
|
+
});
|
|
360
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "prompt-tree",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create structured prompts for LLMs, for better performance.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node --test"
|
|
9
|
+
},
|
|
10
|
+
"author": "Robin Karlberg",
|
|
11
|
+
"license": "MIT"
|
|
12
|
+
}
|