vintasend-liquidjs 0.0.0-stage → 1.0.0-alpha10

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 CHANGED
@@ -1,3 +1,204 @@
1
- # Temporary Holding Version
1
+ # vintasend-liquidjs
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ```
@@ -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,56 @@
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. When template content carries a `preheader` (as managed templates do),
21
+ * it is rendered with the body's escaping and injected as a hidden element at the top of the body,
22
+ * which is what mail clients show as inbox preview text. The file-based `render` has no preheader.
23
+ * The subject is plain text and is never escaped; its whitespace, including
24
+ * the trailing newline of a template file, is collapsed to single spaces.
25
+ */
26
+ export declare class LiquidEmailTemplateRenderer<Config extends BaseNotificationTypeConfig> implements BaseEmailTemplateRenderer<Config> {
27
+ protected readonly options: LiquidTemplateRendererOptions;
28
+ logger: BaseLogger | null;
29
+ protected readonly rendererName: string;
30
+ /** What this renderer produces, for log lines. */
31
+ protected readonly templateKind: string;
32
+ protected readonly subjectRequired: boolean;
33
+ /** Whether a preheader in template content is injected into the body. Off for plain text. */
34
+ protected readonly preheaderSupported: boolean;
35
+ private readonly bodyEngine;
36
+ private readonly subjectEngine;
37
+ constructor(options?: LiquidTemplateRendererOptions);
38
+ /**
39
+ * Inject logger (called by VintaSend when logger exists)
40
+ */
41
+ injectLogger(logger: BaseLogger): void;
42
+ render(notification: AnyNotification<Config>, context: JsonObject): Promise<EmailTemplate>;
43
+ renderFromTemplateContent(notification: AnyNotification<Config>, templateContent: EmailTemplateContent, context: JsonObject): Promise<EmailTemplate>;
44
+ protected normalizeBody(body: string): string;
45
+ private assertTemplateExists;
46
+ /**
47
+ * Runs one render, logging a failure by template key and error name only: Liquid's errors quote
48
+ * the template source around the failure, and an error thrown by a filter keeps its message,
49
+ * which can include context values.
50
+ */
51
+ private renderPart;
52
+ }
53
+ export declare class LiquidEmailTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
54
+ create(options?: LiquidTemplateRendererOptions): LiquidEmailTemplateRenderer<Config>;
55
+ }
56
+ //# 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;;;;;;;;;;;;;;;GAeG;AACH,qBAAa,2BAA2B,CAAC,MAAM,SAAS,0BAA0B,CAChF,YAAW,yBAAyB,CAAC,MAAM,CAAC;IAYhC,SAAS,CAAC,QAAQ,CAAC,OAAO,EAAE,6BAA6B;IAVrE,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,6FAA6F;IAC7F,SAAS,CAAC,QAAQ,CAAC,kBAAkB,EAAE,OAAO,CAAQ;IACtD,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IAEvC,YAA+B,OAAO,GAAE,6BAAkC,EAMzE;IAED;;OAEG;IACH,YAAY,CAAC,MAAM,EAAE,UAAU,GAAG,IAAI,CAErC;IAEK,MAAM,CAAC,YAAY,EAAE,eAAe,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,aAAa,CAAC,CA2B/F;IAEK,yBAAyB,CAC7B,YAAY,EAAE,eAAe,CAAC,MAAM,CAAC,EACrC,eAAe,EAAE,oBAAoB,EACrC,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,aAAa,CAAC,CA8BxB;IAED,SAAS,CAAC,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE5C;IAED,OAAO,CAAC,oBAAoB;IAO5B;;;;OAIG;YACW,UAAU;CAiBzB;AA2BD,qBAAa,kCAAkC,CAAC,MAAM,SAAS,0BAA0B;IACvF,MAAM,CAAC,OAAO,GAAE,6BAAkC,GAAG,2BAA2B,CAAC,MAAM,CAAC,CAEvF;CACF"}
@@ -0,0 +1,130 @@
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. When template content carries a `preheader` (as managed templates do),
14
+ * it is rendered with the body's escaping and injected as a hidden element at the top of the body,
15
+ * which is what mail clients show as inbox preview text. The file-based `render` has no preheader.
16
+ * The subject is plain text and is never escaped; its whitespace, including
17
+ * the trailing newline of a template file, is collapsed to single spaces.
18
+ */
19
+ export class LiquidEmailTemplateRenderer {
20
+ constructor(options = {}) {
21
+ this.options = options;
22
+ this.logger = null;
23
+ this.rendererName = 'LiquidEmailTemplateRenderer';
24
+ /** What this renderer produces, for log lines. */
25
+ this.templateKind = 'email';
26
+ this.subjectRequired = true;
27
+ /** Whether a preheader in template content is injected into the body. Off for plain text. */
28
+ this.preheaderSupported = true;
29
+ const { configureEngine, ...liquidOptions } = options;
30
+ this.bodyEngine = new Liquid({ outputEscape: 'escape', ...liquidOptions });
31
+ this.subjectEngine = new Liquid({ ...liquidOptions, outputEscape: undefined });
32
+ configureEngine?.(this.bodyEngine);
33
+ configureEngine?.(this.subjectEngine);
34
+ }
35
+ /**
36
+ * Inject logger (called by VintaSend when logger exists)
37
+ */
38
+ injectLogger(logger) {
39
+ this.logger = logger;
40
+ }
41
+ async render(notification, context) {
42
+ const { bodyTemplate, subjectTemplate } = notification;
43
+ if (!bodyTemplate) {
44
+ throw new Error('Body template is required');
45
+ }
46
+ if (!subjectTemplate && this.subjectRequired) {
47
+ throw new Error('Subject template is required');
48
+ }
49
+ this.assertTemplateExists('Body', bodyTemplate);
50
+ if (subjectTemplate) {
51
+ this.assertTemplateExists('Subject', subjectTemplate);
52
+ }
53
+ this.logger?.info(log `[${logLabel(this.rendererName)}] Rendering ${logLabel(this.templateKind)} template for notification ${logId(notification.id)}`);
54
+ const body = await this.renderPart('body', bodyTemplate, notification, () => this.bodyEngine.renderFile(bodyTemplate, context));
55
+ const subject = subjectTemplate
56
+ ? await this.renderPart('subject', subjectTemplate, notification, () => this.subjectEngine.renderFile(subjectTemplate, context))
57
+ : '';
58
+ return { subject: normalizeSubject(subject), body: this.normalizeBody(body) };
59
+ }
60
+ async renderFromTemplateContent(notification, templateContent, context) {
61
+ const { subject: subjectContent, body: bodyContent } = templateContent;
62
+ const preheaderContent = templateContent.preheader;
63
+ if (!subjectContent && this.subjectRequired) {
64
+ throw new Error('Subject template is required');
65
+ }
66
+ this.logger?.info(log `[${logLabel(this.rendererName)}] Rendering ${logLabel(this.templateKind)} template from content for notification ${logId(notification.id)}`);
67
+ const body = await this.renderPart('body', null, notification, () => this.bodyEngine.parseAndRender(bodyContent, context));
68
+ const subject = subjectContent
69
+ ? await this.renderPart('subject', null, notification, () => this.subjectEngine.parseAndRender(subjectContent, context))
70
+ : '';
71
+ const preheader = this.preheaderSupported && preheaderContent?.trim()
72
+ ? await this.renderPart('preheader', null, notification, () => this.bodyEngine.parseAndRender(preheaderContent, context))
73
+ : '';
74
+ return {
75
+ subject: normalizeSubject(subject),
76
+ body: this.normalizeBody(injectPreheader(body, preheader)),
77
+ };
78
+ }
79
+ normalizeBody(body) {
80
+ return body;
81
+ }
82
+ assertTemplateExists(part, templateKey) {
83
+ const { templates } = this.options;
84
+ if (templates && !Object.hasOwn(templates, templateKey)) {
85
+ throw new Error(`${part} template "${templateKey}" not found in templates`);
86
+ }
87
+ }
88
+ /**
89
+ * Runs one render, logging a failure by template key and error name only: Liquid's errors quote
90
+ * the template source around the failure, and an error thrown by a filter keeps its message,
91
+ * which can include context values.
92
+ */
93
+ async renderPart(part, templateKey, notification, renderTemplate) {
94
+ try {
95
+ return await renderTemplate();
96
+ }
97
+ catch (error) {
98
+ this.logger?.error(templateKey === null
99
+ ? log `[${logLabel(this.rendererName)}] Error rendering ${logLabel(part)} template content for notification ${logId(notification.id)}: ${logError(error)}`
100
+ : log `[${logLabel(this.rendererName)}] Error rendering ${logLabel(part)} template ${logLabel(templateKey)} for notification ${logId(notification.id)}: ${logError(error)}`);
101
+ throw error;
102
+ }
103
+ }
104
+ }
105
+ const PREHEADER_STYLE = 'display:none;font-size:1px;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;visibility:hidden;';
106
+ /**
107
+ * Put `preheader` (already escaped by the engine) in a hidden element at the top of the body, or
108
+ * straight after the opening `<body>` tag when the body is a full document. No-op when blank.
109
+ */
110
+ function injectPreheader(body, preheader) {
111
+ const text = preheader.replace(/\s+/g, ' ').trim();
112
+ if (!text) {
113
+ return body;
114
+ }
115
+ const element = `<div style="${PREHEADER_STYLE}">${text}</div>`;
116
+ const bodyTag = /<body\b[^>]*>/i.exec(body);
117
+ if (bodyTag === null) {
118
+ return element + body;
119
+ }
120
+ const end = bodyTag.index + bodyTag[0].length;
121
+ return body.slice(0, end) + element + body.slice(end);
122
+ }
123
+ function normalizeSubject(subject) {
124
+ return subject.replace(/\s+/g, ' ').trim();
125
+ }
126
+ export class LiquidEmailTemplateRendererFactory {
127
+ create(options = {}) {
128
+ return new LiquidEmailTemplateRenderer(options);
129
+ }
130
+ }
@@ -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;IAEvF,YACE,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACjC,OAAO,GAAE,IAAI,CAAC,6BAA6B,EAAE,WAAW,CAAM,EAG/D;CACF;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,CAE3C;CACF"}
@@ -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,28 @@
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
+ protected readonly preheaderSupported: boolean;
22
+ constructor(options?: LiquidTemplateRendererOptions);
23
+ protected normalizeBody(body: string): string;
24
+ }
25
+ export declare class LiquidSmsTemplateRendererFactory<Config extends BaseNotificationTypeConfig> {
26
+ create(options?: LiquidTemplateRendererOptions): LiquidSmsTemplateRenderer<Config>;
27
+ }
28
+ //# 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;IAC7D,mBAA4B,kBAAkB,EAAE,OAAO,CAAS;IAEhE,YAAY,OAAO,GAAE,6BAAkC,EAEtD;IAED,UAAmB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAErD;CACF;AAED,qBAAa,gCAAgC,CAAC,MAAM,SAAS,0BAA0B;IACrF,MAAM,CAAC,OAAO,GAAE,6BAAkC,GAAG,yBAAyB,CAAC,MAAM,CAAC,CAErF;CACF"}
@@ -0,0 +1,32 @@
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
+ this.preheaderSupported = false;
23
+ }
24
+ normalizeBody(body) {
25
+ return body.trim();
26
+ }
27
+ }
28
+ export class LiquidSmsTemplateRendererFactory {
29
+ create(options = {}) {
30
+ return new LiquidSmsTemplateRenderer(options);
31
+ }
32
+ }
@@ -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": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-alpha10",
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.30.1",
41
+ "vintasend": "1.0.0-alpha10"
42
+ },
43
+ "devDependencies": {
44
+ "@biomejs/biome": "^2.6.4",
45
+ "@types/node": "26.6.6",
46
+ "@vitest/coverage-v8": "5.0.5",
47
+ "husky": "^9.1.7",
48
+ "typescript": "^7.0.2",
49
+ "vitest": "5.0.5"
50
+ }
51
+ }