|
25 | 25 |
|
26 | 26 | import { formatValue } from '@objectstack/formula'; |
27 | 27 |
|
28 | | -// 1=open(`{{`|`{{{`) 2=path 3=formatter(opt) 4=arg(opt) 5=close(`}}`|`}}}`). |
29 | | -// `[^'}]*?` is brace/quote-free, so it cannot run past the closing braces — |
30 | | -// the matcher stays linear (no ReDoS). |
31 | | -const PLACEHOLDER = |
32 | | - /(\{\{\{?)\s*([\w.]+)(?:\s*\|\s*(\w+)(?:\s*:\s*'?([^'}]*?)'?)?)?\s*(\}\}\}?)/g; |
| 28 | +// Match a hole: open braces, a BRACE-FREE inner body, close braces. Capturing |
| 29 | +// the inner with `[^{}]*` (a single star over a negated class — no nested |
| 30 | +// quantifier, no overlapping `\s*` groups) keeps the matcher strictly linear: |
| 31 | +// it can never backtrack into a polynomial-ReDoS shape. The inner body is then |
| 32 | +// parsed with plain string ops (`parseHole`) instead of a complex pattern. |
| 33 | +// 1=open(`{{`|`{{{`) 2=inner 3=close(`}}`|`}}}`). |
| 34 | +const PLACEHOLDER = /(\{\{\{?)([^{}]*)(\}\}\}?)/g; |
33 | 35 |
|
34 | 36 | /** Locale + reference timezone for hole formatters (ADR-0053 Phase 2). */ |
35 | 37 | export interface RenderOptions { |
36 | 38 | locale?: string; |
37 | 39 | timeZone?: string; |
38 | 40 | } |
39 | 41 |
|
| 42 | +// A hole body is a dotted path with an optional `| formatter[:arg]`. Validated |
| 43 | +// against small, already-extracted strings (bounded input → no ReDoS). |
| 44 | +const PATH_RE = /^[\w.]+$/; |
| 45 | +const FILTER_RE = /^(\w+)(?::\s*'?([^']*?)'?)?$/; |
| 46 | + |
| 47 | +interface ParsedHole { |
| 48 | + path: string; |
| 49 | + formatter?: string; |
| 50 | + arg?: string; |
| 51 | +} |
| 52 | + |
| 53 | +/** Parse a hole's inner body into a path + optional formatter. Null if malformed. */ |
| 54 | +function parseHole(inner: string): ParsedHole | null { |
| 55 | + const pipe = inner.indexOf('|'); |
| 56 | + if (pipe === -1) { |
| 57 | + const path = inner.trim(); |
| 58 | + return PATH_RE.test(path) ? { path } : null; |
| 59 | + } |
| 60 | + const path = inner.slice(0, pipe).trim(); |
| 61 | + if (!PATH_RE.test(path)) return null; |
| 62 | + const m = FILTER_RE.exec(inner.slice(pipe + 1).trim()); |
| 63 | + if (!m) return null; |
| 64 | + return { path, formatter: m[1], arg: m[2] }; |
| 65 | +} |
| 66 | + |
40 | 67 | function lookup(data: Record<string, any>, path: string): unknown { |
41 | 68 | if (!path) return undefined; |
42 | 69 | const parts = path.split('.'); |
@@ -70,14 +97,19 @@ export function renderTemplate( |
70 | 97 | if (!template) return ''; |
71 | 98 | return template.replace( |
72 | 99 | PLACEHOLDER, |
73 | | - (_match, open: string, path: string, fname: string | undefined, farg: string | undefined, close: string) => { |
| 100 | + (match: string, open: string, inner: string, close: string) => { |
| 101 | + const parsed = parseHole(inner); |
| 102 | + if (!parsed) return match; // not a path[+formatter] hole — leave verbatim |
74 | 103 | const isUnescaped = open === '{{{' && close === '}}}'; |
75 | | - const raw = lookup(data, path); |
| 104 | + const raw = lookup(data, parsed.path); |
76 | 105 | let str: string; |
77 | | - if (fname) { |
| 106 | + if (parsed.formatter) { |
78 | 107 | // Formatted holes render '' for a missing value (the formula formatters |
79 | 108 | // treat null as empty), so they never emit "undefined". |
80 | | - const formatted = formatValue(fname, raw, farg, { locale: opts.locale, timeZone: opts.timeZone }); |
| 109 | + const formatted = formatValue(parsed.formatter, raw, parsed.arg, { |
| 110 | + locale: opts.locale, |
| 111 | + timeZone: opts.timeZone, |
| 112 | + }); |
81 | 113 | str = formatted !== undefined |
82 | 114 | ? formatted |
83 | 115 | : raw == null ? '' : (typeof raw === 'string' ? raw : String(raw)); // unknown formatter → raw |
|
0 commit comments