@voyantjs/templating 0.96.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/README.md +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/template-renderer.d.ts +23 -0
- package/dist/template-renderer.d.ts.map +1 -0
- package/dist/template-renderer.js +282 -0
- package/package.json +51 -0
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @voyantjs/templating
|
|
2
|
+
|
|
3
|
+
Liquid/Mustache template rendering and syntax validation, extracted from
|
|
4
|
+
`@voyantjs/utils` into a lean package (`liquidjs` only — no Drizzle, no
|
|
5
|
+
`@voyantjs/db`, no pdf-lib). This lets contract packages that need template
|
|
6
|
+
validation (e.g. `@voyantjs/legal-contracts`, whose contract bodies are Liquid
|
|
7
|
+
templates) depend on it without pulling the data layer.
|
|
8
|
+
|
|
9
|
+
`@voyantjs/utils/template-renderer` re-exports everything here, so existing
|
|
10
|
+
import paths are unchanged.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,wBAAwB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./template-renderer.js";
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type StructuredTemplateBodyFormat = "html" | "markdown" | "lexical_json";
|
|
2
|
+
/**
|
|
3
|
+
* Optional render hooks. `missingValuePlaceholder` substitutes a
|
|
4
|
+
* literal (typically `"-"`) for any output tag whose resolved value is
|
|
5
|
+
* null / undefined / empty-string. Numbers (including 0) and
|
|
6
|
+
* booleans are stringified as-is — they aren't "missing".
|
|
7
|
+
*/
|
|
8
|
+
export interface RenderTemplateOptions {
|
|
9
|
+
missingValuePlaceholder?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface TemplateSyntaxIssue {
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function renderMustacheTemplate(body: string, variables: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
15
|
+
/**
|
|
16
|
+
* Parse-check Liquid syntax without rendering. Rich-text editors can split
|
|
17
|
+
* Liquid delimiters across HTML blocks; validating the raw body catches those
|
|
18
|
+
* templates before they are persisted or previewed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function validateStructuredTemplateSyntax(body: string, bodyFormat: StructuredTemplateBodyFormat): TemplateSyntaxIssue[];
|
|
21
|
+
export declare function renderStringTemplate(body: string, variables: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
22
|
+
export declare function renderStructuredTemplate(body: string, bodyFormat: StructuredTemplateBodyFormat, variables: Record<string, unknown>, options?: RenderTemplateOptions): string;
|
|
23
|
+
//# sourceMappingURL=template-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"template-renderer.d.ts","sourceRoot":"","sources":["../src/template-renderer.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,4BAA4B,GAAG,MAAM,GAAG,UAAU,GAAG,cAAc,CAAA;AA+I/E;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,uBAAuB,CAAC,EAAE,MAAM,CAAA;CACjC;AAED,MAAM,WAAW,mBAAmB;IAClC,OAAO,EAAE,MAAM,CAAA;CAChB;AAiCD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,MAAM,CAMR;AA4BD;;;;GAIG;AACH,wBAAgB,gCAAgC,CAC9C,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,4BAA4B,GACvC,mBAAmB,EAAE,CA+BvB;AAED,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,MAAM,CASR;AAiBD,wBAAgB,wBAAwB,CACtC,IAAI,EAAE,MAAM,EACZ,UAAU,EAAE,4BAA4B,EACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAClC,OAAO,CAAC,EAAE,qBAAqB,GAC9B,MAAM,CAgCR"}
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { Liquid } from "liquidjs";
|
|
2
|
+
const liquid = new Liquid({
|
|
3
|
+
strictFilters: false,
|
|
4
|
+
strictVariables: false,
|
|
5
|
+
jsTruthy: true,
|
|
6
|
+
});
|
|
7
|
+
liquid.registerFilter("json", (value) => JSON.stringify(value ?? null));
|
|
8
|
+
function parseNumber(value) {
|
|
9
|
+
if (typeof value === "number")
|
|
10
|
+
return Number.isFinite(value) ? value : null;
|
|
11
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
12
|
+
const n = Number.parseFloat(value);
|
|
13
|
+
return Number.isFinite(n) ? n : null;
|
|
14
|
+
}
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* `currency` — Intl.NumberFormat currency style. Expects a decimal amount
|
|
19
|
+
* (`123.45`). Example: `{{ amount | currency: "EUR", "en-US" }}` →
|
|
20
|
+
* `"€123.45"`. Falls back to `String(value)` when the value isn't a number.
|
|
21
|
+
*/
|
|
22
|
+
liquid.registerFilter("currency", (value, currency = "EUR", locale = "en-US") => {
|
|
23
|
+
const num = parseNumber(value);
|
|
24
|
+
if (num === null)
|
|
25
|
+
return String(value ?? "");
|
|
26
|
+
return new Intl.NumberFormat(String(locale), {
|
|
27
|
+
style: "currency",
|
|
28
|
+
currency: String(currency || "EUR"),
|
|
29
|
+
}).format(num);
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* `cents` — Shortcut for formatting integer cents (`12345` → `"€123.45"`).
|
|
33
|
+
* Saves every template from `{{ (amountCents | divided_by: 100) | currency: ... }}`.
|
|
34
|
+
*/
|
|
35
|
+
liquid.registerFilter("cents", (value, currency = "EUR", locale = "en-US") => {
|
|
36
|
+
const num = parseNumber(value);
|
|
37
|
+
if (num === null)
|
|
38
|
+
return String(value ?? "");
|
|
39
|
+
return new Intl.NumberFormat(String(locale), {
|
|
40
|
+
style: "currency",
|
|
41
|
+
currency: String(currency || "EUR"),
|
|
42
|
+
}).format(num / 100);
|
|
43
|
+
});
|
|
44
|
+
/**
|
|
45
|
+
* `format_date` — ISO/string/Date → locale-formatted date. Second arg chooses
|
|
46
|
+
* the preset: `"short"` (`01/15/2026`), `"medium"` (default, `Jan 15, 2026`),
|
|
47
|
+
* `"long"` (`January 15, 2026`), `"iso"` (`2026-01-15`), or `"time"`
|
|
48
|
+
* (delegates to `format_time` — `08:30`). Pair with a locale for
|
|
49
|
+
* Romanian/etc.: `{{ startsAt | format_date: "medium", "ro-RO" }}`.
|
|
50
|
+
*/
|
|
51
|
+
liquid.registerFilter("format_date", (value, preset = "medium", locale = "en-US") => {
|
|
52
|
+
if (value === null || value === undefined || value === "")
|
|
53
|
+
return "";
|
|
54
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
55
|
+
if (Number.isNaN(date.getTime()))
|
|
56
|
+
return String(value);
|
|
57
|
+
const p = String(preset ?? "medium").toLowerCase();
|
|
58
|
+
if (p === "iso")
|
|
59
|
+
return date.toISOString().slice(0, 10);
|
|
60
|
+
if (p === "time") {
|
|
61
|
+
// Delegate to `format_time`'s default short shape so authors
|
|
62
|
+
// can pipe a single `format_date: "time"` without remembering
|
|
63
|
+
// to switch filters.
|
|
64
|
+
return date.toLocaleTimeString(String(locale), { hour: "2-digit", minute: "2-digit" });
|
|
65
|
+
}
|
|
66
|
+
const options = p === "short"
|
|
67
|
+
? { year: "numeric", month: "2-digit", day: "2-digit" }
|
|
68
|
+
: p === "long"
|
|
69
|
+
? { year: "numeric", month: "long", day: "numeric" }
|
|
70
|
+
: { year: "numeric", month: "short", day: "numeric" };
|
|
71
|
+
return date.toLocaleDateString(String(locale), options);
|
|
72
|
+
});
|
|
73
|
+
/**
|
|
74
|
+
* `format_time` — ISO/string/Date → locale-formatted time-of-day.
|
|
75
|
+
* Second arg picks the preset: `"short"` (default, `08:30`),
|
|
76
|
+
* `"medium"` (`08:30:42`), `"iso"` (`08:30:42` — same as medium but
|
|
77
|
+
* always 24-hour). Locale defaults to `en-US`; pass `"ro-RO"` etc.
|
|
78
|
+
* for locale-aware formatting (12 vs. 24 hour). Falls back to the
|
|
79
|
+
* raw value when not parseable.
|
|
80
|
+
*
|
|
81
|
+
* Examples:
|
|
82
|
+
* `{{ contract.signedAt | format_time }}` → `"08:30"`
|
|
83
|
+
* `{{ contract.signedAt | format_time: "medium" }}` → `"08:30:42"`
|
|
84
|
+
* `{{ contract.signedAt | format_time: "short", "ro-RO" }}` → `"08:30"`
|
|
85
|
+
*/
|
|
86
|
+
liquid.registerFilter("format_time", (value, preset = "short", locale = "en-US") => {
|
|
87
|
+
if (value === null || value === undefined || value === "")
|
|
88
|
+
return "";
|
|
89
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
90
|
+
if (Number.isNaN(date.getTime()))
|
|
91
|
+
return String(value);
|
|
92
|
+
const p = String(preset ?? "short").toLowerCase();
|
|
93
|
+
if (p === "iso") {
|
|
94
|
+
// 24-hour HH:MM:SS regardless of locale, useful for audit-
|
|
95
|
+
// trail copy where consistency beats locale courtesy.
|
|
96
|
+
return date.toISOString().slice(11, 19);
|
|
97
|
+
}
|
|
98
|
+
const options = p === "medium"
|
|
99
|
+
? { hour: "2-digit", minute: "2-digit", second: "2-digit" }
|
|
100
|
+
: { hour: "2-digit", minute: "2-digit" };
|
|
101
|
+
return date.toLocaleTimeString(String(locale), options);
|
|
102
|
+
});
|
|
103
|
+
function resolvePath(obj, path) {
|
|
104
|
+
if (obj === null || obj === undefined)
|
|
105
|
+
return undefined;
|
|
106
|
+
const segments = [];
|
|
107
|
+
const parts = path.split(".");
|
|
108
|
+
for (const part of parts) {
|
|
109
|
+
if (!part)
|
|
110
|
+
continue;
|
|
111
|
+
const indexMatches = [...part.matchAll(/([^[\]]+)|\[(\d+)\]/g)];
|
|
112
|
+
for (const match of indexMatches) {
|
|
113
|
+
if (match[1] !== undefined)
|
|
114
|
+
segments.push(match[1]);
|
|
115
|
+
else if (match[2] !== undefined)
|
|
116
|
+
segments.push(Number.parseInt(match[2], 10));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
let current = obj;
|
|
120
|
+
for (const seg of segments) {
|
|
121
|
+
if (current === null || current === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
if (typeof seg === "number") {
|
|
124
|
+
if (!Array.isArray(current))
|
|
125
|
+
return undefined;
|
|
126
|
+
current = current[seg];
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
if (typeof current !== "object")
|
|
130
|
+
return undefined;
|
|
131
|
+
current = current[seg];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return current;
|
|
135
|
+
}
|
|
136
|
+
function stringifyValue(value, placeholder) {
|
|
137
|
+
if (value === null || value === undefined)
|
|
138
|
+
return placeholder ?? "";
|
|
139
|
+
if (typeof value === "string") {
|
|
140
|
+
return value === "" && placeholder ? placeholder : value;
|
|
141
|
+
}
|
|
142
|
+
if (typeof value === "number" || typeof value === "boolean")
|
|
143
|
+
return String(value);
|
|
144
|
+
return JSON.stringify(value);
|
|
145
|
+
}
|
|
146
|
+
const MUSTACHE_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
147
|
+
const LIQUID_CONTROL_RE = /\{%-?[\s\S]*?-?%\}/;
|
|
148
|
+
const LIQUID_FILTER_RE = /\{\{[\s\S]*\|[\s\S]*\}\}/;
|
|
149
|
+
const LIQUID_OUTPUT_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
|
|
150
|
+
const LIQUID_DELIMITER_RE = /\{\{|\{%/;
|
|
151
|
+
const HAS_DEFAULT_FILTER_RE = /\|\s*default\s*:/i;
|
|
152
|
+
/**
|
|
153
|
+
* Inject `| default: <fallback>` into every Liquid output tag that
|
|
154
|
+
* doesn't already chain a `default:` filter. Applied AFTER the rest of
|
|
155
|
+
* the filter chain so transformations like `cents` / `format_date`
|
|
156
|
+
* still run; they return empty strings on missing input, which the
|
|
157
|
+
* `default` filter then replaces with the fallback. Output tags whose
|
|
158
|
+
* authors already wired their own `default: "..."` are left alone.
|
|
159
|
+
*/
|
|
160
|
+
function injectDefaultFilter(body, fallback) {
|
|
161
|
+
return body.replace(LIQUID_OUTPUT_RE, (full, inner) => {
|
|
162
|
+
if (HAS_DEFAULT_FILTER_RE.test(inner))
|
|
163
|
+
return full;
|
|
164
|
+
return `{{ ${inner.trim()} | default: ${JSON.stringify(fallback)} }}`;
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
export function renderMustacheTemplate(body, variables, options) {
|
|
168
|
+
const placeholder = options?.missingValuePlaceholder;
|
|
169
|
+
return body.replace(MUSTACHE_RE, (_, path) => {
|
|
170
|
+
const resolved = resolvePath(variables, path.trim());
|
|
171
|
+
return stringifyValue(resolved, placeholder);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
function shouldUseLiquid(body) {
|
|
175
|
+
return LIQUID_CONTROL_RE.test(body) || LIQUID_FILTER_RE.test(body);
|
|
176
|
+
}
|
|
177
|
+
function validateStringTemplateSyntax(body) {
|
|
178
|
+
if (!LIQUID_DELIMITER_RE.test(body))
|
|
179
|
+
return [];
|
|
180
|
+
try {
|
|
181
|
+
liquid.parse(body);
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
return [{ message: error instanceof Error ? error.message : String(error) }];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function collectLexicalTextIssues(node, issues) {
|
|
189
|
+
if (typeof node.text === "string") {
|
|
190
|
+
issues.push(...validateStringTemplateSyntax(node.text));
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(node.children)) {
|
|
193
|
+
for (const child of node.children) {
|
|
194
|
+
collectLexicalTextIssues(child, issues);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Parse-check Liquid syntax without rendering. Rich-text editors can split
|
|
200
|
+
* Liquid delimiters across HTML blocks; validating the raw body catches those
|
|
201
|
+
* templates before they are persisted or previewed.
|
|
202
|
+
*/
|
|
203
|
+
export function validateStructuredTemplateSyntax(body, bodyFormat) {
|
|
204
|
+
if (bodyFormat !== "lexical_json") {
|
|
205
|
+
return validateStringTemplateSyntax(body);
|
|
206
|
+
}
|
|
207
|
+
try {
|
|
208
|
+
const parsed = JSON.parse(body);
|
|
209
|
+
const issues = [];
|
|
210
|
+
if (Array.isArray(parsed)) {
|
|
211
|
+
for (const entry of parsed) {
|
|
212
|
+
if (entry && typeof entry === "object") {
|
|
213
|
+
collectLexicalTextIssues(entry, issues);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return issues;
|
|
217
|
+
}
|
|
218
|
+
if (parsed && typeof parsed === "object") {
|
|
219
|
+
const obj = parsed;
|
|
220
|
+
if (obj.root && typeof obj.root === "object") {
|
|
221
|
+
collectLexicalTextIssues(obj.root, issues);
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
collectLexicalTextIssues(obj, issues);
|
|
225
|
+
return issues;
|
|
226
|
+
}
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return validateStringTemplateSyntax(body);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
export function renderStringTemplate(body, variables, options) {
|
|
234
|
+
if (!shouldUseLiquid(body)) {
|
|
235
|
+
return renderMustacheTemplate(body, variables, options);
|
|
236
|
+
}
|
|
237
|
+
const processedBody = options?.missingValuePlaceholder
|
|
238
|
+
? injectDefaultFilter(body, options.missingValuePlaceholder)
|
|
239
|
+
: body;
|
|
240
|
+
return liquid.parseAndRenderSync(processedBody, variables);
|
|
241
|
+
}
|
|
242
|
+
function walkLexical(node, variables, options) {
|
|
243
|
+
const next = { ...node };
|
|
244
|
+
if (typeof next.text === "string") {
|
|
245
|
+
next.text = renderStringTemplate(next.text, variables, options);
|
|
246
|
+
}
|
|
247
|
+
if (Array.isArray(next.children)) {
|
|
248
|
+
next.children = next.children.map((child) => walkLexical(child, variables, options));
|
|
249
|
+
}
|
|
250
|
+
return next;
|
|
251
|
+
}
|
|
252
|
+
export function renderStructuredTemplate(body, bodyFormat, variables, options) {
|
|
253
|
+
if (bodyFormat === "lexical_json") {
|
|
254
|
+
try {
|
|
255
|
+
const parsed = JSON.parse(body);
|
|
256
|
+
if (Array.isArray(parsed)) {
|
|
257
|
+
return JSON.stringify(parsed.map((entry) => {
|
|
258
|
+
if (entry && typeof entry === "object") {
|
|
259
|
+
return walkLexical(entry, variables, options);
|
|
260
|
+
}
|
|
261
|
+
return entry;
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
264
|
+
if (parsed && typeof parsed === "object") {
|
|
265
|
+
const obj = parsed;
|
|
266
|
+
if (obj.root && typeof obj.root === "object") {
|
|
267
|
+
const result = {
|
|
268
|
+
...obj,
|
|
269
|
+
root: walkLexical(obj.root, variables, options),
|
|
270
|
+
};
|
|
271
|
+
return JSON.stringify(result);
|
|
272
|
+
}
|
|
273
|
+
return JSON.stringify(walkLexical(obj, variables, options));
|
|
274
|
+
}
|
|
275
|
+
return body;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return renderStringTemplate(body, variables, options);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return renderStringTemplate(body, variables, options);
|
|
282
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voyantjs/templating",
|
|
3
|
+
"version": "0.96.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./template-renderer": "./src/template-renderer.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"typecheck": "tsc --noEmit",
|
|
12
|
+
"lint": "biome check src/",
|
|
13
|
+
"test": "vitest run --passWithNoTests",
|
|
14
|
+
"build": "tsc -p tsconfig.json",
|
|
15
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo",
|
|
16
|
+
"prepack": "pnpm run build"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"default": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./template-renderer": {
|
|
30
|
+
"types": "./dist/template-renderer.d.ts",
|
|
31
|
+
"import": "./dist/template-renderer.js",
|
|
32
|
+
"default": "./dist/template-renderer.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"main": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"liquidjs": "^10.24.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@voyantjs/voyant-typescript-config": "workspace:*",
|
|
43
|
+
"typescript": "^6.0.2",
|
|
44
|
+
"vitest": "^4.1.2"
|
|
45
|
+
},
|
|
46
|
+
"repository": {
|
|
47
|
+
"type": "git",
|
|
48
|
+
"url": "https://github.com/voyantjs/voyant.git",
|
|
49
|
+
"directory": "packages/templating"
|
|
50
|
+
}
|
|
51
|
+
}
|