@gajae-code/utils 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/package.json +2 -2
  2. package/src/logger.ts +142 -54
  3. package/src/prompt.ts +205 -180
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.9.2",
4
+ "version": "0.9.4",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.9.2",
34
+ "@gajae-code/natives": "0.9.4",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
package/src/logger.ts CHANGED
@@ -11,8 +11,7 @@
11
11
  */
12
12
  import { AsyncLocalStorage } from "node:async_hooks";
13
13
  import * as fs from "node:fs";
14
- import winston from "winston";
15
- import DailyRotateFile from "winston-daily-rotate-file";
14
+ import type * as winston from "winston";
16
15
  import { getLogsDir } from "./dirs";
17
16
 
18
17
  /** Ensure a logs directory exists; return the resolved path. */
@@ -23,28 +22,59 @@ function ensureDir(dir: string): string {
23
22
  return dir;
24
23
  }
25
24
 
26
- /** Custom format that includes pid and flattens metadata */
27
- const logFormat = winston.format.combine(
28
- winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
29
- winston.format.printf(({ timestamp, level, message, ...meta }) => {
30
- const entry: Record<string, unknown> = {
31
- timestamp,
32
- level,
33
- pid: process.pid,
34
- message,
35
- };
36
- // Flatten metadata into entry
37
- for (const [key, value] of Object.entries(meta)) {
38
- if (key !== "level" && key !== "timestamp" && key !== "message") {
39
- entry[key] = value;
25
+ type WinstonModule = typeof import("winston");
26
+ type DailyRotateFileCtor = typeof import("winston-daily-rotate-file");
27
+ type Logger = winston.Logger;
28
+ type Transport = winston.transport;
29
+ type LogLevel = "error" | "warn" | "info" | "debug";
30
+ type LogRecord = { level: LogLevel; message: string; context?: Record<string, unknown> };
31
+
32
+ let winstonModule: WinstonModule | undefined;
33
+ let dailyRotateFileCtor: DailyRotateFileCtor | undefined;
34
+ let winstonLogger: Logger | undefined;
35
+ let loggerInit: Promise<Logger> | undefined;
36
+ let flushingBufferedLogs = false;
37
+ const bufferedLogs: LogRecord[] = [];
38
+ /** Cap pre-init buffering so a hung/failed winston import cannot grow heap unboundedly. */
39
+ const MAX_BUFFERED_LOGS = 10_000;
40
+ let transportOptions: { console?: boolean; file?: boolean | string } = { file: true };
41
+
42
+ async function loadLoggingModules(): Promise<{ winston: WinstonModule; DailyRotateFile: DailyRotateFileCtor }> {
43
+ if (!winstonModule || !dailyRotateFileCtor) {
44
+ const [winstonImport, dailyRotateFileImport] = await Promise.all([
45
+ import("winston"),
46
+ import("winston-daily-rotate-file"),
47
+ ]);
48
+ winstonModule = (winstonImport.default ?? winstonImport) as WinstonModule;
49
+ dailyRotateFileCtor = dailyRotateFileImport.default;
50
+ }
51
+ return { winston: winstonModule, DailyRotateFile: dailyRotateFileCtor };
52
+ }
53
+
54
+ /** Build the JSON log formatter after winston is loaded. */
55
+ function makeLogFormat(winston: WinstonModule): winston.Logform.Format {
56
+ return winston.format.combine(
57
+ winston.format.timestamp({ format: "YYYY-MM-DDTHH:mm:ss.SSSZ" }),
58
+ winston.format.printf(({ timestamp, level, message, ...meta }) => {
59
+ const entry: Record<string, unknown> = {
60
+ timestamp,
61
+ level,
62
+ pid: process.pid,
63
+ message,
64
+ };
65
+ // Flatten metadata into entry
66
+ for (const [key, value] of Object.entries(meta)) {
67
+ if (key !== "level" && key !== "timestamp" && key !== "message") {
68
+ entry[key] = value;
69
+ }
40
70
  }
41
- }
42
- return JSON.stringify(entry);
43
- }),
44
- );
71
+ return JSON.stringify(entry);
72
+ }),
73
+ );
74
+ }
45
75
 
46
76
  /** Build a rotating file transport, materializing the target directory lazily. */
47
- function makeFileTransport(dir?: string): winston.transport {
77
+ function makeFileTransport(DailyRotateFile: DailyRotateFileCtor, dir?: string): Transport {
48
78
  return new DailyRotateFile({
49
79
  dirname: ensureDir(dir ?? getLogsDir()),
50
80
  filename: "gjc.%DATE%.log",
@@ -55,18 +85,83 @@ function makeFileTransport(dir?: string): winston.transport {
55
85
  });
56
86
  }
57
87
 
58
- function makeConsoleTransport(): winston.transport {
59
- return new winston.transports.Console({ format: logFormat });
88
+ function makeConsoleTransport(winston: WinstonModule): Transport {
89
+ return new winston.transports.Console({ format: makeLogFormat(winston) });
90
+ }
91
+
92
+ function applyTransports(
93
+ logger: Logger,
94
+ modules: { winston: WinstonModule; DailyRotateFile: DailyRotateFileCtor },
95
+ ): void {
96
+ logger.clear();
97
+ if (transportOptions.file) {
98
+ logger.add(
99
+ makeFileTransport(
100
+ modules.DailyRotateFile,
101
+ typeof transportOptions.file === "string" ? transportOptions.file : undefined,
102
+ ),
103
+ );
104
+ }
105
+ if (transportOptions.console) logger.add(makeConsoleTransport(modules.winston));
60
106
  }
61
107
 
62
108
  /** The winston logger instance. Default: file ON (TUI-safe), console OFF. */
63
- const winstonLogger = winston.createLogger({
64
- level: "debug",
65
- format: logFormat,
66
- transports: [makeFileTransport()],
67
- // Don't exit on error - logging failures shouldn't crash the app
68
- exitOnError: false,
69
- });
109
+ async function getWinstonLogger(): Promise<Logger> {
110
+ if (winstonLogger) return winstonLogger;
111
+ loggerInit ??= (async () => {
112
+ const modules = await loadLoggingModules();
113
+ const logger = modules.winston.createLogger({
114
+ level: "debug",
115
+ format: makeLogFormat(modules.winston),
116
+ transports: [],
117
+ // Don't exit on error - logging failures shouldn't crash the app
118
+ exitOnError: false,
119
+ });
120
+ applyTransports(logger, modules);
121
+ winstonLogger = logger;
122
+ flushBufferedLogs();
123
+ return logger;
124
+ })();
125
+ return loggerInit;
126
+ }
127
+
128
+ function flushBufferedLogs(): void {
129
+ if (!winstonLogger || flushingBufferedLogs) return;
130
+ flushingBufferedLogs = true;
131
+ try {
132
+ // Loop: records buffered by reentrant writes during a flush pass (e.g.
133
+ // a transport callback that logs) are drained by the next pass instead
134
+ // of being stranded forever.
135
+ while (bufferedLogs.length > 0) {
136
+ for (const record of bufferedLogs.splice(0)) {
137
+ winstonLogger[record.level](record.message, record.context);
138
+ }
139
+ }
140
+ } finally {
141
+ flushingBufferedLogs = false;
142
+ }
143
+ }
144
+
145
+ function writeLog(level: LogLevel, message: string, context?: Record<string, unknown>): void {
146
+ try {
147
+ if (winstonLogger && !flushingBufferedLogs) {
148
+ winstonLogger[level](message, context);
149
+ return;
150
+ }
151
+ bufferedLogs.push({ level, message, context });
152
+ if (bufferedLogs.length > MAX_BUFFERED_LOGS) {
153
+ bufferedLogs.splice(0, bufferedLogs.length - MAX_BUFFERED_LOGS);
154
+ }
155
+ void getWinstonLogger().catch(() => {
156
+ bufferedLogs.length = 0;
157
+ // Allow a later write to retry initialization instead of pinning a
158
+ // rejected promise forever.
159
+ loggerInit = undefined;
160
+ });
161
+ } catch {
162
+ // Silently ignore logging failures
163
+ }
164
+ }
70
165
 
71
166
  /**
72
167
  * Replace the active log transports. Pass `console: true, file: false` for
@@ -74,11 +169,20 @@ const winstonLogger = winston.createLogger({
74
169
  * logs piped into a process supervisor instead of the rotating file.
75
170
  */
76
171
  export function setTransports(opts: { console?: boolean; file?: boolean | string }): void {
77
- winstonLogger.clear();
78
- if (opts.file) {
79
- winstonLogger.add(makeFileTransport(typeof opts.file === "string" ? opts.file : undefined));
172
+ transportOptions = opts;
173
+ if (winstonLogger && winstonModule && dailyRotateFileCtor) {
174
+ applyTransports(winstonLogger, { winston: winstonModule, DailyRotateFile: dailyRotateFileCtor });
175
+ } else if (loggerInit) {
176
+ // Init in flight: re-apply once it settles so the new options can never
177
+ // be silently skipped by an ordering race inside the init closure.
178
+ void loggerInit
179
+ .then(logger => {
180
+ if (winstonModule && dailyRotateFileCtor) {
181
+ applyTransports(logger, { winston: winstonModule, DailyRotateFile: dailyRotateFileCtor });
182
+ }
183
+ })
184
+ .catch(() => {});
80
185
  }
81
- if (opts.console) winstonLogger.add(makeConsoleTransport());
82
186
  }
83
187
 
84
188
  /**
@@ -87,11 +191,7 @@ export function setTransports(opts: { console?: boolean; file?: boolean | string
87
191
  * @param context - The context to log.
88
192
  */
89
193
  export function error(message: string, context?: Record<string, unknown>): void {
90
- try {
91
- winstonLogger.error(message, context);
92
- } catch {
93
- // Silently ignore logging failures
94
- }
194
+ writeLog("error", message, context);
95
195
  }
96
196
 
97
197
  /**
@@ -100,11 +200,7 @@ export function error(message: string, context?: Record<string, unknown>): void
100
200
  * @param context - The context to log.
101
201
  */
102
202
  export function warn(message: string, context?: Record<string, unknown>): void {
103
- try {
104
- winstonLogger.warn(message, context);
105
- } catch {
106
- // Silently ignore logging failures
107
- }
203
+ writeLog("warn", message, context);
108
204
  }
109
205
 
110
206
  /**
@@ -113,11 +209,7 @@ export function warn(message: string, context?: Record<string, unknown>): void {
113
209
  * @param context - The context to log.
114
210
  */
115
211
  export function info(message: string, context?: Record<string, unknown>): void {
116
- try {
117
- winstonLogger.info(message, context);
118
- } catch {
119
- // Silently ignore logging failures
120
- }
212
+ writeLog("info", message, context);
121
213
  }
122
214
 
123
215
  /**
@@ -126,11 +218,7 @@ export function info(message: string, context?: Record<string, unknown>): void {
126
218
  * @param context - The context to log.
127
219
  */
128
220
  export function debug(message: string, context?: Record<string, unknown>): void {
129
- try {
130
- winstonLogger.debug(message, context);
131
- } catch {
132
- // Silently ignore logging failures
133
- }
221
+ writeLog("debug", message, context);
134
222
  }
135
223
 
136
224
  const LOGGED_TIMING_THRESHOLD_MS = 0.5;
package/src/prompt.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import { createRequire } from "node:module";
1
2
  import type { HelperDelegate, HelperOptions, Template, TemplateDelegate } from "handlebars";
2
- import Handlebars from "handlebars";
3
3
 
4
4
  export type { HelperDelegate, HelperOptions, Template, TemplateDelegate };
5
5
 
@@ -223,107 +223,132 @@ export interface TemplateContext extends Record<string, unknown> {
223
223
  arguments?: string;
224
224
  }
225
225
 
226
- const handlebars = Handlebars.create();
226
+ type HandlebarsRuntime = typeof import("handlebars");
227
+ type HandlebarsInstance = ReturnType<HandlebarsRuntime["create"]>;
228
+
229
+ const require = createRequire(import.meta.url);
230
+ const COMPILED_HANDLEBARS_PATH = "/$bunfs/root/node_modules/handlebars/lib/index.js";
231
+ let handlebars: HandlebarsInstance | undefined;
232
+
233
+ function getHandlebars(): HandlebarsInstance {
234
+ if (handlebars) return handlebars;
235
+ const Handlebars = asHandlebarsRuntime(
236
+ require(process.env.PI_COMPILED === "true" ? COMPILED_HANDLEBARS_PATH : "handlebars"),
237
+ );
238
+ handlebars = Handlebars.create();
239
+ registerBuiltinHelpers(handlebars);
240
+ return handlebars;
241
+ }
227
242
 
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
- });
243
+ function asHandlebarsRuntime(value: unknown): HandlebarsRuntime {
244
+ if (isHandlebarsRuntime(value)) return value;
245
+ if (isObjectRecord(value) && isHandlebarsRuntime(value.default)) return value.default;
246
+ throw new Error("Loaded handlebars module does not expose the expected runtime API.");
247
+ }
236
248
 
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 {
249
+ function isHandlebarsRuntime(value: unknown): value is HandlebarsRuntime {
250
+ return isObjectRecord(value) && typeof value.create === "function";
251
+ }
252
+
253
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
254
+ return typeof value === "object" && value !== null;
255
+ }
256
+
257
+ function registerBuiltinHelpers(handlebars: HandlebarsInstance): void {
258
+ handlebars.registerHelper("arg", function (this: TemplateContext, index: number | string): string {
259
+ const args = this.args ?? [];
260
+ const parsedIndex = typeof index === "number" ? index : Number.parseInt(index, 10);
261
+ if (!Number.isFinite(parsedIndex)) return "";
262
+ const zeroBased = parsedIndex - 1;
263
+ if (zeroBased < 0) return "";
264
+ return args[zeroBased] ?? "";
265
+ });
266
+
267
+ /**
268
+ * {{#list items prefix="- " suffix="" join="\n"}}{{this}}{{/list}}
269
+ * Renders an array with customizable prefix, suffix, and join separator.
270
+ * Note: Use \n in join for newlines (will be unescaped automatically).
271
+ */
272
+ handlebars.registerHelper("list", function (this: unknown, context: unknown[], options: HelperOptions): string {
245
273
  if (!Array.isArray(context) || context.length === 0) return "";
246
274
  const prefix = (options.hash.prefix as string) ?? "";
247
275
  const suffix = (options.hash.suffix as string) ?? "";
248
276
  const rawSeparator = (options.hash.join as string) ?? "\n";
249
277
  const separator = rawSeparator.replace(/\\n/g, "\n").replace(/\\t/g, "\t");
250
278
  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
- );
279
+ });
278
280
 
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
- );
281
+ /**
282
+ * {{join array ", "}}
283
+ * Joins an array with a separator (default: ", ").
284
+ */
285
+ handlebars.registerHelper("join", (context: unknown[], separator?: unknown): string => {
286
+ if (!Array.isArray(context)) return "";
287
+ const sep = typeof separator === "string" ? separator : ", ";
288
+ return context.join(sep);
289
+ });
301
290
 
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
- });
291
+ /**
292
+ * {{default value "fallback"}}
293
+ * Returns the value if truthy, otherwise returns the fallback.
294
+ */
295
+ handlebars.registerHelper("default", (value: unknown, defaultValue: unknown): unknown => value || defaultValue);
296
+
297
+ /**
298
+ * {{pluralize count "item" "items"}}
299
+ * Returns "1 item" or "5 items" based on count.
300
+ */
301
+ handlebars.registerHelper(
302
+ "pluralize",
303
+ (count: number, singular: string, plural: string): string => `${count} ${count === 1 ? singular : plural}`,
304
+ );
305
+
306
+ /**
307
+ * {{#when value "==" compare}}...{{else}}...{{/when}}
308
+ * Conditional block with comparison operators: ==, ===, !=, !==, >, <, >=, <=
309
+ */
310
+ handlebars.registerHelper(
311
+ "when",
312
+ function (this: unknown, lhs: unknown, operator: string, rhs: unknown, options: HelperOptions): string {
313
+ const ops: Record<string, (a: unknown, b: unknown) => boolean> = {
314
+ "==": (a, b) => a === b,
315
+ "===": (a, b) => a === b,
316
+ "!=": (a, b) => a !== b,
317
+ "!==": (a, b) => a !== b,
318
+ ">": (a, b) => (a as number) > (b as number),
319
+ "<": (a, b) => (a as number) < (b as number),
320
+ ">=": (a, b) => (a as number) >= (b as number),
321
+ "<=": (a, b) => (a as number) <= (b as number),
322
+ };
323
+ const fn = ops[operator];
324
+ if (!fn) return options.inverse(this);
325
+ return fn(lhs, rhs) ? options.fn(this) : options.inverse(this);
326
+ },
327
+ );
328
+
329
+ /**
330
+ * {{#ifAny a b c}}...{{else}}...{{/ifAny}}
331
+ * True if any argument is truthy.
332
+ */
333
+ handlebars.registerHelper("ifAny", function (this: unknown, ...args: unknown[]): string {
334
+ const options = args.pop() as HelperOptions;
335
+ return args.some(Boolean) ? options.fn(this) : options.inverse(this);
336
+ });
310
337
 
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
- });
338
+ /**
339
+ * {{#ifAll a b c}}...{{else}}...{{/ifAll}}
340
+ * True if all arguments are truthy.
341
+ */
342
+ handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
343
+ const options = args.pop() as HelperOptions;
344
+ return args.every(Boolean) ? options.fn(this) : options.inverse(this);
345
+ });
319
346
 
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 {
347
+ /**
348
+ * {{#table rows headers="Col1|Col2"}}{{col1}}|{{col2}}{{/table}}
349
+ * Generates a markdown table from an array of objects.
350
+ */
351
+ handlebars.registerHelper("table", function (this: unknown, context: unknown[], options: HelperOptions): string {
327
352
  if (!Array.isArray(context) || context.length === 0) return "";
328
353
  const headersStr = options.hash.headers as string | undefined;
329
354
  const headers = headersStr?.split("|") ?? [];
@@ -331,108 +356,108 @@ handlebars.registerHelper(
331
356
  const headerRow = headers.length > 0 ? `| ${headers.join(" | ")} |\n| ${separator} |\n` : "";
332
357
  const rows = context.map(item => `| ${options.fn(item).trim()} |`).join("\n");
333
358
  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
- });
359
+ });
356
360
 
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
364
- });
361
+ /**
362
+ * {{#codeblock lang="diff"}}...{{/codeblock}}
363
+ * Wraps content in a fenced code block.
364
+ */
365
+ handlebars.registerHelper("codeblock", function (this: unknown, options: HelperOptions): string {
366
+ const lang = (options.hash.lang as string) ?? "";
367
+ const content = options.fn(this).trim();
368
+ return `\`\`\`${lang}\n${content}\n\`\`\``;
369
+ });
365
370
 
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
- });
371
+ /**
372
+ * {{#xml "tag"}}content{{/xml}}
373
+ * Wraps content in XML-style tags. Returns empty string if content is empty.
374
+ */
375
+ handlebars.registerHelper("xml", function (this: unknown, tag: string, options: HelperOptions): string {
376
+ const content = options.fn(this).trim();
377
+ if (!content) return "";
378
+ return `<${tag}>\n${content}\n</${tag}>`;
379
+ });
375
380
 
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
+ * {{escapeXml value}}
383
+ * Escapes XML special characters: & < > "
384
+ */
385
+ handlebars.registerHelper("escapeXml", (value: unknown): string => {
386
+ if (value == null) return "";
387
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
388
+ });
381
389
 
