@gajae-code/utils 0.9.2 → 0.9.3
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/package.json +2 -2
- package/src/logger.ts +142 -54
- 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.
|
|
4
|
+
"version": "0.9.3",
|
|
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.
|
|
34
|
+
"@gajae-code/natives": "0.9.3",
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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):
|
|
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():
|
|
59
|
-
return new winston.transports.Console({ format:
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
78
|
-
if (
|
|
79
|
-
winstonLogger
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
handlebars.registerHelper(
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
handlebars.registerHelper("
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
handlebars.registerHelper("ifAll", function (this: unknown, ...args: unknown[]): string {
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
322
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
handlebars.registerHelper("
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
handlebars.registerHelper("
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
handlebars.registerHelper("
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
388
|
+
});
|
|
381
389
|
|
|
382
|
-
/**
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
handlebars.registerHelper("
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
handlebars.registerHelper(
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
handlebars.registerHelper("
|
|
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
|
-
|
|
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
|
-
|
|
456
|
+
getHandlebars().registerHelper(name, fn);
|
|
432
457
|
}
|
|
433
458
|
|
|
434
459
|
export function registerPartial(name: string, fn: Template): void {
|
|
435
|
-
|
|
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 =
|
|
485
|
+
const compiled = getHandlebars().compile(disambiguated, { noEscape: true, strict: false }) as (
|
|
461
486
|
context: TemplateContext,
|
|
462
487
|
) => string;
|
|
463
488
|
compiledTemplateCache.set(disambiguated, compiled);
|