backend-manager 5.0.202 → 5.1.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/CHANGELOG.md +61 -0
- package/CLAUDE.md +43 -1501
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/helpers/settings.js +26 -7
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/disposable-domains.json +2 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared building blocks for newsletter templates.
|
|
3
|
+
*
|
|
4
|
+
* Architecture (rev 2):
|
|
5
|
+
*
|
|
6
|
+
* shell({ ...envelope }, { header, hero, body, signoff, extraStyles, extraAttributes })
|
|
7
|
+
*
|
|
8
|
+
* The shell is OPINIONATED — it always renders the cross-cutting concerns
|
|
9
|
+
* (top sponsorships, middle sponsorships, end sponsorships, citations, footer
|
|
10
|
+
* with CAN-SPAM address) automatically from the envelope object. Templates
|
|
11
|
+
* physically cannot forget them.
|
|
12
|
+
*
|
|
13
|
+
* Templates only own:
|
|
14
|
+
* - header: brand wordmark / masthead / whatever sits above the body
|
|
15
|
+
* - hero: optional cover treatment (subject as headline + eyebrow, etc.)
|
|
16
|
+
* - body: the section content — the main editorial
|
|
17
|
+
* - signoff: the closing sign-off ("Best, the Team")
|
|
18
|
+
* - extraStyles / extraAttributes: per-template CSS + mj-attributes overrides
|
|
19
|
+
*
|
|
20
|
+
* Everything else lives here and is rendered in a fixed, predictable order so
|
|
21
|
+
* any new template gets working citations + sponsorships + footer-with-address
|
|
22
|
+
* for free.
|
|
23
|
+
*
|
|
24
|
+
* Theme tokens:
|
|
25
|
+
* theme.spacing.gutter — horizontal padding inside the card (default 32px)
|
|
26
|
+
* theme.spacing.sectionGap — vertical gap between sections (default 24px)
|
|
27
|
+
* theme.spacing.ruleColor — hairline divider color (default #e8e8ec)
|
|
28
|
+
* theme.primaryColor, secondaryColor, accentColor, font — the usual
|
|
29
|
+
*
|
|
30
|
+
* Templates may override theme.spacing per-build for their own look.
|
|
31
|
+
*/
|
|
32
|
+
const MarkdownIt = require('markdown-it');
|
|
33
|
+
|
|
34
|
+
const md = new MarkdownIt({ html: false, breaks: true, linkify: true });
|
|
35
|
+
|
|
36
|
+
// ---------- Default tokens ----------
|
|
37
|
+
|
|
38
|
+
const DEFAULT_SPACING = {
|
|
39
|
+
gutter: '32px',
|
|
40
|
+
sectionGap: '24px',
|
|
41
|
+
ruleColor: '#e8e8ec',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// ---------- HTML / markdown helpers ----------
|
|
45
|
+
|
|
46
|
+
function markdownToHtml(markdown) {
|
|
47
|
+
if (!markdown) {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return md.render(markdown).replace(/\n+$/, '');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escape(s) {
|
|
55
|
+
return String(s || '')
|
|
56
|
+
.replace(/&/g, '&')
|
|
57
|
+
.replace(/</g, '<')
|
|
58
|
+
.replace(/>/g, '>')
|
|
59
|
+
.replace(/"/g, '"');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render markdown then strip the surrounding <p> tags so it can be dropped
|
|
64
|
+
* inside another paragraph-styled element (e.g. <mj-text> already has
|
|
65
|
+
* paragraph styling).
|
|
66
|
+
*/
|
|
67
|
+
function inlineMarkdown(markdown) {
|
|
68
|
+
return markdownToHtml(markdown)
|
|
69
|
+
.replace(/^<p>/, '')
|
|
70
|
+
.replace(/<\/p>$/, '');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------- Address formatting (CAN-SPAM) ----------
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Format a structured address object into a single comma-separated line.
|
|
77
|
+
* Accepts either a string (returned as-is) or an object:
|
|
78
|
+
* { line1, line2?, city, region?, postalCode?, country }
|
|
79
|
+
*
|
|
80
|
+
* Returns '' for falsy/empty input. All fields except line1 are optional —
|
|
81
|
+
* the formatter just omits missing pieces.
|
|
82
|
+
*/
|
|
83
|
+
function formatAddress(address) {
|
|
84
|
+
if (!address) {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof address === 'string') {
|
|
89
|
+
return address;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const parts = [];
|
|
93
|
+
|
|
94
|
+
if (address.line1) parts.push(address.line1);
|
|
95
|
+
if (address.line2) parts.push(address.line2);
|
|
96
|
+
|
|
97
|
+
// City + region + postal go together: "Redondo Beach, CA 90278"
|
|
98
|
+
const cityLine = [
|
|
99
|
+
address.city,
|
|
100
|
+
[address.region, address.postalCode].filter(Boolean).join(' '),
|
|
101
|
+
].filter(Boolean).join(', ');
|
|
102
|
+
|
|
103
|
+
if (cityLine) parts.push(cityLine);
|
|
104
|
+
if (address.country) parts.push(address.country);
|
|
105
|
+
|
|
106
|
+
return parts.join(', ');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------- Theme resolution ----------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Merge a template's theme with the default spacing tokens. Templates can
|
|
113
|
+
* override per-build (e.g. editorial uses a 48px gutter).
|
|
114
|
+
*/
|
|
115
|
+
function resolveTheme(theme, overrides) {
|
|
116
|
+
return {
|
|
117
|
+
...theme,
|
|
118
|
+
spacing: {
|
|
119
|
+
...DEFAULT_SPACING,
|
|
120
|
+
...(theme?.spacing || {}),
|
|
121
|
+
...(overrides || {}),
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------- Top-level shell (opinionated envelope) ----------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Render the complete <mjml> document. Always includes the cross-cutting
|
|
130
|
+
* concerns (sponsorships, citations, footer) — templates can't forget them.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} envelope - data common to every newsletter
|
|
133
|
+
* @param {object} envelope.structure - {subject, preheader, citations}
|
|
134
|
+
* @param {object} envelope.theme
|
|
135
|
+
* @param {string} envelope.brandName
|
|
136
|
+
* @param {string} envelope.brandUrl
|
|
137
|
+
* @param {string} envelope.brandAddress - formatted address string (or empty)
|
|
138
|
+
* @param {Array} envelope.sponsorships
|
|
139
|
+
* @param {Date} [envelope.now]
|
|
140
|
+
*
|
|
141
|
+
* @param {object} slots - template-provided content
|
|
142
|
+
* @param {string} [slots.header] - brand header / masthead HTML
|
|
143
|
+
* @param {string} [slots.hero] - optional cover headline / preamble HTML
|
|
144
|
+
* @param {string} [slots.body] - section content HTML (the main editorial)
|
|
145
|
+
* @param {string} [slots.signoff] - closing sign-off HTML
|
|
146
|
+
*
|
|
147
|
+
* @param {object} [config] - template-specific shell configuration
|
|
148
|
+
* @param {string} [config.width] — body width (default 600px)
|
|
149
|
+
* @param {string} [config.extraAttributes] — extra <mj-attributes>
|
|
150
|
+
* @param {string} [config.extraStyles] — extra CSS for <mj-style>
|
|
151
|
+
* @param {object} [config.sponsorshipStyle] — passed to sponsorship rendering
|
|
152
|
+
* @param {object} [config.citationsStyle] — passed to citations rendering
|
|
153
|
+
* @param {object} [config.footerStyle] — passed to footer rendering
|
|
154
|
+
*/
|
|
155
|
+
function shell(envelope, slots, config) {
|
|
156
|
+
const { structure, theme, brandName, brandUrl, brandAddress, sponsorships, now } = envelope;
|
|
157
|
+
const { header = '', hero = '', body = '', signoff = '' } = slots || {};
|
|
158
|
+
const cfg = config || {};
|
|
159
|
+
|
|
160
|
+
const sponsorshipStyle = cfg.sponsorshipStyle || {};
|
|
161
|
+
const citationsStyle = cfg.citationsStyle || {};
|
|
162
|
+
const footerStyle = cfg.footerStyle || {};
|
|
163
|
+
|
|
164
|
+
// The shell appends every cross-cutting concern in a fixed order.
|
|
165
|
+
// Templates cannot skip these — they ALWAYS render if there's content for them.
|
|
166
|
+
const compose = [
|
|
167
|
+
header,
|
|
168
|
+
hero,
|
|
169
|
+
sponsorshipsAt({ sponsorships, position: 'top', theme, ...sponsorshipStyle }),
|
|
170
|
+
body,
|
|
171
|
+
sponsorshipsAt({ sponsorships, position: 'end', theme, ...sponsorshipStyle }),
|
|
172
|
+
signoff,
|
|
173
|
+
citationsBlock({ citations: structure.citations, theme, ...citationsStyle }),
|
|
174
|
+
footerBlock({ brandName, brandUrl, theme, address: brandAddress, ...footerStyle }),
|
|
175
|
+
].filter(Boolean).join('\n');
|
|
176
|
+
|
|
177
|
+
return `<mjml>
|
|
178
|
+
<mj-head>
|
|
179
|
+
<mj-title>${escape(structure.subject || brandName)}</mj-title>
|
|
180
|
+
<mj-preview>${escape(structure.preheader || '')}</mj-preview>
|
|
181
|
+
<mj-attributes>
|
|
182
|
+
<mj-all font-family="${theme.font}" />
|
|
183
|
+
<mj-text font-size="16px" line-height="1.6" color="${theme.secondaryColor}" />
|
|
184
|
+
<mj-button background-color="${theme.primaryColor}" color="#ffffff" border-radius="6px" font-weight="600" inner-padding="14px 24px" padding="0" />
|
|
185
|
+
<mj-section padding="0" />
|
|
186
|
+
${cfg.extraAttributes || ''}
|
|
187
|
+
</mj-attributes>
|
|
188
|
+
<mj-style>
|
|
189
|
+
h1, h2, h3 { color: ${theme.secondaryColor}; margin: 0 0 12px; }
|
|
190
|
+
h2 { font-size: 22px; line-height: 1.3; }
|
|
191
|
+
a { color: ${theme.primaryColor}; }
|
|
192
|
+
p { margin: 0 0 12px; }
|
|
193
|
+
${cfg.extraStyles || ''}
|
|
194
|
+
</mj-style>
|
|
195
|
+
</mj-head>
|
|
196
|
+
<mj-body background-color="${theme.accentColor}" width="${cfg.width || '600px'}">
|
|
197
|
+
${compose}
|
|
198
|
+
</mj-body>
|
|
199
|
+
</mjml>`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------- Section primitives ----------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A raw <mj-section> with a single column. The most common shape — used
|
|
206
|
+
* for text-only blocks (intro, signoff, footer).
|
|
207
|
+
*/
|
|
208
|
+
function singleColumnSection({ background, padding, content }) {
|
|
209
|
+
return `
|
|
210
|
+
<mj-section background-color="${background || '#ffffff'}" padding="${padding || '24px 32px'}">
|
|
211
|
+
<mj-column>
|
|
212
|
+
${content}
|
|
213
|
+
</mj-column>
|
|
214
|
+
</mj-section>`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A <mj-section> with two side-by-side columns inside an <mj-group>. Used for
|
|
219
|
+
* layouts like image+text, image+numeral, etc. Pass each column's content as
|
|
220
|
+
* a complete `<mj-column>...</mj-column>` string, or use the `column()` helper.
|
|
221
|
+
*/
|
|
222
|
+
function twoColumnSection({ background, padding, left, right }) {
|
|
223
|
+
return `
|
|
224
|
+
<mj-section background-color="${background || '#ffffff'}" padding="${padding || '0 32px'}">
|
|
225
|
+
<mj-group>
|
|
226
|
+
${left}
|
|
227
|
+
${right}
|
|
228
|
+
</mj-group>
|
|
229
|
+
</mj-section>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build a single <mj-column> with the given width + content.
|
|
234
|
+
*/
|
|
235
|
+
function column({ width, verticalAlign, content }) {
|
|
236
|
+
return `<mj-column width="${width || '50%'}" vertical-align="${verticalAlign || 'middle'}">
|
|
237
|
+
${content}
|
|
238
|
+
</mj-column>`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Plain text block. Markdown rendered. Optional inline style passthrough.
|
|
243
|
+
*/
|
|
244
|
+
function textBlock({ background, padding, markdown, html }) {
|
|
245
|
+
const inner = html != null ? html : markdownToHtml(markdown || '');
|
|
246
|
+
return singleColumnSection({
|
|
247
|
+
background,
|
|
248
|
+
padding,
|
|
249
|
+
content: `<mj-text>${inner}</mj-text>`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Hairline divider. Defaults to a faint grey rule on white.
|
|
255
|
+
*/
|
|
256
|
+
function dividerBlock({ background, padding, color, width }) {
|
|
257
|
+
return singleColumnSection({
|
|
258
|
+
background,
|
|
259
|
+
padding,
|
|
260
|
+
content: `<mj-divider border-color="${color || DEFAULT_SPACING.ruleColor}" border-width="${width || '1px'}" padding="0" />`,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Standard CTA button.
|
|
266
|
+
*/
|
|
267
|
+
function ctaBlock({ cta, padding, background, align }) {
|
|
268
|
+
if (!cta?.label || !cta?.url) {
|
|
269
|
+
return '';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return singleColumnSection({
|
|
273
|
+
background,
|
|
274
|
+
padding: padding || '16px 32px 0 32px',
|
|
275
|
+
content: `<mj-button href="${escape(cta.url)}"${align ? ` align="${align}"` : ''}>${escape(cta.label)}</mj-button>`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* A standalone image, full-width within its section.
|
|
281
|
+
*/
|
|
282
|
+
function imageBlock({ src, alt, padding, background, borderRadius }) {
|
|
283
|
+
if (!src) {
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return singleColumnSection({
|
|
288
|
+
background,
|
|
289
|
+
padding: padding || '0',
|
|
290
|
+
content: `<mj-image src="${escape(src)}" alt="${escape(alt || '')}" padding="0"${borderRadius ? ` border-radius="${borderRadius}"` : ''} />`,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---------- Whole-newsletter building blocks ----------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Standard brand wordmark header. Brand name linked to brand URL, primary color.
|
|
298
|
+
*/
|
|
299
|
+
function brandHeader({ brandName, brandUrl, theme, padding, background }) {
|
|
300
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
301
|
+
return singleColumnSection({
|
|
302
|
+
background: background || '#ffffff',
|
|
303
|
+
padding: padding || `32px ${gutter} 16px ${gutter}`,
|
|
304
|
+
content: `<mj-text font-size="20px" font-weight="700" color="${theme.primaryColor}">
|
|
305
|
+
<a href="${brandUrl}" style="color: ${theme.primaryColor}; text-decoration: none;">${escape(brandName)}</a>
|
|
306
|
+
</mj-text>`,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Intro paragraph block. Pass a `decorate` function for templates that want
|
|
312
|
+
* special treatment (e.g. drop-caps).
|
|
313
|
+
*/
|
|
314
|
+
function introBlock({ intro, theme, padding, background, decorate }) {
|
|
315
|
+
if (!intro) {
|
|
316
|
+
return '';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
320
|
+
const html = decorate ? decorate(intro, theme) : markdownToHtml(intro);
|
|
321
|
+
return textBlock({
|
|
322
|
+
background: background || '#ffffff',
|
|
323
|
+
padding: padding || `0 ${gutter} 24px ${gutter}`,
|
|
324
|
+
html,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Default signoff block — simple italic line on white. Templates can override
|
|
330
|
+
* by providing their own slots.signoff to shell().
|
|
331
|
+
*/
|
|
332
|
+
function signoffBlock({ signoff, theme, padding, background }) {
|
|
333
|
+
if (!signoff) {
|
|
334
|
+
return '';
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
338
|
+
const html = escape(signoff).replace(/\n/g, '<br/>');
|
|
339
|
+
|
|
340
|
+
return singleColumnSection({
|
|
341
|
+
background: background || '#ffffff',
|
|
342
|
+
padding: padding || `32px ${gutter} 8px ${gutter}`,
|
|
343
|
+
content: `<mj-text padding="0"><div style="color: ${theme.secondaryColor};">${html}</div></mj-text>`,
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Standard footer. "You're receiving this..." + brand link + physical postal
|
|
349
|
+
* address (CAN-SPAM compliance — the address is the load-bearing requirement;
|
|
350
|
+
* the unsubscribe mechanism is provided by the sending platform).
|
|
351
|
+
*
|
|
352
|
+
* Unsubscribe handling: Beehiiv automatically appends its own CAN-SPAM-compliant
|
|
353
|
+
* footer (with a working unsubscribe link tied to the subscriber's record) on
|
|
354
|
+
* every email it sends. SendGrid does the same via list-unsubscribe headers
|
|
355
|
+
* and template-level unsubscribe blocks. We do NOT render our own unsubscribe
|
|
356
|
+
* link here because:
|
|
357
|
+
* 1. It would point to ${brandUrl}/unsubscribe, which isn't wired to anything.
|
|
358
|
+
* 2. Two unsubscribe links in the same email (ours + the platform's) is
|
|
359
|
+
* confusing and worse for compliance signal.
|
|
360
|
+
*
|
|
361
|
+
* Always renders. The shell calls this unconditionally.
|
|
362
|
+
*/
|
|
363
|
+
function footerBlock({ brandName, brandUrl, theme, padding, background, address, extraLine, topRule, linkStyle }) {
|
|
364
|
+
const accent = background || theme.accentColor;
|
|
365
|
+
const topRuleHtml = topRule || '';
|
|
366
|
+
const extraLineHtml = extraLine ? `<div style="margin-bottom: 8px;">${escape(extraLine)}</div>` : '';
|
|
367
|
+
const linkExtra = linkStyle ? ` ${linkStyle}` : '';
|
|
368
|
+
const addressHtml = address ? `<br/><div style="margin-top: 10px; color: #aaa;">${escape(address)}</div>` : '';
|
|
369
|
+
|
|
370
|
+
return `
|
|
371
|
+
<mj-section background-color="${accent}" padding="${padding || '16px 32px 32px 32px'}">
|
|
372
|
+
<mj-column>
|
|
373
|
+
<mj-text font-size="12px" color="#888888" align="center">
|
|
374
|
+
${topRuleHtml}${extraLineHtml}You're receiving this because you subscribed to <a href="${brandUrl}" style="color: #888888;${linkExtra}">${escape(brandName)}</a>.
|
|
375
|
+
${addressHtml}
|
|
376
|
+
</mj-text>
|
|
377
|
+
</mj-column>
|
|
378
|
+
</mj-section>`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Citations footnote block — renders an array of { note, source } as a
|
|
383
|
+
* small numbered footer block at the bottom of the newsletter.
|
|
384
|
+
* Renders nothing if citations is empty/missing. The shell calls this
|
|
385
|
+
* unconditionally; the block self-suppresses when empty.
|
|
386
|
+
*/
|
|
387
|
+
function citationsBlock({ citations, theme, padding, background }) {
|
|
388
|
+
if (!Array.isArray(citations) || !citations.length) {
|
|
389
|
+
return '';
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
393
|
+
|
|
394
|
+
const items = citations.map((c, i) => `
|
|
395
|
+
<div style="margin-bottom: 8px;">
|
|
396
|
+
<span style="font-weight: 600; color: ${theme.primaryColor};">[${i + 1}]</span>
|
|
397
|
+
<span style="color: ${theme.secondaryColor};"> ${escape(c.note)}</span>
|
|
398
|
+
<span style="color: #888888;"> — ${escape(c.source)}</span>
|
|
399
|
+
</div>`).join('');
|
|
400
|
+
|
|
401
|
+
return singleColumnSection({
|
|
402
|
+
background: background || '#ffffff',
|
|
403
|
+
padding: padding || `24px ${gutter} 24px ${gutter}`,
|
|
404
|
+
content: `<mj-text font-size="12px" line-height="1.5">
|
|
405
|
+
<div style="font-size: 11px; letter-spacing: 4px; text-transform: uppercase; font-weight: 700; color: ${theme.primaryColor}; margin-bottom: 12px;">Sources & data</div>
|
|
406
|
+
${items}
|
|
407
|
+
</mj-text>`,
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Sponsorship block — renders a single sponsorship promo blended into the
|
|
413
|
+
* surrounding white card (no hard color break), with hairline rules above
|
|
414
|
+
* and below to mark it as distinct content.
|
|
415
|
+
*/
|
|
416
|
+
function sponsorshipBlock({ sponsorship, theme, padding, background, label, withRules }) {
|
|
417
|
+
if (!sponsorship?.url) {
|
|
418
|
+
return '';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const surface = background || '#ffffff';
|
|
422
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
423
|
+
const ruleColor = theme?.spacing?.ruleColor || DEFAULT_SPACING.ruleColor;
|
|
424
|
+
const resolvedPadding = padding || `20px ${gutter} 20px ${gutter}`;
|
|
425
|
+
|
|
426
|
+
const eyebrowText = sponsorship.eyebrowText || label || 'Sponsored';
|
|
427
|
+
const image = sponsorship.image ? `
|
|
428
|
+
<mj-image src="${escape(sponsorship.image)}" alt="${escape(sponsorship.headline || sponsorship.label || 'Sponsor')}" padding="0 0 12px 0" />` : '';
|
|
429
|
+
const headline = sponsorship.headline ? `<h3 style="font-size: 18px; margin: 0 0 6px;">${escape(sponsorship.headline)}</h3>` : '';
|
|
430
|
+
const body = sponsorship.body ? `<p style="font-size: 14px; color: #555; margin: 0;">${escape(sponsorship.body)}</p>` : '';
|
|
431
|
+
const cta = sponsorship.ctaLabel || 'Learn more';
|
|
432
|
+
|
|
433
|
+
const card = singleColumnSection({
|
|
434
|
+
background: surface,
|
|
435
|
+
padding: resolvedPadding,
|
|
436
|
+
content: `${image}<mj-text padding="0">
|
|
437
|
+
<div style="font-size: 11px; letter-spacing: 4px; text-transform: uppercase; font-weight: 700; color: ${theme.primaryColor}; margin-bottom: 10px;">${escape(eyebrowText)}</div>
|
|
438
|
+
${headline}
|
|
439
|
+
${body}
|
|
440
|
+
</mj-text>
|
|
441
|
+
<mj-button href="${escape(sponsorship.url)}" align="left" padding="14px 0 0 0">${escape(cta)}</mj-button>`,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
if (withRules === false) {
|
|
445
|
+
return card;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Extract horizontal padding so dividers align with the card's gutter
|
|
449
|
+
const paddingParts = resolvedPadding.split(/\s+/);
|
|
450
|
+
const horizontalPadding = paddingParts.length >= 4
|
|
451
|
+
? `0 ${paddingParts[1]} 0 ${paddingParts[3]}`
|
|
452
|
+
: `0 ${paddingParts[1] || gutter}`;
|
|
453
|
+
|
|
454
|
+
const rule = dividerBlock({ background: surface, padding: horizontalPadding, color: ruleColor });
|
|
455
|
+
|
|
456
|
+
return `${rule}\n${card}\n${rule}`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Render all sponsorships at a given position. Position matches the
|
|
461
|
+
* sponsorship's `position` field — 'top', 'middle', or 'end'.
|
|
462
|
+
* Sponsorships without an explicit position default to 'middle'.
|
|
463
|
+
*
|
|
464
|
+
* Note: 'middle' sponsorships are NOT rendered automatically by the shell —
|
|
465
|
+
* templates that want them interleaved between sections must call this
|
|
466
|
+
* themselves. 'top' and 'end' are always handled by the shell.
|
|
467
|
+
*/
|
|
468
|
+
function sponsorshipsAt({ sponsorships, position, theme, padding, background, label, withRules }) {
|
|
469
|
+
if (!Array.isArray(sponsorships) || !sponsorships.length) {
|
|
470
|
+
return '';
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const matching = sponsorships.filter((s) => (s.position || 'middle') === position);
|
|
474
|
+
|
|
475
|
+
if (!matching.length) {
|
|
476
|
+
return '';
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return matching
|
|
480
|
+
.map((sponsorship) => sponsorshipBlock({ sponsorship, theme, padding, background, label, withRules }))
|
|
481
|
+
.join('\n');
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* A standard "card" section — image (optional) + title + body + cta inside
|
|
486
|
+
* a single white section. Used by the `clean` template.
|
|
487
|
+
*/
|
|
488
|
+
function sectionCard({ section, imagePath, theme, padding, background, imageBorderRadius }) {
|
|
489
|
+
const gutter = theme?.spacing?.gutter || DEFAULT_SPACING.gutter;
|
|
490
|
+
const imageMjml = imagePath ? `
|
|
491
|
+
<mj-image src="${escape(imagePath)}" alt="${escape(section.title)}" padding="0" border-radius="${imageBorderRadius || '8px 8px 0 0'}" />` : '';
|
|
492
|
+
const ctaMjml = section.cta?.label && section.cta?.url ? `
|
|
493
|
+
<mj-button href="${escape(section.cta.url)}" padding="16px 0 0 0">${escape(section.cta.label)}</mj-button>` : '';
|
|
494
|
+
|
|
495
|
+
return `
|
|
496
|
+
<mj-section background-color="${background || '#ffffff'}" padding="${padding || `24px ${gutter} 8px ${gutter}`}">
|
|
497
|
+
<mj-column>${imageMjml}
|
|
498
|
+
<mj-text padding="20px 0 0 0">
|
|
499
|
+
<h2>${escape(section.title)}</h2>
|
|
500
|
+
${markdownToHtml(section.body || '')}
|
|
501
|
+
</mj-text>${ctaMjml}
|
|
502
|
+
</mj-column>
|
|
503
|
+
</mj-section>`;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
module.exports = {
|
|
507
|
+
// tokens
|
|
508
|
+
DEFAULT_SPACING,
|
|
509
|
+
// helpers
|
|
510
|
+
markdownToHtml,
|
|
511
|
+
escape,
|
|
512
|
+
inlineMarkdown,
|
|
513
|
+
formatAddress,
|
|
514
|
+
resolveTheme,
|
|
515
|
+
// top-level
|
|
516
|
+
shell,
|
|
517
|
+
// primitives
|
|
518
|
+
singleColumnSection,
|
|
519
|
+
twoColumnSection,
|
|
520
|
+
column,
|
|
521
|
+
textBlock,
|
|
522
|
+
dividerBlock,
|
|
523
|
+
ctaBlock,
|
|
524
|
+
imageBlock,
|
|
525
|
+
// composite blocks (templates can use directly OR ignore for custom versions)
|
|
526
|
+
brandHeader,
|
|
527
|
+
introBlock,
|
|
528
|
+
signoffBlock,
|
|
529
|
+
footerBlock,
|
|
530
|
+
citationsBlock,
|
|
531
|
+
sponsorshipBlock,
|
|
532
|
+
sponsorshipsAt,
|
|
533
|
+
sectionCard,
|
|
534
|
+
};
|