@prometheus-ai/utils 0.5.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/dist/types/abortable.d.ts +27 -0
- package/dist/types/async.d.ts +6 -0
- package/dist/types/cli.d.ts +117 -0
- package/dist/types/color.d.ts +102 -0
- package/dist/types/dirs.d.ts +171 -0
- package/dist/types/env.d.ts +55 -0
- package/dist/types/fetch-retry.d.ts +80 -0
- package/dist/types/format.d.ts +37 -0
- package/dist/types/frontmatter.d.ts +25 -0
- package/dist/types/fs-error.d.ts +31 -0
- package/dist/types/glob.d.ts +28 -0
- package/dist/types/hook-fetch.d.ts +16 -0
- package/dist/types/index.d.ts +29 -0
- package/dist/types/json.d.ts +4 -0
- package/dist/types/logger.d.ts +66 -0
- package/dist/types/mermaid-ascii.d.ts +11 -0
- package/dist/types/mime.d.ts +29 -0
- package/dist/types/peek-file.d.ts +29 -0
- package/dist/types/postmortem.d.ts +29 -0
- package/dist/types/procmgr.d.ts +25 -0
- package/dist/types/prompt.d.ts +18 -0
- package/dist/types/ptree.d.ts +108 -0
- package/dist/types/ring.d.ts +93 -0
- package/dist/types/sanitize-text.d.ts +14 -0
- package/dist/types/snowflake.d.ts +25 -0
- package/dist/types/stream.d.ts +68 -0
- package/dist/types/tab-spacing.d.ts +9 -0
- package/dist/types/temp.d.ts +14 -0
- package/dist/types/type-guards.d.ts +3 -0
- package/dist/types/which.d.ts +37 -0
- package/package.json +61 -0
- package/src/abortable.ts +73 -0
- package/src/async.ts +50 -0
- package/src/cli.ts +432 -0
- package/src/color.ts +302 -0
- package/src/dirs.ts +584 -0
- package/src/env.ts +172 -0
- package/src/fetch-retry.ts +325 -0
- package/src/format.ts +113 -0
- package/src/frontmatter.ts +128 -0
- package/src/fs-error.ts +56 -0
- package/src/glob.ts +189 -0
- package/src/hook-fetch.ts +30 -0
- package/src/index.ts +49 -0
- package/src/json.ts +10 -0
- package/src/logger.ts +417 -0
- package/src/mermaid-ascii.ts +31 -0
- package/src/mime.ts +159 -0
- package/src/peek-file.ts +188 -0
- package/src/postmortem.ts +196 -0
- package/src/procmgr.ts +195 -0
- package/src/prompt.ts +471 -0
- package/src/ptree.ts +390 -0
- package/src/ring.ts +169 -0
- package/src/sanitize-text.ts +38 -0
- package/src/snowflake.ts +136 -0
- package/src/stream.ts +403 -0
- package/src/tab-spacing.ts +342 -0
- package/src/temp.ts +77 -0
- package/src/type-guards.ts +11 -0
- package/src/which.ts +232 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
import type { HelperDelegate, HelperOptions, Template, TemplateDelegate } from "handlebars";
|
|
2
|
+
import Handlebars from "handlebars";
|
|
3
|
+
|
|
4
|
+
export type { HelperDelegate, HelperOptions, Template, TemplateDelegate };
|
|
5
|
+
|
|
6
|
+
export type PromptRenderPhase = "pre-render" | "post-render";
|
|
7
|
+
|
|
8
|
+
export interface PromptFormatOptions {
|
|
9
|
+
renderPhase?: PromptRenderPhase;
|
|
10
|
+
replaceAsciiSymbols?: boolean;
|
|
11
|
+
normalizeRfc2119?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Opening XML tag (not self-closing, not closing)
|
|
15
|
+
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
16
|
+
// Closing XML tag
|
|
17
|
+
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
18
|
+
// Handlebars block end: {{/if}}, {{/has}}, {{/list}}, etc.
|
|
19
|
+
const CLOSING_HBS = /^\{\{\//;
|
|
20
|
+
// Table row
|
|
21
|
+
const TABLE_ROW = /^\|.*\|$/;
|
|
22
|
+
// Table separator (|---|---|)
|
|
23
|
+
const TABLE_SEP = /^\|[-:\s|]+\|$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* RFC 2119 keywords (plus project aliases NEVER/AVOID) wrapped in markdown bold
|
|
27
|
+
* — `**MUST**`, `**MUST NOT**`, `**NEVER**`, etc.
|
|
28
|
+
*/
|
|
29
|
+
const RFC2119_BOLD = /\*\*(MUST NOT|SHOULD NOT|RECOMMENDED|REQUIRED|OPTIONAL|SHOULD|MUST|MAY|NEVER|AVOID)\*\*/g;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalize RFC 2119 markers per project convention:
|
|
33
|
+
* - Strip `**KEYWORD**` bold (visual noise, no semantics).
|
|
34
|
+
* - Alias `MUST NOT` → `NEVER` and `SHOULD NOT` → `AVOID` (single-token equivalents).
|
|
35
|
+
* Skips spans inside inline code (`` `…` ``) so alias definitions can be quoted literally.
|
|
36
|
+
*/
|
|
37
|
+
function normalizeRfc2119(line: string): string {
|
|
38
|
+
const segments = line.split("`");
|
|
39
|
+
for (let i = 0; i < segments.length; i += 2) {
|
|
40
|
+
segments[i] = segments[i]
|
|
41
|
+
.replace(RFC2119_BOLD, "$1")
|
|
42
|
+
.replace(/\bMUST NOT\b/g, "NEVER")
|
|
43
|
+
.replace(/\bSHOULD NOT\b/g, "AVOID");
|
|
44
|
+
}
|
|
45
|
+
return segments.join("`");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Compact a table row by trimming cell padding */
|
|
49
|
+
function compactTableRow(line: string): string {
|
|
50
|
+
const cells = line.split("|");
|
|
51
|
+
return cells.map(c => c.trim()).join("|");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Compact a table separator row */
|
|
55
|
+
function compactTableSep(line: string): string {
|
|
56
|
+
const cells = line.split("|").filter(c => c.trim());
|
|
57
|
+
const normalized = cells.map(c => {
|
|
58
|
+
const trimmed = c.trim();
|
|
59
|
+
const left = trimmed.startsWith(":");
|
|
60
|
+
const right = trimmed.endsWith(":");
|
|
61
|
+
if (left && right) return ":---:";
|
|
62
|
+
if (left) return ":---";
|
|
63
|
+
if (right) return "---:";
|
|
64
|
+
return "---";
|
|
65
|
+
});
|
|
66
|
+
return `|${normalized.join("|")}|`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const HTML_COMMENT_OPEN = "<!--";
|
|
70
|
+
const HTML_COMMENT_CLOSE = "-->";
|
|
71
|
+
|
|
72
|
+
type HtmlCommentState = {
|
|
73
|
+
inHtmlComment: boolean;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function replaceCommonAsciiSymbols(line: string): string {
|
|
77
|
+
return line
|
|
78
|
+
.replace(/\.{3}/g, "…")
|
|
79
|
+
.replace(/<->/g, "↔")
|
|
80
|
+
.replace(/->/g, "→")
|
|
81
|
+
.replace(/<-/g, "←")
|
|
82
|
+
.replace(/!=/g, "≠")
|
|
83
|
+
.replace(/<=/g, "≤")
|
|
84
|
+
.replace(/>=/g, "≥");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function replaceCommonAsciiSymbolsOutsideHtmlComments(line: string, state: HtmlCommentState): string {
|
|
88
|
+
if (!state.inHtmlComment && !line.includes(HTML_COMMENT_OPEN) && !line.includes(HTML_COMMENT_CLOSE)) {
|
|
89
|
+
return replaceCommonAsciiSymbols(line);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let result = "";
|
|
93
|
+
let cursor = 0;
|
|
94
|
+
|
|
95
|
+
while (cursor < line.length) {
|
|
96
|
+
if (state.inHtmlComment) {
|
|
97
|
+
const closeIndex = line.indexOf(HTML_COMMENT_CLOSE, cursor);
|
|
98
|
+
if (closeIndex === -1) {
|
|
99
|
+
return result + line.slice(cursor);
|
|
100
|
+
}
|
|
101
|
+
result += line.slice(cursor, closeIndex + HTML_COMMENT_CLOSE.length);
|
|
102
|
+
cursor = closeIndex + HTML_COMMENT_CLOSE.length;
|
|
103
|
+
state.inHtmlComment = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const openIndex = line.indexOf(HTML_COMMENT_OPEN, cursor);
|
|
108
|
+
if (openIndex === -1) {
|
|
109
|
+
result += replaceCommonAsciiSymbols(line.slice(cursor));
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
result += replaceCommonAsciiSymbols(line.slice(cursor, openIndex));
|
|
114
|
+
const closeIndex = line.indexOf(HTML_COMMENT_CLOSE, openIndex + HTML_COMMENT_OPEN.length);
|
|
115
|
+
if (closeIndex === -1) {
|
|
116
|
+
result += line.slice(openIndex);
|
|
117
|
+
state.inHtmlComment = true;
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result += line.slice(openIndex, closeIndex + HTML_COMMENT_CLOSE.length);
|
|
122
|
+
cursor = closeIndex + HTML_COMMENT_CLOSE.length;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function format(content: string, options: PromptFormatOptions = {}): string {
|
|
129
|
+
const {
|
|
130
|
+
renderPhase = "post-render",
|
|
131
|
+
replaceAsciiSymbols = false,
|
|
132
|
+
normalizeRfc2119: shouldNormalizeRfc2119 = false,
|
|
133
|
+
} = options;
|
|
134
|
+
const isPreRender = renderPhase === "pre-render";
|
|
135
|
+
const lines = content.split("\n");
|
|
136
|
+
const result: string[] = [];
|
|
137
|
+
let inCodeBlock = false;
|
|
138
|
+
|
|
139
|
+
const htmlCommentState: HtmlCommentState = { inHtmlComment: false };
|
|
140
|
+
const topLevelTags: string[] = [];
|
|
141
|
+
|
|
142
|
+
for (let i = 0; i < lines.length; i++) {
|
|
143
|
+
let line = lines[i].trimEnd();
|
|
144
|
+
let trimmedStart = line.trimStart();
|
|
145
|
+
if (trimmedStart.startsWith("```") || trimmedStart.startsWith("~~~")) {
|
|
146
|
+
inCodeBlock = !inCodeBlock;
|
|
147
|
+
result.push(line);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (inCodeBlock) {
|
|
152
|
+
result.push(line);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (replaceAsciiSymbols) {
|
|
157
|
+
line = replaceCommonAsciiSymbolsOutsideHtmlComments(line, htmlCommentState);
|
|
158
|
+
}
|
|
159
|
+
trimmedStart = line.trimStart();
|
|
160
|
+
const trimmed = line.trim();
|
|
161
|
+
|
|
162
|
+
const isOpeningXml = OPENING_XML.test(trimmedStart) && !trimmedStart.endsWith("/>");
|
|
163
|
+
if (isOpeningXml && line.length === trimmedStart.length) {
|
|
164
|
+
const match = OPENING_XML.exec(trimmedStart);
|
|
165
|
+
if (match) topLevelTags.push(match[1]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const closingMatch = CLOSING_XML.exec(trimmedStart);
|
|
169
|
+
if (closingMatch) {
|
|
170
|
+
const tagName = closingMatch[1];
|
|
171
|
+
if (topLevelTags.length > 0 && topLevelTags[topLevelTags.length - 1] === tagName) {
|
|
172
|
+
topLevelTags.pop();
|
|
173
|
+
}
|
|
174
|
+
} else if (isPreRender && trimmedStart.startsWith("{{")) {
|
|
175
|
+
/* keep indentation as-is in pre-render for Handlebars markers */
|
|
176
|
+
} else if (TABLE_SEP.test(trimmedStart)) {
|
|
177
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
178
|
+
line = `${leadingWhitespace}${compactTableSep(trimmedStart)}`;
|
|
179
|
+
} else if (TABLE_ROW.test(trimmedStart)) {
|
|
180
|
+
const leadingWhitespace = line.slice(0, line.length - trimmedStart.length);
|
|
181
|
+
line = `${leadingWhitespace}${compactTableRow(trimmedStart)}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (shouldNormalizeRfc2119) {
|
|
185
|
+
line = normalizeRfc2119(line);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (trimmed === "") {
|
|
189
|
+
const nextLine = lines[i + 1]?.trim() ?? "";
|
|
190
|
+
// Strip any run of 2+ consecutive blank lines entirely; preserve a single blank.
|
|
191
|
+
if (nextLine === "") {
|
|
192
|
+
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
193
|
+
result.pop();
|
|
194
|
+
}
|
|
195
|
+
while (i + 1 < lines.length && lines[i + 1].trim() === "") i++;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const prevLine = result[result.length - 1]?.trim() ?? "";
|
|
199
|
+
if (prevLine === "") {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (CLOSING_XML.test(trimmed) || (isPreRender && CLOSING_HBS.test(trimmed))) {
|
|
205
|
+
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
206
|
+
result.pop();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
result.push(line);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
while (result.length > 0 && result[result.length - 1].trim() === "") {
|
|
214
|
+
result.pop();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return result.join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface TemplateContext extends Record<string, unknown> {
|
|
221
|
+
args?: string[];
|
|
222
|
+
ARGUMENTS?: string;
|
|
223
|
+
arguments?: string;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const handlebars = Handlebars.create();
|
|
227
|
+
|
|
228
|
+
handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string {
|
|
229
|
+
const args = this.args ?? [];
|
|
230
|
+
const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10);
|
|
231
|
+
if (!Number.isFinite(parsedIndex)) return "";
|
|
232
|
+
const zeroBased = parsedIndex - 1;
|
|
233
|
+
if (zeroBased < 0) return "";
|
|
234
|
+
return args[zeroBased] ?? "";
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}}
|
|
239
|
+
* Renders an array with customizable prefix, suffix, and join separator.
|
|
240
|
+
* Note: Use \n in join for newlines (will be unescaped automatically).
|
|
241
|
+
*/
|
|
242
|
+
handlebars.registerHelper(
|
|
243
|
+
"list",
|
|
244
|
+
function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
|
|
245
|
+
if (!Array.isArray(context) || context.length === 0) return "";
|
|
246
|
+
const prefix = (options.hash.prefix as string) ?? "";
|
|
247
|
+
const suffix = (options.hash.suffix as string) ?? "";
|
|
248
|
+
const rawSeparator = (options.hash.join as string) ?? "\n";
|
|
249
|
+
const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
|
|
250
|
+
return context.map(item => `${prefix}${options.fn(item)}${suffix}`).join(separator);
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* {{join array ", "}}
|
|
256
|
+
* Joins an array with a separator (default: ", ").
|
|
257
|
+
*/
|
|
258
|
+
handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
|
|
259
|
+
if (!Array.isArray(context)) return "";
|
|
260
|
+
const sep = typeof separator === "string" ? separator : ", ";
|
|
261
|
+
return context.join(sep);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* {{default value "fallback"}}
|
|
266
|
+
* Returns the value if truthy, otherwise returns the fallback.
|
|
267
|
+
*/
|
|
268
|
+
handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue);
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* {{pluralize count "item" "items"}}
|
|
272
|
+
* Returns "1 item" or "5 items" based on count.
|
|
273
|
+
*/
|
|
274
|
+
handlebars.registerHelper(
|
|
275
|
+
"pluralize",
|
|
276
|
+
(count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* {{#when value "==" compare}}...{{else}}...{{/when}}
|
|
281
|
+
* Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <=
|
|
282
|
+
*/
|
|
283
|
+
handlebars.registerHelper(
|
|
284
|
+
"when",
|
|
285
|
+
function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: Handlebars.HelperOptions): string {
|
|
286
|
+
const ops: Record<string, (a: unknown, b: unknown) => boolean> = {
|
|
287
|
+
"==": (a, b) => a === b,
|
|
288
|
+
"===": (a, b) => a === b,
|
|
289
|
+
"!=": (a, b) => a !== b,
|
|
290
|
+
"!==": (a, b) => a !== b,
|
|
291
|
+
">": (a, b) => (a as number) > (b as number),
|
|
292
|
+
"<": (a, b) => (a as number) < (b as number),
|
|
293
|
+
">=": (a, b) => (a as number) >= (b as number),
|
|
294
|
+
"<=": (a, b) => (a as number) <= (b as number),
|
|
295
|
+
};
|
|
296
|
+
const fn = ops[operator];
|
|
297
|
+
if (!fn) return options.inverse(this);
|
|
298
|
+
return fn(lhs, rhs) ? options.fn(this) : options.inverse(this);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* {{#ifAny a b c}}...{{else}}...{{/ifAny}}
|
|
304
|
+
* True if any argument is truthy.
|
|
305
|
+
*/
|
|
306
|
+
handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string {
|
|
307
|
+
const options = args.pop() as Handlebars.HelperOptions;
|
|
308
|
+
return args.some(Boolean) ? options.fn(this) : options.inverse(this);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* {{#ifAll a b c}}...{{else}}...{{/ifAll}}
|
|
313
|
+
* True if all arguments are truthy.
|
|
314
|
+
*/
|
|
315
|
+
handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
|
|
316
|
+
const options = args.pop() as Handlebars.HelperOptions;
|
|
317
|
+
return args.every(Boolean) ? options.fn(this) : options.inverse(this);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}}
|
|
322
|
+
* Generates a markdown table from an array of objects.
|
|
323
|
+
*/
|
|
324
|
+
handlebars.registerHelper(
|
|
325
|
+
"table",
|
|
326
|
+
function (this: unknown, context: unknown[], options: Handlebars.HelperOptions): string {
|
|
327
|
+
if (!Array.isArray(context) || context.length === 0) return "";
|
|
328
|
+
const headersStr = options.hash.headers as string | undefined;
|
|
329
|
+
const headers = headersStr?.split("|") ?? [];
|
|
330
|
+
const separator = headers.map(() => "---").join(" | ");
|
|
331
|
+
const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : "";
|
|
332
|
+
const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n");
|
|
333
|
+
return headerRow + rows;
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* {{#codeblock lang="diff"}}...{{/codeblock}}
|
|
339
|
+
* Wraps content in a fenced code block.
|
|
340
|
+
*/
|
|
341
|
+
handlebars.registerHelper("codeblock", function (this: unknown, options: Handlebars.HelperOptions): string {
|
|
342
|
+
const lang = (options.hash.lang as string) ?? "";
|
|
343
|
+
const content = options.fn(this).trim();
|
|
344
|
+
return `\`\`\`${lang}\n${content}\n\`\`\``;
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* {{#xml "tag"}}content{{/xml}}
|
|
349
|
+
* Wraps content in XML-style tags. Returns empty string if content is empty.
|
|
350
|
+
*/
|
|
351
|
+
handlebars.registerHelper("xml", function (this: unknown, tag: string, options: Handlebars.HelperOptions): string {
|
|
352
|
+
const content = options.fn(this).trim();
|
|
353
|
+
if (!content) return "";
|
|
354
|
+
return `<${tag}>\n${content}\n</${tag}>`;
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* {{escapeXml value}}
|
|
359
|
+
* Escapes XML special characters: & < > "
|
|
360
|
+
*/
|
|
361
|
+
handlebars.registerHelper("escapeXml", (value: unknown): string => {
|
|
362
|
+
if (value == null) return "";
|
|
363
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* {{len array}}
|
|
368
|
+
* Returns the length of an array or string.
|
|
369
|
+
*/
|
|
370
|
+
handlebars.registerHelper("len", (value: unknown): number => {
|
|
371
|
+
if (Array.isArray(value)) return value.length;
|
|
372
|
+
if (typeof value === "string") return value.length;
|
|
373
|
+
return 0;
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* {{add a b}}
|
|
378
|
+
* Adds two numbers.
|
|
379
|
+
*/
|
|
380
|
+
handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0));
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* {{sub a b}}
|
|
384
|
+
* Subtracts b from a.
|
|
385
|
+
*/
|
|
386
|
+
handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* {{#has collection item}}...{{else}}...{{/has}}
|
|
390
|
+
* Checks if an array includes an item or if a Set/Map has a key.
|
|
391
|
+
*/
|
|
392
|
+
handlebars.registerHelper(
|
|
393
|
+
"has",
|
|
394
|
+
function (this: unknown, collection: unknown, item: unknown, options: Handlebars.HelperOptions): string {
|
|
395
|
+
let found = false;
|
|
396
|
+
if (Array.isArray(collection)) {
|
|
397
|
+
found = collection.includes(item);
|
|
398
|
+
} else if (collection instanceof Set) {
|
|
399
|
+
found = collection.has(item);
|
|
400
|
+
} else if (collection instanceof Map) {
|
|
401
|
+
found = collection.has(item);
|
|
402
|
+
} else if (collection && typeof collection === "object") {
|
|
403
|
+
if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") {
|
|
404
|
+
found = item in collection;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return found ? options.fn(this) : options.inverse(this);
|
|
408
|
+
},
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* {{includes array item}}
|
|
413
|
+
* Returns true if array includes item. For use in other helpers.
|
|
414
|
+
*/
|
|
415
|
+
handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => {
|
|
416
|
+
if (Array.isArray(collection)) return collection.includes(item);
|
|
417
|
+
if (collection instanceof Set) return collection.has(item);
|
|
418
|
+
if (collection instanceof Map) return collection.has(item);
|
|
419
|
+
return false;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* {{not value}}
|
|
424
|
+
* Returns logical NOT of value. For use in subexpressions.
|
|
425
|
+
*/
|
|
426
|
+
handlebars.registerHelper("not", (value: unknown): boolean => !value);
|
|
427
|
+
|
|
428
|
+
handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
|
|
429
|
+
|
|
430
|
+
export function registerHelper(name: string, fn: HelperDelegate): void {
|
|
431
|
+
handlebars.registerHelper(name, fn);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function registerPartial(name: string, fn: Template): void {
|
|
435
|
+
handlebars.registerPartial(name, fn);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Handlebars' lexer greedily matches `}}}` as `CLOSE_UNESCAPED` (the close of a
|
|
440
|
+
* triple-stash `{{{ ... }}}`). When a regular helper close `}}` is immediately
|
|
441
|
+
* followed by a literal `}` (common in compact JSON examples like
|
|
442
|
+
* `{del:{{href ...}}}`), the lexer mistakes the trailing `}}}` for a triple-close
|
|
443
|
+
* and rejects the input.
|
|
444
|
+
*
|
|
445
|
+
* We never use triple-stash (it's redundant under `noEscape: true`), so any run
|
|
446
|
+
* of 3+ closing braces is unambiguously "helper close `}}`" + "literal `}`s".
|
|
447
|
+
* Inject a no-op comment between them so the lexer tokenizes the helper close
|
|
448
|
+
* cleanly and treats the rest as content.
|
|
449
|
+
*/
|
|
450
|
+
function disambiguateClosingBraces(template: string): string {
|
|
451
|
+
return template.replace(/\}\}(\}+)/g, "}}{{!---}}$1");
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const compiledTemplateCache = new Map<string, (context: TemplateContext) => string>();
|
|
455
|
+
|
|
456
|
+
export function compile(template: string): (context: TemplateContext) => string {
|
|
457
|
+
const disambiguated = disambiguateClosingBraces(template);
|
|
458
|
+
const cached = compiledTemplateCache.get(disambiguated);
|
|
459
|
+
if (cached) return cached;
|
|
460
|
+
const compiled = handlebars.compile(disambiguated, { noEscape: true, strict: false }) as (
|
|
461
|
+
context: TemplateContext,
|
|
462
|
+
) => string;
|
|
463
|
+
compiledTemplateCache.set(disambiguated, compiled);
|
|
464
|
+
return compiled;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export function render(template: string, context: TemplateContext = {}): string {
|
|
468
|
+
const compiled = compile(template);
|
|
469
|
+
const rendered = compiled(context ?? {});
|
|
470
|
+
return format(rendered, { renderPhase: "post-render" });
|
|
471
|
+
}
|