vintasend-liquidjs 0.0.0-stage → 1.0.0-alpha5
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 +203 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/liquid-email-template-renderer.d.ts +51 -0
- package/dist/liquid-email-template-renderer.d.ts.map +1 -0
- package/dist/liquid-email-template-renderer.js +100 -0
- package/dist/liquid-inline-email-template-renderer.d.ts +20 -0
- package/dist/liquid-inline-email-template-renderer.d.ts.map +1 -0
- package/dist/liquid-inline-email-template-renderer.js +22 -0
- package/dist/liquid-sms-template-renderer.d.ts +27 -0
- package/dist/liquid-sms-template-renderer.d.ts.map +1 -0
- package/dist/liquid-sms-template-renderer.js +31 -0
- package/dist/scripts/compile-liquid-templates.d.ts +7 -0
- package/dist/scripts/compile-liquid-templates.d.ts.map +1 -0
- package/dist/scripts/compile-liquid-templates.js +79 -0
- package/package.json +49 -4
package/README.md
CHANGED
|
@@ -1,3 +1,204 @@
|
|
|
1
|
-
#
|
|
1
|
+
# vintasend-liquidjs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[LiquidJS](https://liquidjs.com/) email and SMS template renderers for VintaSend.
|
|
4
|
+
|
|
5
|
+
Liquid templates cannot run arbitrary code, which makes them a good fit for templates edited
|
|
6
|
+
outside the codebase, such as [managed templates](https://github.com/vintasoftware/vintasend-ts#managed-templates)
|
|
7
|
+
stored in a database.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install vintasend-liquidjs
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Exports
|
|
16
|
+
|
|
17
|
+
- `LiquidEmailTemplateRenderer` / `LiquidEmailTemplateRendererFactory`: renders template files.
|
|
18
|
+
- `LiquidInlineEmailTemplateRenderer` / `LiquidInlineEmailTemplateRendererFactory`: renders
|
|
19
|
+
templates from an in-memory map.
|
|
20
|
+
- `LiquidSmsTemplateRenderer` / `LiquidSmsTemplateRendererFactory`: renders plain-text SMS
|
|
21
|
+
messages, from files or an in-memory map.
|
|
22
|
+
- `LiquidTemplateRendererOptions`: the renderer options (every `LiquidOptions` field plus
|
|
23
|
+
`configureEngine`).
|
|
24
|
+
|
|
25
|
+
## Rendering template files
|
|
26
|
+
|
|
27
|
+
`notification.bodyTemplate` and `notification.subjectTemplate` are paths that Liquid resolves
|
|
28
|
+
against `root`. Lookups that leave the roots fail, so a template key cannot read an arbitrary file.
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
templates/
|
|
32
|
+
emails/
|
|
33
|
+
welcome.liquid
|
|
34
|
+
welcome-subject.liquid
|
|
35
|
+
partials/
|
|
36
|
+
footer.liquid
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```liquid
|
|
40
|
+
{%- comment -%} templates/emails/welcome.liquid {%- endcomment -%}
|
|
41
|
+
<h1>Hello {{ firstName }}!</h1>
|
|
42
|
+
<p>Your appointment is on {{ appointmentDate | date: "%B %-d, %Y" }}.</p>
|
|
43
|
+
{% include "partials/footer.liquid" %}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```liquid
|
|
47
|
+
{%- comment -%} templates/emails/welcome-subject.liquid {%- endcomment -%}
|
|
48
|
+
Welcome, {{ firstName }}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
import { LiquidEmailTemplateRendererFactory } from 'vintasend-liquidjs';
|
|
53
|
+
|
|
54
|
+
const templateRenderer = new LiquidEmailTemplateRendererFactory<NotificationTypeConfig>().create({
|
|
55
|
+
root: './templates',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Then create the notification with
|
|
59
|
+
// bodyTemplate: 'emails/welcome.liquid',
|
|
60
|
+
// subjectTemplate: 'emails/welcome-subject.liquid',
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Any [LiquidJS option](https://liquidjs.com/tutorials/options.html) can be passed: `root`,
|
|
64
|
+
`partials`, `layouts`, `extname`, `cache`, `strictVariables`, `strictFilters`, `globals`, and so
|
|
65
|
+
on.
|
|
66
|
+
|
|
67
|
+
### Escaping
|
|
68
|
+
|
|
69
|
+
The **body** is rendered with `outputEscape: 'escape'`, so every `{{ value }}` is HTML-escaped and
|
|
70
|
+
context values cannot inject markup into the email. Mark a value as trusted HTML with the `raw`
|
|
71
|
+
filter: `{{ trustedHtml | raw }}`. To change this, pass your own `outputEscape`.
|
|
72
|
+
|
|
73
|
+
The **subject** is plain text and is never escaped. Its whitespace, including the trailing newline
|
|
74
|
+
most editors add to a template file and any line breaks coming from context values, is collapsed to
|
|
75
|
+
single spaces.
|
|
76
|
+
|
|
77
|
+
### Custom filters and tags
|
|
78
|
+
|
|
79
|
+
The renderer creates one Liquid engine for the body and one for the subject. Register filters,
|
|
80
|
+
tags or plugins on both with `configureEngine`:
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
const templateRenderer = new LiquidEmailTemplateRendererFactory<NotificationTypeConfig>().create({
|
|
84
|
+
root: './templates',
|
|
85
|
+
configureEngine: (engine) => {
|
|
86
|
+
engine.registerFilter('initials', (name: string) =>
|
|
87
|
+
name.split(' ').map((part) => part[0]).join(''),
|
|
88
|
+
);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Untrusted templates
|
|
94
|
+
|
|
95
|
+
When templates come from users rather than from your repository, set LiquidJS's
|
|
96
|
+
[DoS limits](https://liquidjs.com/tutorials/dos.html) (`parseLimit`, `renderLimit`,
|
|
97
|
+
`memoryLimit`) and consider `strictFilters`, so a template cannot run expensive loops.
|
|
98
|
+
|
|
99
|
+
## Rendering templates from memory
|
|
100
|
+
|
|
101
|
+
For environments that can only deploy a single bundle (for example Medplum bots), use the inline
|
|
102
|
+
renderer with a map of template key to template source. `include`, `render` and `layout` resolve
|
|
103
|
+
against the same map.
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { LiquidInlineEmailTemplateRendererFactory } from 'vintasend-liquidjs';
|
|
107
|
+
import templates from './compiled-templates.json';
|
|
108
|
+
|
|
109
|
+
const templateRenderer = new LiquidInlineEmailTemplateRendererFactory<NotificationTypeConfig>().create(
|
|
110
|
+
templates,
|
|
111
|
+
{ strictVariables: true },
|
|
112
|
+
);
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Rendering a key that is not in the map throws `Body template "<key>" not found in templates` (or
|
|
116
|
+
the subject equivalent).
|
|
117
|
+
|
|
118
|
+
### Template compilation script
|
|
119
|
+
|
|
120
|
+
The `compile-liquid-templates` CLI writes every `.liquid` file in a directory to a JSON map keyed
|
|
121
|
+
by POSIX relative path (`emails/welcome.liquid`), which is what the inline renderer expects:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
compile-liquid-templates [input-directory] [output-file]
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
`input-directory` defaults to `./templates` and `output-file` to `compiled-templates.json`.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"scripts": {
|
|
132
|
+
"compile-templates": "compile-liquid-templates ./templates ./src/compiled-templates.json"
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Rendering SMS
|
|
138
|
+
|
|
139
|
+
`LiquidSmsTemplateRenderer` works with SMS adapters such as
|
|
140
|
+
[vintasend-ts-twilio](https://github.com/vintasoftware/vintasend-ts-twilio), which send the rendered
|
|
141
|
+
`body`. Compared with the email renderers:
|
|
142
|
+
|
|
143
|
+
- Output is **not** HTML-escaped, because an SMS is plain text: `Tom & Jerry` arrives as written.
|
|
144
|
+
- The subject template is optional. Without one, `subject` is an empty string.
|
|
145
|
+
- Leading and trailing whitespace is trimmed from the body, including the trailing newline of a
|
|
146
|
+
template file. Line breaks inside the message are kept.
|
|
147
|
+
|
|
148
|
+
```liquid
|
|
149
|
+
{%- comment -%} templates/sms/reminder.liquid {%- endcomment -%}
|
|
150
|
+
Hi {{ firstName }}, your appointment is on {{ appointmentDate | date: "%b %-d" }}.
|
|
151
|
+
Reply STOP to opt out.
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
import { TwilioSmsAdapterFactory } from 'vintasend-ts-twilio';
|
|
156
|
+
import { LiquidSmsTemplateRendererFactory } from 'vintasend-liquidjs';
|
|
157
|
+
|
|
158
|
+
const smsRenderer = new LiquidSmsTemplateRendererFactory<NotificationTypeConfig>().create({
|
|
159
|
+
root: './templates',
|
|
160
|
+
});
|
|
161
|
+
const smsAdapter = new TwilioSmsAdapterFactory<NotificationTypeConfig>().create(
|
|
162
|
+
smsRenderer,
|
|
163
|
+
false,
|
|
164
|
+
twilioConfig,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Then create the notification with
|
|
168
|
+
// bodyTemplate: 'sms/reminder.liquid',
|
|
169
|
+
// subjectTemplate: null,
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
For templates held in memory (Medplum bots), pass the map as the `templates` option. Keys missing
|
|
173
|
+
from the map fail with the same `not found in templates` error as the inline email renderer:
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
import templates from './compiled-templates.json';
|
|
177
|
+
|
|
178
|
+
const smsRenderer = new LiquidSmsTemplateRendererFactory<NotificationTypeConfig>().create({
|
|
179
|
+
templates,
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Rendering stored template content
|
|
184
|
+
|
|
185
|
+
Every renderer implements `renderFromTemplateContent`, which renders `{ subject, body }` strings
|
|
186
|
+
directly. This is what `vintasend-managed-templates` calls, so any of them can render managed
|
|
187
|
+
templates (the SMS renderer accepts a `null` subject). Includes in that content resolve against
|
|
188
|
+
`root` or the template map.
|
|
189
|
+
|
|
190
|
+
## Logging
|
|
191
|
+
|
|
192
|
+
With a logger injected, the renderers log the notification id, the template key and, on failure,
|
|
193
|
+
the error's name. They never log the context, the rendered output or the error message. LiquidJS
|
|
194
|
+
errors quote the template source around the failure, and errors thrown by filters can quote
|
|
195
|
+
context values.
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
npm install
|
|
201
|
+
npm run build
|
|
202
|
+
npm test
|
|
203
|
+
npm run lint
|
|
204
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { LiquidOptions } from 'liquidjs';
|
|
2
|
+
export type { LiquidTemplateRendererOptions } from './liquid-email-template-renderer.js';
|
|
3
|
+
export { LiquidEmailTemplateRenderer, LiquidEmailTemplateRendererFactory, } from './liquid-email-template-renderer.js';
|
|
4
|
+
export { LiquidInlineEmailTemplateRenderer, LiquidInlineEmailTemplateRendererFactory, } from './liquid-inline-email-template-renderer.js';
|
|
5
|
+
export { LiquidSmsTemplateRenderer, LiquidSmsTemplateRendererFactory, } from './liquid-sms-template-renderer.js';
|
|
6
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAC9C,YAAY,EAAE,6BAA6B,EAAE,MAAM,qCAAqC,CAAC;AACzF,OAAO,EACL,2BAA2B,EAC3B,kCAAkC,GACnC,MAAM,qCAAqC,CAAC;AAC7C,OAAO,EACL,iCAAiC,EACjC,wCAAwC,GACzC,MAAM,4CAA4C,CAAC;AACpD,OAAO,EACL,yBAAyB,EACzB,gCAAgC,GACjC,MAAM,mCAAmC,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { LiquidEmailTemplateRenderer, LiquidEmailTemplateRendererFactory, } from './liquid-email-template-renderer.js';
|
|
2
|
+
export { LiquidInlineEmailTemplateRenderer, LiquidInlineEmailTemplateRendererFactory, } from './liquid-inline-email-template-renderer.js';
|
|
3
|
+
export { LiquidSmsTemplateRenderer, LiquidSmsTemplateRendererFactory, } from './liquid-sms-template-renderer.js';
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Liquid, type LiquidOptions } from 'liquidjs';
|
|
2
|
+
import type { AnyNotification, BaseEmailTemplateRenderer, BaseLogger, BaseNotificationTypeConfig, EmailTemplate, EmailTemplateContent, JsonObject } from 'vintasend';
|
|
3
|
+
export type LiquidTemplateRendererOptions = LiquidOptions & {
|
|
4
|
+
/**
|
|
5
|
+
* Called once for each Liquid engine the renderer creates (body and subject), to register custom
|
|
6
|
+
* filters, tags or plugins.
|
|
7
|
+
*/
|
|
8
|
+
configureEngine?: (engine: Liquid) => void;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Renders email notifications from Liquid template files.
|
|
12
|
+
*
|
|
13
|
+
* `notification.bodyTemplate` and `notification.subjectTemplate` are template paths resolved by
|
|
14
|
+
* Liquid against `options.root` (the working directory when unset). Liquid refuses lookups that
|
|
15
|
+
* escape the roots, so a template key can never read an arbitrary file. With `options.templates`
|
|
16
|
+
* they are keys into that map instead, and a key missing from it is rejected before rendering.
|
|
17
|
+
*
|
|
18
|
+
* The body is rendered with `outputEscape: 'escape'` unless `options.outputEscape` says otherwise,
|
|
19
|
+
* so context values cannot inject markup into an HTML email; use `{{ value | raw }}` for values
|
|
20
|
+
* that are trusted HTML. The subject is plain text and is never escaped; its whitespace, including
|
|
21
|
+
* the trailing newline of a template file, is collapsed to single spaces.
|
|
22
|
+
*/
|
|
23
|
+
export declare class LiquidEmailTemplateRenderer<Config extends BaseNotificationTypeConfig> implements BaseEmailTemplateRenderer<Config> {
|
|
24
|
+
protected readonly options: LiquidTemplateRendererOptions;
|
|
25
|
+
logger: BaseLogger | null;
|
|
26
|
+
protected readonly rendererName: string;
|
|
27
|
+
/** What this renderer produces, for log lines. */
|
|
28
|
+
protected readonly templateKind: string;
|
|
29
|
+
protected readonly subjectRequired: boolean;
|
|
30
|
+
private readonly bodyEngine;
|
|
31
|
+
private readonly subjectEngine;
|
|
32
|
+
constructor(options?: LiquidTemplateRendererOptions);
|
|
33
|
+
/**
|
|
34
|
+
* Inject logger (called by VintaSend when logger exists)
|
|
35
|
+
*/
|
|
36
|
+
injectLogger(logger: BaseLogger): void;
|
|
37
|
+
render(notification: AnyNotification<Config>, context: JsonObject): Promise<EmailTemplate>;
|
|
38
|
+
renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: EmailTemplateContent, context: JsonObject): Promise<EmailTemplate>;
|
|
39
|
+
protected normalizeBody(body: string): string;
|
|
40
|
+
private assertTemplateExists;
|
|
41
|
+
/**
|
|
42
|
+
* Runs one render, logging a failure by template key and error name only: Liquid's errors quote
|
|
43
|
+
* the template source around the failure, and an error thrown by a filter keeps its message,
|
|
44
|
+
* which can include context values.
|
|
45
|
+
*/
|
|
46
|
+
private renderPart;
|
|
47
|
+
}
|
|
48
|
+
export declare class LiquidEmailTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
|
|
49
|
+
create(options?: LiquidTemplateRendererOptions): LiquidEmailTemplateRenderer<Config>;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=liquid-email-template-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"liquid-email-template-renderer.d.ts","sourceRoot":"","sources":["../src/liquid-email-template-renderer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,aAAa,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,KAAK,EACV,eAAe,EACf,yBAAyB,EACzB,UAAU,EACV,0BAA0B,EAC1B,aAAa,EACb,oBAAoB,EACpB,UAAU,EACX,MAAM,WAAW,CAAC;AAKnB,MAAM,MAAM,6BAA6B,GAAG,aAAa,GAAG;IAC1D;;;OAGG;IACH,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,qBAAa,2BAA2B,CAAC,MAAM,SAAS,0BAA0B,CAChF,YAAW,yBAAyB,CAAC,MAAM,CAAC;IAUhC,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,6BAA6B;IARrE,MAAM,EAAE,UAAU,GAAG,IAAI,CAAQ;IACjC,SAAS,CAAC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAiC;IACxE,kDAAkD;IAClD,SAAS,CAAC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAW;IAClD,SAAS,CAAC,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAQ;IACnD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;gBAER,OAAO,GAAE,6BAAkC;IAQ1E;;OAEG;IACH,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI;IAIhC,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC;IA6B1F,yBAAyB,CAC7B,YAAY,EAAE,eAAe,CAAC,MAAM,CAAC,EACrC,eAAe,EAAE,oBAAoB,EACrC,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,CAAC;IAsBzB,SAAS,CAAC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;IAI7C,OAAO,CAAC,oBAAoB;IAO5B;;;;OAIG;YACW,UAAU;CAiBzB;AAMD,qBAAa,kCAAkC,CAAC,MAAM,SAAS,0BAA0B;IACvF,MAAM,CAAC,OAAO,GAAE,6BAAkC,GAAG,2BAA2B,CAAC,MAAM,CAAC;CAGzF"}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { Liquid } from 'liquidjs';
|
|
2
|
+
import { log, logError, logId, logLabel } from 'vintasend';
|
|
3
|
+
/**
|
|
4
|
+
* Renders email notifications from Liquid template files.
|
|
5
|
+
*
|
|
6
|
+
* `notification.bodyTemplate` and `notification.subjectTemplate` are template paths resolved by
|
|
7
|
+
* Liquid against `options.root` (the working directory when unset). Liquid refuses lookups that
|
|
8
|
+
* escape the roots, so a template key can never read an arbitrary file. With `options.templates`
|
|
9
|
+
* they are keys into that map instead, and a key missing from it is rejected before rendering.
|
|
10
|
+
*
|
|
11
|
+
* The body is rendered with `outputEscape: 'escape'` unless `options.outputEscape` says otherwise,
|
|
12
|
+
* so context values cannot inject markup into an HTML email; use `{{ value | raw }}` for values
|
|
13
|
+
* that are trusted HTML. The subject is plain text and is never escaped; its whitespace, including
|
|
14
|
+
* the trailing newline of a template file, is collapsed to single spaces.
|
|
15
|
+
*/
|
|
16
|
+
export class LiquidEmailTemplateRenderer {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.options = options;
|
|
19
|
+
this.logger = null;
|
|
20
|
+
this.rendererName = 'LiquidEmailTemplateRenderer';
|
|
21
|
+
/** What this renderer produces, for log lines. */
|
|
22
|
+
this.templateKind = 'email';
|
|
23
|
+
this.subjectRequired = true;
|
|
24
|
+
const { configureEngine, ...liquidOptions } = options;
|
|
25
|
+
this.bodyEngine = new Liquid({ outputEscape: 'escape', ...liquidOptions });
|
|
26
|
+
this.subjectEngine = new Liquid({ ...liquidOptions, outputEscape: undefined });
|
|
27
|
+
configureEngine?.(this.bodyEngine);
|
|
28
|
+
configureEngine?.(this.subjectEngine);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Inject logger (called by VintaSend when logger exists)
|
|
32
|
+
*/
|
|
33
|
+
injectLogger(logger) {
|
|
34
|
+
this.logger = logger;
|
|
35
|
+
}
|
|
36
|
+
async render(notification, context) {
|
|
37
|
+
const { bodyTemplate, subjectTemplate } = notification;
|
|
38
|
+
if (!bodyTemplate) {
|
|
39
|
+
throw new Error('Body template is required');
|
|
40
|
+
}
|
|
41
|
+
if (!subjectTemplate && this.subjectRequired) {
|
|
42
|
+
throw new Error('Subject template is required');
|
|
43
|
+
}
|
|
44
|
+
this.assertTemplateExists('Body', bodyTemplate);
|
|
45
|
+
if (subjectTemplate) {
|
|
46
|
+
this.assertTemplateExists('Subject', subjectTemplate);
|
|
47
|
+
}
|
|
48
|
+
this.logger?.info(log `[${logLabel(this.rendererName)}] Rendering ${logLabel(this.templateKind)} template for notification ${logId(notification.id)}`);
|
|
49
|
+
const body = await this.renderPart('body', bodyTemplate, notification, () => this.bodyEngine.renderFile(bodyTemplate, context));
|
|
50
|
+
const subject = subjectTemplate
|
|
51
|
+
? await this.renderPart('subject', subjectTemplate, notification, () => this.subjectEngine.renderFile(subjectTemplate, context))
|
|
52
|
+
: '';
|
|
53
|
+
return { subject: normalizeSubject(subject), body: this.normalizeBody(body) };
|
|
54
|
+
}
|
|
55
|
+
async renderFromTemplateContent(notification, templateContent, context) {
|
|
56
|
+
const { subject: subjectContent, body: bodyContent } = templateContent;
|
|
57
|
+
if (!subjectContent && this.subjectRequired) {
|
|
58
|
+
throw new Error('Subject template is required');
|
|
59
|
+
}
|
|
60
|
+
this.logger?.info(log `[${logLabel(this.rendererName)}] Rendering ${logLabel(this.templateKind)} template from content for notification ${logId(notification.id)}`);
|
|
61
|
+
const body = await this.renderPart('body', null, notification, () => this.bodyEngine.parseAndRender(bodyContent, context));
|
|
62
|
+
const subject = subjectContent
|
|
63
|
+
? await this.renderPart('subject', null, notification, () => this.subjectEngine.parseAndRender(subjectContent, context))
|
|
64
|
+
: '';
|
|
65
|
+
return { subject: normalizeSubject(subject), body: this.normalizeBody(body) };
|
|
66
|
+
}
|
|
67
|
+
normalizeBody(body) {
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
70
|
+
assertTemplateExists(part, templateKey) {
|
|
71
|
+
const { templates } = this.options;
|
|
72
|
+
if (templates && !Object.hasOwn(templates, templateKey)) {
|
|
73
|
+
throw new Error(`${part} template "${templateKey}" not found in templates`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Runs one render, logging a failure by template key and error name only: Liquid's errors quote
|
|
78
|
+
* the template source around the failure, and an error thrown by a filter keeps its message,
|
|
79
|
+
* which can include context values.
|
|
80
|
+
*/
|
|
81
|
+
async renderPart(part, templateKey, notification, renderTemplate) {
|
|
82
|
+
try {
|
|
83
|
+
return await renderTemplate();
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
this.logger?.error(templateKey === null
|
|
87
|
+
? log `[${logLabel(this.rendererName)}] Error rendering ${logLabel(part)} template content for notification ${logId(notification.id)}: ${logError(error)}`
|
|
88
|
+
: log `[${logLabel(this.rendererName)}] Error rendering ${logLabel(part)} template ${logLabel(templateKey)} for notification ${logId(notification.id)}: ${logError(error)}`);
|
|
89
|
+
throw error;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function normalizeSubject(subject) {
|
|
94
|
+
return subject.replace(/\s+/g, ' ').trim();
|
|
95
|
+
}
|
|
96
|
+
export class LiquidEmailTemplateRendererFactory {
|
|
97
|
+
create(options = {}) {
|
|
98
|
+
return new LiquidEmailTemplateRenderer(options);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BaseNotificationTypeConfig } from 'vintasend';
|
|
2
|
+
import { LiquidEmailTemplateRenderer, type LiquidTemplateRendererOptions } from './liquid-email-template-renderer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Renders email notifications from Liquid templates held in memory instead of on disk.
|
|
5
|
+
*
|
|
6
|
+
* `notification.bodyTemplate` and `notification.subjectTemplate` are keys into `templates`, which
|
|
7
|
+
* is typically the JSON written by `compile-liquid-templates`. Includes, renders and layouts
|
|
8
|
+
* resolve against the same map, so templates can share partials without a file system — useful in
|
|
9
|
+
* environments such as Medplum bots where templates ship embedded in the bundle.
|
|
10
|
+
*
|
|
11
|
+
* Escaping and subject handling are the same as `LiquidEmailTemplateRenderer`.
|
|
12
|
+
*/
|
|
13
|
+
export declare class LiquidInlineEmailTemplateRenderer<Config extends BaseNotificationTypeConfig> extends LiquidEmailTemplateRenderer<Config> {
|
|
14
|
+
protected readonly rendererName: string;
|
|
15
|
+
constructor(templates: Record<string, string>, options?: Omit<LiquidTemplateRendererOptions, 'templates'>);
|
|
16
|
+
}
|
|
17
|
+
export declare class LiquidInlineEmailTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
|
|
18
|
+
create(templates: Record<string, string>, options?: Omit<LiquidTemplateRendererOptions, 'templates'>): LiquidInlineEmailTemplateRenderer<Config>;
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=liquid-inline-email-template-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"liquid-inline-email-template-renderer.d.ts","sourceRoot":"","sources":["../src/liquid-inline-email-template-renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EACL,2BAA2B,EAC3B,KAAK,6BAA6B,EACnC,MAAM,qCAAqC,CAAC;AAE7C;;;;;;;;;GASG;AACH,qBAAa,iCAAiC,CAC5C,MAAM,SAAS,0BAA0B,CACzC,SAAQ,2BAA2B,CAAC,MAAM,CAAC;IAC3C,mBAA4B,YAAY,EAAE,MAAM,CAAuC;gBAGrF,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACjC,OAAO,GAAE,IAAI,CAAC,6BAA6B,EAAE,WAAW,CAAM;CAIjE;AAED,qBAAa,wCAAwC,CAAC,MAAM,SAAS,0BAA0B;IAC7F,MAAM,CACJ,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACjC,OAAO,GAAE,IAAI,CAAC,6BAA6B,EAAE,WAAW,CAAM,GAC7D,iCAAiC,CAAC,MAAM,CAAC;CAG7C"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { LiquidEmailTemplateRenderer, } from './liquid-email-template-renderer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Renders email notifications from Liquid templates held in memory instead of on disk.
|
|
4
|
+
*
|
|
5
|
+
* `notification.bodyTemplate` and `notification.subjectTemplate` are keys into `templates`, which
|
|
6
|
+
* is typically the JSON written by `compile-liquid-templates`. Includes, renders and layouts
|
|
7
|
+
* resolve against the same map, so templates can share partials without a file system — useful in
|
|
8
|
+
* environments such as Medplum bots where templates ship embedded in the bundle.
|
|
9
|
+
*
|
|
10
|
+
* Escaping and subject handling are the same as `LiquidEmailTemplateRenderer`.
|
|
11
|
+
*/
|
|
12
|
+
export class LiquidInlineEmailTemplateRenderer extends LiquidEmailTemplateRenderer {
|
|
13
|
+
constructor(templates, options = {}) {
|
|
14
|
+
super({ ...options, templates });
|
|
15
|
+
this.rendererName = 'LiquidInlineEmailTemplateRenderer';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export class LiquidInlineEmailTemplateRendererFactory {
|
|
19
|
+
create(templates, options = {}) {
|
|
20
|
+
return new LiquidInlineEmailTemplateRenderer(templates, options);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { BaseNotificationTypeConfig } from 'vintasend';
|
|
2
|
+
import { LiquidEmailTemplateRenderer, type LiquidTemplateRendererOptions } from './liquid-email-template-renderer.js';
|
|
3
|
+
/**
|
|
4
|
+
* Renders SMS notifications from Liquid templates, for SMS adapters such as
|
|
5
|
+
* `vintasend-ts-twilio`, which send the rendered `body`.
|
|
6
|
+
*
|
|
7
|
+
* Templates resolve the same way as in `LiquidEmailTemplateRenderer`: paths under `options.root`,
|
|
8
|
+
* or keys into `options.templates` for templates held in memory (e.g. in Medplum bots).
|
|
9
|
+
*
|
|
10
|
+
* Differences from the email renderer:
|
|
11
|
+
* - Output is not HTML-escaped, since an SMS is plain text (`Tom & Jerry` stays as is). Passing
|
|
12
|
+
* `outputEscape` still overrides this.
|
|
13
|
+
* - The subject template is optional; without one, `subject` is an empty string.
|
|
14
|
+
* - Leading and trailing whitespace is trimmed from the body, including the trailing newline of a
|
|
15
|
+
* template file. Line breaks inside the message are kept.
|
|
16
|
+
*/
|
|
17
|
+
export declare class LiquidSmsTemplateRenderer<Config extends BaseNotificationTypeConfig> extends LiquidEmailTemplateRenderer<Config> {
|
|
18
|
+
protected readonly rendererName: string;
|
|
19
|
+
protected readonly templateKind: string;
|
|
20
|
+
protected readonly subjectRequired: boolean;
|
|
21
|
+
constructor(options?: LiquidTemplateRendererOptions);
|
|
22
|
+
protected normalizeBody(body: string): string;
|
|
23
|
+
}
|
|
24
|
+
export declare class LiquidSmsTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
|
|
25
|
+
create(options?: LiquidTemplateRendererOptions): LiquidSmsTemplateRenderer<Config>;
|
|
26
|
+
}
|
|
27
|
+
//# sourceMappingURL=liquid-sms-template-renderer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"liquid-sms-template-renderer.d.ts","sourceRoot":"","sources":["../src/liquid-sms-template-renderer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,WAAW,CAAC;AAC5D,OAAO,EACL,2BAA2B,EAC3B,KAAK,6BAA6B,EACnC,MAAM,qCAAqC,CAAC;AAE7C;;;;;;;;;;;;;GAaG;AACH,qBAAa,yBAAyB,CACpC,MAAM,SAAS,0BAA0B,CACzC,SAAQ,2BAA2B,CAAC,MAAM,CAAC;IAC3C,mBAA4B,YAAY,EAAE,MAAM,CAA+B;IAC/E,mBAA4B,YAAY,EAAE,MAAM,CAAS;IACzD,mBAA4B,eAAe,EAAE,OAAO,CAAS;gBAEjD,OAAO,GAAE,6BAAkC;cAIpC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM;CAGvD;AAED,qBAAa,gCAAgC,CAAC,MAAM,SAAS,0BAA0B;IACrF,MAAM,CAAC,OAAO,GAAE,6BAAkC,GAAG,yBAAyB,CAAC,MAAM,CAAC;CAGvF"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { LiquidEmailTemplateRenderer, } from './liquid-email-template-renderer.js';
|
|
2
|
+
/**
|
|
3
|
+
* Renders SMS notifications from Liquid templates, for SMS adapters such as
|
|
4
|
+
* `vintasend-ts-twilio`, which send the rendered `body`.
|
|
5
|
+
*
|
|
6
|
+
* Templates resolve the same way as in `LiquidEmailTemplateRenderer`: paths under `options.root`,
|
|
7
|
+
* or keys into `options.templates` for templates held in memory (e.g. in Medplum bots).
|
|
8
|
+
*
|
|
9
|
+
* Differences from the email renderer:
|
|
10
|
+
* - Output is not HTML-escaped, since an SMS is plain text (`Tom & Jerry` stays as is). Passing
|
|
11
|
+
* `outputEscape` still overrides this.
|
|
12
|
+
* - The subject template is optional; without one, `subject` is an empty string.
|
|
13
|
+
* - Leading and trailing whitespace is trimmed from the body, including the trailing newline of a
|
|
14
|
+
* template file. Line breaks inside the message are kept.
|
|
15
|
+
*/
|
|
16
|
+
export class LiquidSmsTemplateRenderer extends LiquidEmailTemplateRenderer {
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
super({ outputEscape: undefined, ...options });
|
|
19
|
+
this.rendererName = 'LiquidSmsTemplateRenderer';
|
|
20
|
+
this.templateKind = 'SMS';
|
|
21
|
+
this.subjectRequired = false;
|
|
22
|
+
}
|
|
23
|
+
normalizeBody(body) {
|
|
24
|
+
return body.trim();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class LiquidSmsTemplateRendererFactory {
|
|
28
|
+
create(options = {}) {
|
|
29
|
+
return new LiquidSmsTemplateRenderer(options);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Main function to compile liquid templates to JSON
|
|
4
|
+
*/
|
|
5
|
+
export declare function compileLiquidTemplates(inputDir?: string, outputFile?: string): void;
|
|
6
|
+
export declare function runCli(args?: string[]): void;
|
|
7
|
+
//# sourceMappingURL=compile-liquid-templates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compile-liquid-templates.d.ts","sourceRoot":"","sources":["../../src/scripts/compile-liquid-templates.ts"],"names":[],"mappings":";AAoCA;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,GAAE,MAAsB,EAChC,UAAU,GAAE,MAAkC,GAC7C,IAAI,CAwBN;AAeD,wBAAgB,MAAM,CAAC,IAAI,GAAE,MAAM,EAA0B,GAAG,IAAI,CAenE"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/**
|
|
6
|
+
* Recursively finds all .liquid files in a directory
|
|
7
|
+
*/
|
|
8
|
+
function findLiquidFiles(dir, baseDir, files = new Map()) {
|
|
9
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
10
|
+
for (const entry of entries) {
|
|
11
|
+
const fullPath = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
findLiquidFiles(fullPath, baseDir, files);
|
|
14
|
+
}
|
|
15
|
+
else if (entry.isFile() && entry.name.endsWith('.liquid')) {
|
|
16
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
17
|
+
// Keys are POSIX-style paths relative to the base directory, so includes such as
|
|
18
|
+
// {% include "partials/footer.liquid" %} resolve the same way they do on disk.
|
|
19
|
+
const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
20
|
+
files.set(relativePath, content);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return files;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Main function to compile liquid templates to JSON
|
|
27
|
+
*/
|
|
28
|
+
export function compileLiquidTemplates(inputDir = './templates', outputFile = 'compiled-templates.json') {
|
|
29
|
+
if (!fs.existsSync(inputDir)) {
|
|
30
|
+
throw new Error(`Directory "${inputDir}" does not exist.`);
|
|
31
|
+
}
|
|
32
|
+
if (!fs.statSync(inputDir).isDirectory()) {
|
|
33
|
+
throw new Error(`"${inputDir}" is not a directory.`);
|
|
34
|
+
}
|
|
35
|
+
console.log(`Searching for .liquid files in: ${inputDir}`);
|
|
36
|
+
const liquidFiles = findLiquidFiles(inputDir, inputDir);
|
|
37
|
+
console.log(`Found ${liquidFiles.size} .liquid file(s)`);
|
|
38
|
+
const result = {};
|
|
39
|
+
for (const [filePath, content] of liquidFiles.entries()) {
|
|
40
|
+
result[filePath] = content;
|
|
41
|
+
console.log(` - ${filePath}`);
|
|
42
|
+
}
|
|
43
|
+
fs.writeFileSync(outputFile, JSON.stringify(result, null, 2), 'utf-8');
|
|
44
|
+
console.log(`\nSuccessfully compiled templates to: ${outputFile}`);
|
|
45
|
+
}
|
|
46
|
+
function printHelp() {
|
|
47
|
+
console.log('Usage: compile-liquid-templates [input-directory] [output-file]');
|
|
48
|
+
console.log('');
|
|
49
|
+
console.log('Arguments:');
|
|
50
|
+
console.log(' input-directory Directory containing .liquid templates (default: ./templates)');
|
|
51
|
+
console.log(' output-file Output JSON file path (default: compiled-templates.json)');
|
|
52
|
+
console.log('');
|
|
53
|
+
console.log('Examples:');
|
|
54
|
+
console.log(' compile-liquid-templates');
|
|
55
|
+
console.log(' compile-liquid-templates ./templates');
|
|
56
|
+
console.log(' compile-liquid-templates ./templates ./compiled-templates.json');
|
|
57
|
+
}
|
|
58
|
+
export function runCli(args = process.argv.slice(2)) {
|
|
59
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
60
|
+
printHelp();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const [inputDir, outputFile] = args;
|
|
64
|
+
try {
|
|
65
|
+
compileLiquidTemplates(inputDir, outputFile);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
console.error(`Error: ${message}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Compare against the real path so the bin also runs when invoked through the
|
|
74
|
+
// node_modules/.bin symlink.
|
|
75
|
+
const isExecutedDirectly = process.argv[1] !== undefined &&
|
|
76
|
+
fileURLToPath(import.meta.url) === fs.realpathSync(process.argv[1]);
|
|
77
|
+
if (isExecutedDirectly) {
|
|
78
|
+
runCli(process.argv.slice(2));
|
|
79
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vintasend-liquidjs",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0-alpha5",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/vintasoftware/vintasend-liquidjs.git"
|
|
7
|
+
},
|
|
8
|
+
"description": "LiquidJS email and SMS template renderers for VintaSend",
|
|
9
|
+
"keywords": [
|
|
10
|
+
"vintasend",
|
|
11
|
+
"liquid",
|
|
12
|
+
"liquidjs",
|
|
13
|
+
"email",
|
|
14
|
+
"sms",
|
|
15
|
+
"templates",
|
|
16
|
+
"notifications"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "dist/index.js",
|
|
20
|
+
"types": "dist/index.d.ts",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"prepare": "husky",
|
|
25
|
+
"lint": "biome check .",
|
|
26
|
+
"format": "biome check --write .",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"test:coverage": "vitest run --coverage"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"bin": {
|
|
35
|
+
"compile-liquid-templates": "dist/scripts/compile-liquid-templates.js"
|
|
36
|
+
},
|
|
37
|
+
"author": "Hugo Bessa",
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"liquidjs": "^10.29.0",
|
|
41
|
+
"vintasend": "^1.0.0-alpha5"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@biomejs/biome": "^2.5.10",
|
|
45
|
+
"@types/node": "25.3.3",
|
|
46
|
+
"@vitest/coverage-v8": "4.0.18",
|
|
47
|
+
"husky": "^9.1.7",
|
|
48
|
+
"typescript": "^5.9.3",
|
|
49
|
+
"vitest": "4.0.18"
|
|
50
|
+
}
|
|
51
|
+
}
|