-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtemplate-engine.ts
More file actions
283 lines (262 loc) · 10.8 KB
/
Copy pathtemplate-engine.ts
File metadata and controls
283 lines (262 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
/**
* Template dialect engine — strict Mustache subset with a formatter whitelist.
*
* Holes are `{{ path }}` or `{{ path | formatter[:'arg'] }}` (ADR-0032 §3).
* Holes are restricted to a **field/variable path** plus a **whitelisted
* formatter** — never arbitrary CEL logic — so the grammar stays small (low
* author/agent error surface), GUI-pickable (path + formatter dropdown), and
* display strings stay declarative. Real logic belongs in `Predicate`/`Expr`
* (CEL) fields, where it is validated and visible.
*
* The variable scope is the same as CEL (`record`, `previous`, `input`,
* `os.user/org/env`, plus `extra`), so authors move fluidly between a CEL
* formula and a template body without re-learning a namespace.
*
* Value→string semantics are explicit and defined per formatter (numbers,
* dates, money, percent, null), instead of implicit coercion.
*/
import type { Expression } from '@objectstack/spec';
import { buildScope } from './stdlib';
import type { DialectEngine, EvalContext, EvalResult } from './types';
/**
* A hole: capture the full inner content (no `}` allowed inside). Uses a single
* greedy `[^}]*` (not `\s*…\s*` around a lazy group) so the pattern is linear —
* `\s` is a subset of `[^}]`, and wrapping a lazy group in `\s*` creates an
* ambiguous (polynomial-ReDoS) matcher. Surrounding whitespace is stripped in
* `parseHole` instead.
*/
const HOLE_RE = /\{\{([^}]*)\}\}/g;
// ───────────────────────── formatter whitelist (ADR-0032 §3) ──────────────
type Formatter = (
value: unknown,
arg: string | undefined,
locale: string,
timeZone?: string,
) => string;
function asNumber(v: unknown): number | undefined {
if (typeof v === 'number') return v;
if (typeof v === 'bigint') return Number(v);
if (typeof v === 'string' && v.trim() !== '' && !Number.isNaN(Number(v))) return Number(v);
return undefined;
}
function asDate(v: unknown): Date | undefined {
if (v instanceof Date) return v;
if (typeof v === 'number') return new Date(v);
if (typeof v === 'string') {
const d = new Date(v);
if (!Number.isNaN(d.getTime())) return d;
}
return undefined;
}
const FORMATTERS: Record<string, Formatter> = {
upper: (v) => baseString(v).toUpperCase(),
lower: (v) => baseString(v).toLowerCase(),
trim: (v) => baseString(v).trim(),
// number | number:2 → grouped, optional fixed decimals
number: (v, arg, locale) => {
const n = asNumber(v);
if (n === undefined) return baseString(v);
const digits = arg !== undefined ? Number(arg) : undefined;
return new Intl.NumberFormat(locale, digits !== undefined && !Number.isNaN(digits)
? { minimumFractionDigits: digits, maximumFractionDigits: digits } : {}).format(n);
},
// currency | currency:EUR → defaults to USD
currency: (v, arg, locale) => {
const n = asNumber(v);
if (n === undefined) return baseString(v);
const code = (arg && arg.trim()) || 'USD';
try {
return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format(n);
} catch {
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'USD' }).format(n);
}
},
// percent | percent:1 → 0.42 → "42%" (value is a 0..1 ratio)
percent: (v, arg, locale) => {
const n = asNumber(v);
if (n === undefined) return baseString(v);
const digits = arg !== undefined ? Number(arg) : 0;
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: Number.isNaN(digits) ? 0 : digits,
maximumFractionDigits: Number.isNaN(digits) ? 0 : digits,
}).format(n);
},
// date | date:long | date:iso → date-only. Intentionally tz-naive
// (ADR-0053): a `Field.date` is a calendar day with no zone, so rendering
// never applies a reference timezone — that would shift the day.
date: (v, arg, locale) => {
const d = asDate(v);
if (!d) return baseString(v);
if (arg === 'iso') return d.toISOString().slice(0, 10);
const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
return new Intl.DateTimeFormat(locale, { dateStyle: style as 'short' | 'medium' | 'long' }).format(d);
},
// datetime | datetime:long | datetime:iso. A `datetime` is a UTC instant;
// when a reference `timeZone` is supplied (ADR-0053 Phase 2) the wall-clock
// styles render in that zone. `iso` stays UTC (machine-readable, unambiguous).
datetime: (v, arg, locale, timeZone) => {
const d = asDate(v);
if (!d) return baseString(v);
if (arg === 'iso') return d.toISOString();
const style = arg === 'long' ? 'long' : arg === 'medium' ? 'medium' : 'short';
return new Intl.DateTimeFormat(locale, {
dateStyle: style as 'short' | 'medium' | 'long',
timeStyle: style as 'short' | 'medium' | 'long',
...(timeZone ? { timeZone } : {}),
}).format(d);
},
// truncate:80 → cut with an ellipsis
truncate: (v, arg) => {
const s = baseString(v);
const len = arg !== undefined ? Number(arg) : 80;
if (Number.isNaN(len) || s.length <= len) return s;
return s.slice(0, Math.max(0, len - 1)) + '…';
},
// default:'N/A' → fallback when the value is null/undefined/empty
default: (v, arg) => {
const s = baseString(v);
return s === '' ? (arg ?? '') : s;
},
json: (v) => {
try { return JSON.stringify(v); } catch { return String(v); }
},
};
/** Public list of whitelisted template formatters (for introspection/docs). */
export const TEMPLATE_FORMATTERS: string[] = Object.keys(FORMATTERS);
/**
* Apply a whitelisted formatter to a value, the single source of truth for
* value→string semantics across dialects. Returns `undefined` for an unknown
* formatter name so callers can decide how to handle it (the template engine
* rejects at compile time; other consumers may pass the raw value through).
*
* Exported so renderers that don't run the full CEL template engine — notably
* the email pipeline (ADR-0053 Phase 2 slice 4) — format dates, money, etc.
* identically to in-app templates, including reference-timezone `datetime`.
*/
export function formatValue(
name: string,
value: unknown,
arg: string | undefined,
opts: { locale?: string; timeZone?: string } = {},
): string | undefined {
const fmt = FORMATTERS[name];
if (!fmt) return undefined;
return fmt(value, arg, opts.locale ?? 'en-US', opts.timeZone);
}
function baseString(value: unknown): string {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString();
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (typeof value === 'bigint') return value.toString();
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function resolvePath(scope: Record<string, unknown>, path: string): unknown {
const normalized = path.replace(/\[(\w+)\]/g, '.$1');
const segments = normalized.split('.').filter(Boolean);
let cursor: unknown = scope;
for (const seg of segments) {
if (cursor == null || typeof cursor !== 'object') return undefined;
cursor = (cursor as Record<string, unknown>)[seg];
}
return cursor;
}
interface ParsedHole {
path: string;
filter?: { name: string; arg?: string };
}
const PATH_ONLY_RE = /^[\w.[\]]+$/;
/**
* Parse a hole's inner content into a path + optional single formatter.
* Returns null when the inner content is not a valid path[+formatter] form
* (e.g. arbitrary CEL was written into a hole — rejected, ADR-0032 §3).
*/
function parseHole(inner: string): ParsedHole | null {
const pipe = inner.indexOf('|');
if (pipe === -1) {
const path = inner.trim();
return PATH_ONLY_RE.test(path) ? { path } : null;
}
const path = inner.slice(0, pipe).trim();
if (!PATH_ONLY_RE.test(path)) return null;
const filterPart = inner.slice(pipe + 1).trim();
// `name` or `name:arg` or `name:'arg'`
const colon = filterPart.indexOf(':');
let name = filterPart;
let arg: string | undefined;
if (colon !== -1) {
name = filterPart.slice(0, colon).trim();
arg = filterPart.slice(colon + 1).trim().replace(/^['"]|['"]$/g, '');
}
if (!FORMATTERS[name]) return null;
return { path, filter: { name, arg } };
}
function compileTemplate(source: string): EvalResult<ParsedHole[]> {
const open = (source.match(/\{\{/g) ?? []).length;
const close = (source.match(/\}\}/g) ?? []).length;
if (open !== close) {
return { ok: false, error: { kind: 'parse', message: 'template has unbalanced {{ }} delimiters' } };
}
const holes: ParsedHole[] = [];
let m: RegExpExecArray | null;
HOLE_RE.lastIndex = 0;
while ((m = HOLE_RE.exec(source)) !== null) {
const parsed = parseHole(m[1]);
if (!parsed) {
return {
ok: false,
error: {
kind: 'parse',
message:
`invalid template hole \`{{ ${m[1]} }}\` — holes are a field path with an optional ` +
`formatter (\`{{ record.amount | currency }}\`), not arbitrary logic. ` +
`Move logic into a CEL field. Known formatters: ${TEMPLATE_FORMATTERS.join(', ')}.`,
},
};
}
holes.push(parsed);
}
return { ok: true, value: holes };
}
export const templateEngine: DialectEngine = {
dialect: 'template',
compile(source: string): EvalResult<unknown> {
return compileTemplate(source);
},
evaluate<T = unknown>(expr: Expression, ctx: EvalContext): EvalResult<T> {
if (expr.dialect !== 'template') {
return {
ok: false,
error: { kind: 'dialect', message: `templateEngine cannot evaluate dialect '${expr.dialect}'` },
};
}
if (typeof expr.source !== 'string') {
return { ok: false, error: { kind: 'parse', message: 'template Expression.source required' } };
}
const check = compileTemplate(expr.source);
if (!check.ok) return check as EvalResult<T>;
const scope = buildScope(ctx);
const locale =
(ctx.extra && typeof ctx.extra.locale === 'string' && ctx.extra.locale) ||
(typeof (ctx as { locale?: string }).locale === 'string' && (ctx as { locale?: string }).locale) ||
'en-US';
// Reference timezone for `datetime` rendering (ADR-0053 Phase 2). Unset →
// Intl uses the runtime zone, matching pre-Phase-2 behavior.
const timeZone = typeof ctx.timezone === 'string' ? ctx.timezone : undefined;
const out = expr.source.replace(HOLE_RE, (_match, inner) => {
const parsed = parseHole(String(inner));
if (!parsed) return _match; // compile already validated; defensive
const value = resolvePath(scope, parsed.path);
if (parsed.filter) {
return FORMATTERS[parsed.filter.name](value, parsed.filter.arg, locale as string, timeZone);
}
return baseString(value);
});
return { ok: true, value: out as unknown as T };
},
};