@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 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.
@@ -0,0 +1,2 @@
1
+ export * from "./template-renderer.js";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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
+ }