backend-manager 5.0.203 → 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.
Files changed (69) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CLAUDE.md +43 -1501
  3. package/TODO-CHARGEBLAST.md +32 -0
  4. package/TODO-email-auth.md +14 -0
  5. package/docs/admin-post-route.md +24 -0
  6. package/docs/ai-library.md +23 -0
  7. package/docs/architecture.md +31 -0
  8. package/docs/auth-hooks.md +74 -0
  9. package/docs/cli-firestore-auth.md +59 -0
  10. package/docs/cli-logs.md +67 -0
  11. package/docs/code-patterns.md +67 -0
  12. package/docs/common-operations.md +64 -0
  13. package/docs/directory-structure.md +119 -0
  14. package/docs/environment-detection.md +7 -0
  15. package/docs/file-naming.md +11 -0
  16. package/docs/marketing-campaigns.md +244 -0
  17. package/docs/marketing-fields.md +25 -0
  18. package/docs/mcp.md +95 -0
  19. package/docs/payment-system.md +325 -0
  20. package/docs/response-headers.md +7 -0
  21. package/docs/routes.md +126 -0
  22. package/docs/sanitization.md +61 -0
  23. package/docs/schemas.md +39 -0
  24. package/docs/stripe-webhook-forwarding.md +18 -0
  25. package/docs/testing.md +129 -0
  26. package/docs/usage-rate-limiting.md +67 -0
  27. package/package.json +8 -4
  28. package/src/defaults/CHANGELOG.md +15 -0
  29. package/src/defaults/CLAUDE.md +8 -4
  30. package/src/defaults/docs/README.md +17 -0
  31. package/src/defaults/test/README.md +33 -0
  32. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  33. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  34. package/src/manager/helpers/utilities.js +21 -0
  35. package/src/manager/index.js +1 -1
  36. package/src/manager/libraries/ai/index.js +162 -0
  37. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  38. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  39. package/src/manager/libraries/ai/providers/openai.js +934 -0
  40. package/src/manager/libraries/disposable-domains.json +2 -0
  41. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  42. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  43. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  44. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  45. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  46. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  47. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  48. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  49. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  50. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  51. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  52. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  53. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  54. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  55. package/src/manager/libraries/email/marketing/index.js +5 -2
  56. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  57. package/src/manager/libraries/openai.js +13 -932
  58. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  59. package/src/manager/routes/admin/post/post.js +10 -17
  60. package/templates/_.env +4 -0
  61. package/templates/_.gitignore +1 -0
  62. package/templates/backend-manager-config.json +48 -4
  63. package/test/helpers/slugify.js +394 -0
  64. package/test/marketing/fixtures/clean.json +31 -0
  65. package/test/marketing/fixtures/editorial.json +31 -0
  66. package/test/marketing/fixtures/field-report.json +54 -0
  67. package/test/marketing/newsletter-generate.js +731 -0
  68. package/test/marketing/newsletter-templates.js +512 -0
  69. 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, '&lt;')
58
+ .replace(/>/g, '&gt;')
59
+ .replace(/"/g, '&quot;');
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 &amp; 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
+ };