382
- /**
383
- * {{sub a b}}
384
- * Subtracts b from a.
385
- */
386
- handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
390
+ /**
391
+ * {{len array}}
392
+ * Returns the length of an array or string.
393
+ */
394
+ handlebars.registerHelper("len", (value: unknown): number => {
395
+ if (Array.isArray(value)) return value.length;
396
+ if (typeof value === "string") return value.length;
397
+ return 0;
398
+ });
387
399
 
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;
400
+ /**
401
+ * {{add a b}}
402
+ * Adds two numbers.
403
+ */
404
+ handlebars.registerHelper("add", (a: number, b: number): number => (a ?? 0) + (b ?? 0));
405
+
406
+ /**
407
+ * {{sub a b}}
408
+ * Subtracts b from a.
409
+ */
410
+ handlebars.registerHelper("sub", (a: number, b: number): number => (a ?? 0) - (b ?? 0));
411
+
412
+ /**
413
+ * {{#has collection item}}...{{else}}...{{/has}}
414
+ * Checks if an array includes an item or if a Set/Map has a key.
415
+ */
416
+ handlebars.registerHelper(
417
+ "has",
418
+ function (this: unknown, collection: unknown, item: unknown, options: HelperOptions): string {
419
+ let found = false;
420
+ if (Array.isArray(collection)) {
421
+ found = collection.includes(item);
422
+ } else if (collection instanceof Set) {
423
+ found = collection.has(item);
424
+ } else if (collection instanceof Map) {
425
+ found = collection.has(item);
426
+ } else if (collection && typeof collection === "object") {
427
+ if (typeof item === "string" || typeof item === "number" || typeof item === "symbol") {
428
+ found = item in collection;
429
+ }
405
430
  }
406
- }
407
- return found ? options.fn(this) : options.inverse(this);
408
- },
409
- );
431
+ return found ? options.fn(this) : options.inverse(this);
432
+ },
433
+ );
434
+
435
+ /**
436
+ * {{includes array item}}
437
+ * Returns true if array includes item. For use in other helpers.
438
+ */
439
+ handlebars.registerHelper("includes", (collection: unknown, item: unknown): boolean => {
440
+ if (Array.isArray(collection)) return collection.includes(item);
441
+ if (collection instanceof Set) return collection.has(item);
442
+ if (collection instanceof Map) return collection.has(item);
443
+ return false;
444
+ });
410
445
 
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
- });
446
+ /**
447
+ * {{not value}}
448
+ * Returns logical NOT of value. For use in subexpressions.
449
+ */
450
+ handlebars.registerHelper("not", (value: unknown): boolean => !value);
421
451
 
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));
452
+ handlebars.registerHelper("jsonStringify", (value: unknown): string => JSON.stringify(value));
453
+ }
429
454
 
430
455
  export function registerHelper(name: string, fn: HelperDelegate): void {
431
- handlebars.registerHelper(name, fn);
456
+ getHandlebars().registerHelper(name, fn);
432
457
  }
433
458
 
434
459
  export function registerPartial(name: string, fn: Template): void {
435
- handlebars.registerPartial(name, fn);
460
+ getHandlebars().registerPartial(name, fn);
436
461
  }
437
462
 
438
463
  /**
@@ -457,7 +482,7 @@ export function compile(template: string): (context: TemplateContext) => string
457
482
  const disambiguated = disambiguateClosingBraces(template);
458
483
  const cached = compiledTemplateCache.get(disambiguated);
459
484
  if (cached) return cached;
460
- const compiled = handlebars.compile(disambiguated, { noEscape: true, strict: false }) as (
485
+ const compiled = getHandlebars().compile(disambiguated, { noEscape: true, strict: false }) as (
461
486
  context: TemplateContext,
462
487
  ) => string;
463
488
  compiledTemplateCache.set(disambiguated, compiled);