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,497 @@
1
+ /**
2
+ * `field-report` template — wire-service correspondent × Bloomberg terminal.
3
+ *
4
+ * Each issue reads like a foreign correspondent's filing: an issue volume
5
+ * strap, a TLDR terminal block under the masthead, then a series of
6
+ * "dispatches" — each with a kicker, headline, byline, dateline, lede,
7
+ * dispatch body, optional terminal-style data callout, and a fixed
8
+ * "— END DISPATCH —" terminator.
9
+ *
10
+ * Aesthetic anchors (constant across brands, only ink color flexes):
11
+ * - Ivory paper background (off-white)
12
+ * - Black/oxblood ink for body text + serif headlines
13
+ * - Mono kickers and bylines (uppercase, tracked)
14
+ * - Terminal-style data blocks: black bg, phosphor green on black, red labels
15
+ * - Right-aligned correspondent's signoff ("— The X Desk")
16
+ *
17
+ * Content schema (template-owned — different from the classic schema):
18
+ * { tldr, dateline, dispatches: [{ kicker, headline, byline, location,
19
+ * lede, dispatch, dataPoints?, cta?, image_prompt }] }
20
+ */
21
+ const {
22
+ shell,
23
+ resolveTheme,
24
+ escape,
25
+ markdownToHtml,
26
+ inlineMarkdown,
27
+ singleColumnSection,
28
+ twoColumnSection,
29
+ column,
30
+ dividerBlock,
31
+ sponsorshipsAt,
32
+ } = require('./shared.js');
33
+
34
+ const {
35
+ SERIF_FONT,
36
+ MONO_FONT,
37
+ TERMINAL,
38
+ DEFAULT_INK,
39
+ kicker,
40
+ issueStrap,
41
+ dispatchDateline,
42
+ dataCallout,
43
+ tldrStrip,
44
+ dispatchTerminator,
45
+ } = require('./field-report-helpers.js');
46
+
47
+ const { computeIssueNumber } = require('./editorial-helpers.js');
48
+
49
+ // Field-report-specific paper background — slight ivory tint, never pure white.
50
+ const PAPER = '#f7f3ec';
51
+ const PAPER_DEEP = '#efe9dc'; // for subtle bands / footer band
52
+
53
+ const SPACING_OVERRIDES = {
54
+ gutter: '40px',
55
+ sectionGap: '0px',
56
+ ruleColor: '#1a1a1a', // ink rule, not the default light grey
57
+ };
58
+
59
+ function build({ structure, imagePaths, theme: themeIn, brandName, brandUrl, brandAddress, now, sponsorships }) {
60
+ const theme = resolveTheme(themeIn, SPACING_OVERRIDES);
61
+ const gutter = theme.spacing.gutter;
62
+ const inkColor = theme.primaryColor || DEFAULT_INK;
63
+ const dispatches = Array.isArray(structure.dispatches) ? structure.dispatches : [];
64
+
65
+ // Build each dispatch block (no leading/trailing divider — orchestrator joins them).
66
+ // Empty dispatches return '' which we filter so the inter-dispatch divider
67
+ // doesn't end up double-stacking.
68
+ const dispatchBlocks = dispatches.map((dispatch, i) =>
69
+ fieldReportDispatch({
70
+ dispatch,
71
+ imagePath: imagePaths?.[i],
72
+ theme,
73
+ inkColor,
74
+ brandName,
75
+ now,
76
+ index: i,
77
+ total: dispatches.length,
78
+ })
79
+ ).filter(Boolean);
80
+
81
+ // Middle sponsorships: insert at midpoint, without their own hairlines —
82
+ // the inter-dispatch divider handles separation.
83
+ const middleSponsorships = sponsorshipsAt({
84
+ sponsorships,
85
+ position: 'middle',
86
+ theme,
87
+ padding: `28px ${gutter} 28px ${gutter}`,
88
+ background: PAPER,
89
+ label: 'Underwritten by',
90
+ withRules: false,
91
+ });
92
+
93
+ if (middleSponsorships) {
94
+ const middleIndex = Math.floor(dispatchBlocks.length / 2);
95
+ dispatchBlocks.splice(middleIndex, 0, middleSponsorships);
96
+ }
97
+
98
+ // Inter-dispatch divider — a wire-service "double rule" (two thin ink lines
99
+ // with a small gap between them, evoking the typographic break that
100
+ // separates filings in print wire reports). Lighter than a heavy 2px slab,
101
+ // but more editorial-feeling than a single hairline.
102
+ const interDispatchDivider = singleColumnSection({
103
+ background: PAPER,
104
+ padding: `40px ${gutter} 40px ${gutter}`,
105
+ content: `<mj-text padding="0"><div style="border-top: 1px solid ${inkColor}; border-bottom: 1px solid ${inkColor}; height: 4px; line-height: 0; font-size: 0;">&nbsp;</div></mj-text>`,
106
+ });
107
+
108
+ const composedBody = dispatchBlocks.join(`\n${interDispatchDivider}\n`);
109
+
110
+ // Envelope for the shell
111
+ const envelope = {
112
+ structure,
113
+ theme,
114
+ brandName,
115
+ brandUrl,
116
+ brandAddress,
117
+ sponsorships,
118
+ now,
119
+ };
120
+
121
+ const slots = {
122
+ header: masthead({ brandName, brandUrl, theme, inkColor, now, dateline: structure.dateline, tagline: structure.preheader, gutter }),
123
+ hero: tldrSection({ tldr: structure.tldr, gutter }),
124
+ body: composedBody,
125
+ signoff: correspondentSignoff({ signoff: structure.signoff, theme, inkColor, brandName, gutter }),
126
+ };
127
+
128
+ const config = {
129
+ width: '660px',
130
+ extraAttributes: `<mj-text font-family="${SERIF_FONT}" font-size="17px" line-height="1.65" color="#1a1a1a" />
131
+ <mj-button background-color="transparent" color="${inkColor}" border-radius="0" font-weight="700" font-size="11px" letter-spacing="3px" inner-padding="14px 22px" text-transform="uppercase" padding="0" font-family="${MONO_FONT}" />`,
132
+ extraStyles: fieldReportStyles({ inkColor }),
133
+ sponsorshipStyle: {
134
+ padding: `28px ${gutter} 28px ${gutter}`,
135
+ background: PAPER,
136
+ label: 'Underwritten by',
137
+ },
138
+ citationsStyle: {
139
+ padding: `32px ${gutter} 32px ${gutter}`,
140
+ background: PAPER,
141
+ },
142
+ footerStyle: {
143
+ padding: `36px ${gutter} 48px ${gutter}`,
144
+ background: PAPER_DEEP,
145
+ topRule: `<div class="footer-stamp">FILED · ${escape(brandName).toUpperCase()}</div>`,
146
+ extraLine: `VOL. ${computeIssueNumber(now || new Date())}`,
147
+ linkStyle: 'border-bottom: none; font-family: ' + MONO_FONT + ';',
148
+ },
149
+ };
150
+
151
+ return shell(envelope, slots, config);
152
+ }
153
+
154
+ // ---------- Field Report CSS ----------
155
+
156
+ function fieldReportStyles({ inkColor }) {
157
+ return `
158
+ body { background-color: ${PAPER}; }
159
+ h1, h2, h3 { color: #1a1a1a; margin: 0; font-family: ${SERIF_FONT}; font-weight: 700; letter-spacing: -0.015em; }
160
+ h1 { font-size: 56px; line-height: 1.0; }
161
+ h2 { font-size: 38px; line-height: 1.05; }
162
+ h3 { font-size: 14px; letter-spacing: 3px; text-transform: uppercase; font-weight: 700; font-family: ${MONO_FONT}; }
163
+ a { color: ${inkColor}; text-decoration: none; border-bottom: 1px solid ${inkColor}; }
164
+ p { margin: 0 0 16px; }
165
+ .strap { font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; color: rgba(0,0,0,0.55); text-transform: uppercase; }
166
+ .masthead-rule { display: block; width: 100%; height: 3px; background: ${inkColor}; margin: 16px 0 18px; }
167
+ .masthead-name { font-family: ${SERIF_FONT}; font-size: 64px; font-weight: 800; letter-spacing: -0.03em; line-height: 0.95; color: ${inkColor}; text-transform: uppercase; }
168
+ .masthead-tagline { font-family: ${SERIF_FONT}; font-style: italic; font-size: 17px; color: rgba(0,0,0,0.75); line-height: 1.4; margin-top: 10px; }
169
+ .lede { font-family: ${SERIF_FONT}; font-style: italic; font-size: 21px; line-height: 1.5; color: #1a1a1a; }
170
+ .lede::first-letter { font-weight: 700; }
171
+ .byline-row { font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 3px; color: rgba(0,0,0,0.6); text-transform: uppercase; margin-bottom: 4px; }
172
+ .dispatch-body p { font-size: 17px; line-height: 1.7; color: #1a1a1a; margin: 0 0 16px; }
173
+ /* Drop-cap reserved for the LEAD dispatch only — three drop-caps in a
174
+ row reads as a quirk, not a feature. Subsequent dispatches get their
175
+ visual entry point from the kicker + headline + ledes instead. */
176
+ .dispatch-body.lead p:first-of-type::first-letter { font-family: ${SERIF_FONT}; font-weight: 700; font-size: 56px; line-height: 0.9; float: left; padding: 4px 12px 0 0; color: ${inkColor}; }
177
+ .correspondent-signoff { font-family: ${SERIF_FONT}; font-style: italic; font-size: 16px; color: rgba(0,0,0,0.7); text-align: right; }
178
+ .footer-stamp { font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; color: ${inkColor}; margin-bottom: 14px; }
179
+ .end-dispatch { text-align: center; font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 6px; color: ${inkColor}; }`;
180
+ }
181
+
182
+ // ---------- Field Report blocks ----------
183
+
184
+ function masthead({ brandName, brandUrl, theme, inkColor, now, dateline, tagline, gutter }) {
185
+ const strap = issueStrap({ now, dateline });
186
+ return singleColumnSection({
187
+ background: PAPER,
188
+ padding: `48px ${gutter} 36px ${gutter}`,
189
+ content: `<mj-text padding="0">
190
+ <div class="strap">${strap}</div>
191
+ <div class="masthead-rule"></div>
192
+ <div class="masthead-name"><a href="${brandUrl}" style="color: ${inkColor}; text-decoration: none; border-bottom: none;">${escape(brandName)}</a></div>
193
+ ${tagline ? `<div class="masthead-tagline">${escape(tagline)}</div>` : ''}
194
+ </mj-text>`,
195
+ });
196
+ }
197
+
198
+ function tldrSection({ tldr, gutter }) {
199
+ if (!tldr) {
200
+ return '';
201
+ }
202
+
203
+ return singleColumnSection({
204
+ background: PAPER,
205
+ padding: `0 ${gutter} 0 ${gutter}`,
206
+ content: `<mj-text padding="0">${tldrStrip({ tldr, gutter: '0' })}</mj-text>`,
207
+ });
208
+ }
209
+
210
+ function fieldReportDispatch({ dispatch, imagePath, theme, inkColor, brandName, now, index, total }) {
211
+ const gutter = theme.spacing.gutter;
212
+ // Graceful omission: every piece below has a sensible fallback so missing
213
+ // fields render an empty/omitted block rather than breaking the whole dispatch.
214
+ const safeDispatch = dispatch || {};
215
+
216
+ // A dispatch with no content at all renders nothing — caller handles the
217
+ // resulting empty string in the join. Prevents a hollow "Dispatch N" stub.
218
+ if (!safeDispatch.headline && !safeDispatch.dispatch && !safeDispatch.lede) {
219
+ return '';
220
+ }
221
+
222
+ const kickerText = safeDispatch.kicker || (index === 0 ? 'LEAD DISPATCH' : 'DISPATCH');
223
+ const headlineText = safeDispatch.headline || `Dispatch ${index + 1}`;
224
+ const headlineHtml = `<h2>${escape(headlineText)}</h2>`;
225
+ const dateline = dispatchDateline({ now, location: safeDispatch.location });
226
+ const bylineText = safeDispatch.byline || `Filed by The ${brandName || ''} desk`.trim();
227
+ const hasData = Array.isArray(safeDispatch.dataPoints) && safeDispatch.dataPoints.length > 0;
228
+
229
+ // Header row: kicker + headline + byline. Always single-column, full-width.
230
+ const headerRow = singleColumnSection({
231
+ background: PAPER,
232
+ padding: `40px ${gutter} 0 ${gutter}`,
233
+ content: `<mj-text padding="0">
234
+ ${kicker({ text: kickerText, color: inkColor })}
235
+ <div style="height: 12px;"></div>
236
+ ${headlineHtml}
237
+ <div style="height: 16px;"></div>
238
+ <div class="byline-row">${escape(bylineText)} &nbsp;·&nbsp; ${dateline}</div>
239
+ <div style="height: 6px; border-bottom: 1px solid ${inkColor};"></div>
240
+ </mj-text>`,
241
+ });
242
+
243
+ // Image row — full width below the header. No image = skip the row entirely.
244
+ const imageRow = imagePath
245
+ ? singleColumnSection({
246
+ background: PAPER,
247
+ padding: `24px ${gutter} 0 ${gutter}`,
248
+ content: `<mj-image src="${escape(imagePath)}" alt="${escape(headlineText)}" padding="0" border-radius="0" />`,
249
+ })
250
+ : '';
251
+
252
+ // Lede paragraph — italic serif, sets the tone. Omitted entirely when missing.
253
+ const ledeRow = safeDispatch.lede
254
+ ? singleColumnSection({
255
+ background: PAPER,
256
+ padding: `28px ${gutter} 0 ${gutter}`,
257
+ content: `<mj-text padding="0"><div class="lede">${inlineMarkdown(safeDispatch.lede)}</div></mj-text>`,
258
+ })
259
+ : '';
260
+
261
+ // Data callout — always rendered as a FULL-WIDTH terminal strip above the
262
+ // body (when dataPoints are present). Earlier versions tried to columnify
263
+ // body + data side-by-side, but at 660px that cramps both. A full-width
264
+ // strip gives the data more visual punch AND lets body prose use the full
265
+ // reading column underneath.
266
+ const dataRow = hasData
267
+ ? singleColumnSection({
268
+ background: PAPER,
269
+ padding: `28px ${gutter} 0 ${gutter}`,
270
+ content: `<mj-text padding="0">${dataCallout({ dataPoints: safeDispatch.dataPoints, fullWidth: true })}</mj-text>`,
271
+ })
272
+ : '';
273
+
274
+ const bodyHtml = safeDispatch.dispatch ? markdownToHtml(safeDispatch.dispatch) : '';
275
+ // Mark the lead dispatch so CSS can apply the drop-cap only to it (see
276
+ // .dispatch-body.lead in fieldReportStyles).
277
+ const bodyClass = index === 0 ? 'dispatch-body lead' : 'dispatch-body';
278
+ const bodyRow = bodyHtml
279
+ ? singleColumnSection({
280
+ background: PAPER,
281
+ padding: `28px ${gutter} 0 ${gutter}`,
282
+ content: `<mj-text padding="0"><div class="${bodyClass}">${bodyHtml}</div></mj-text>`,
283
+ })
284
+ : '';
285
+
286
+ // CTA — outlined ghost button, mono label, left-aligned. Omitted when missing.
287
+ const ctaRow = safeDispatch.cta?.label && safeDispatch.cta?.url
288
+ ? singleColumnSection({
289
+ background: PAPER,
290
+ padding: `24px ${gutter} 0 ${gutter}`,
291
+ content: `<mj-text padding="0"><div style="display: inline-block; border: 1.5px solid ${inkColor}; padding: 14px 24px;"><a href="${escape(safeDispatch.cta.url)}" style="font-family: ${MONO_FONT}; font-size: 11px; letter-spacing: 3px; font-weight: 700; text-transform: uppercase; color: ${inkColor}; text-decoration: none; border-bottom: none;">${escape(safeDispatch.cta.label)} &nbsp;→</a></div></mj-text>`,
292
+ })
293
+ : '';
294
+
295
+ // Terminator row — small "— END DISPATCH —" mono marker.
296
+ const terminatorRow = singleColumnSection({
297
+ background: PAPER,
298
+ padding: `32px ${gutter} 0 ${gutter}`,
299
+ content: `<mj-text padding="0">${dispatchTerminator({ inkColor })}</mj-text>`,
300
+ });
301
+
302
+ return [headerRow, imageRow, ledeRow, dataRow, bodyRow, ctaRow, terminatorRow]
303
+ .filter(Boolean)
304
+ .join('\n');
305
+ }
306
+
307
+ function correspondentSignoff({ signoff, theme, inkColor, brandName, gutter }) {
308
+ if (!signoff) {
309
+ return '';
310
+ }
311
+
312
+ // Field Report renders the signoff as a right-aligned italic line, like a
313
+ // correspondent signing off a dispatch with their desk name. The classic
314
+ // "Best,\nThe X Team" still works — we render both lines, just italic and
315
+ // right-aligned.
316
+ const html = escape(signoff).replace(/\n/g, '<br/>');
317
+
318
+ return singleColumnSection({
319
+ background: PAPER,
320
+ padding: `48px ${gutter} 16px ${gutter}`,
321
+ content: `<mj-text padding="0"><div class="correspondent-signoff">${html}</div></mj-text>`,
322
+ });
323
+ }
324
+
325
+ // ---------- Schema + AI prompt (template-owned content contract) ----------
326
+
327
+ const FIELD_REPORT_SCHEMA = {
328
+ required: ['tldr', 'dateline', 'dispatches'],
329
+ properties: {
330
+ // Global to the issue
331
+ tldr: { type: 'string', maxLength: 400 }, // 2-sentence executive summary
332
+ dateline: { type: 'string', maxLength: 60 }, // "LOS ANGELES" / "REMOTE" / "NEW YORK"
333
+ dispatches: {
334
+ type: 'array',
335
+ minItems: 2,
336
+ maxItems: 5,
337
+ items: {
338
+ type: 'object',
339
+ additionalProperties: false,
340
+ required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'cta', 'dataPoints'],
341
+ properties: {
342
+ kicker: { type: 'string', maxLength: 30 }, // "DISPATCH" / "FIELD NOTES" / "WATCH" / "BRIEF"
343
+ headline: { type: 'string', maxLength: 90 }, // Tight, declarative
344
+ byline: { type: 'string', maxLength: 60 }, // "Filed by the growth desk"
345
+ location: { type: 'string', maxLength: 30 }, // "REMOTE" / "OAKLAND"
346
+ lede: { type: 'string', maxLength: 220 }, // First-paragraph hook, present tense
347
+ dispatch: { type: 'string' }, // Main body markdown
348
+ dataPoints: {
349
+ type: 'array',
350
+ maxItems: 4,
351
+ items: {
352
+ type: 'object',
353
+ additionalProperties: false,
354
+ required: ['label', 'value'],
355
+ properties: {
356
+ label: { type: 'string', maxLength: 22 }, // "USERS REACHED"
357
+ value: { type: 'string', maxLength: 16 }, // "12.4K" / "+38%" / "$2.1M"
358
+ },
359
+ },
360
+ },
361
+ cta: {
362
+ type: ['object', 'null'],
363
+ additionalProperties: false,
364
+ required: ['label', 'url'],
365
+ properties: {
366
+ label: { type: 'string' },
367
+ url: { type: 'string' },
368
+ },
369
+ },
370
+ image_prompt: { type: 'string' },
371
+ },
372
+ },
373
+ },
374
+ },
375
+ };
376
+
377
+ function normalizeFieldReport(structure, { brand } = {}) {
378
+ structure.tldr = structure.tldr || '';
379
+ structure.dateline = structure.dateline || 'REMOTE';
380
+
381
+ if (!Array.isArray(structure.dispatches)) {
382
+ structure.dispatches = [];
383
+ }
384
+
385
+ structure.dispatches = structure.dispatches.map((d, i) => ({
386
+ kicker: d.kicker || (i === 0 ? 'LEAD DISPATCH' : 'DISPATCH'),
387
+ headline: d.headline || `Dispatch ${i + 1}`,
388
+ byline: d.byline || `Filed by The ${brand?.name || 'editorial'} desk`,
389
+ location: d.location || 'REMOTE',
390
+ lede: d.lede || '',
391
+ dispatch: d.dispatch || '',
392
+ dataPoints: Array.isArray(d.dataPoints) ? d.dataPoints.slice(0, 4) : [],
393
+ cta: d.cta || null,
394
+ image_prompt: d.image_prompt || '',
395
+ }));
396
+
397
+ // Map dispatches -> sections so svg-illustrator (which iterates
398
+ // structure.sections) keeps working unchanged. Each section is the
399
+ // dispatch's image_prompt + a fallback title for alt text.
400
+ structure.sections = structure.dispatches.map((d) => ({
401
+ title: d.headline,
402
+ image_prompt: d.image_prompt,
403
+ }));
404
+ }
405
+
406
+ function buildPrompt({ brand, newsletterConfig, sources }) {
407
+ const tone = newsletterConfig?.tone || 'present-tense, observational, terse';
408
+ const instructions = newsletterConfig?.instructions || '';
409
+ const taglineLine = brand?.tagline ? `\nTagline: ${brand.tagline}` : '';
410
+ const descriptionLine = brand?.description ? `\nDescription: ${brand.description}` : '';
411
+ const brandName = brand?.name || 'the brand';
412
+
413
+ const system = [
414
+ `You are the editor of a wire-service-style newsletter for ${brandName}.${taglineLine}${descriptionLine}`,
415
+ instructions ? `\nBrand instructions:\n${instructions}` : '',
416
+ '',
417
+ `Tone: ${tone}`,
418
+ '',
419
+ 'STYLE — write like a foreign correspondent filing a dispatch:',
420
+ '- Present tense. Observational. Terse, declarative sentences.',
421
+ '- Avoid marketing language ("game-changer", "level up", "unlock", "secrets").',
422
+ '- Avoid throat-clearing transitions ("In today\'s digital landscape...", "It\'s no secret that...").',
423
+ '- Avoid second-person hype ("YOU need to know this!", "What this means for YOU").',
424
+ '- Specifics over generalities. Name actors (LinkedIn, Apple, etc.), numbers, places.',
425
+ '- No emojis. No exclamation points. No "guru" language.',
426
+ '',
427
+ 'ATTRIBUTION RULES (CRITICAL):',
428
+ '- NEVER name the source publication, newsletter, blog, or author in the dispatch body.',
429
+ ' (Do NOT write "according to Morning Brew", "as reported by Forbes", etc.)',
430
+ '- Treat sources as background research, not publications you are quoting.',
431
+ '- Write each dispatch AS IF you reported it yourself — first-party voice.',
432
+ '- Naming third-party PLATFORMS, products, or companies in the news (LinkedIn, YouTube, etc.) is fine.',
433
+ '',
434
+ 'CITATIONS:',
435
+ '- If you reference specific numbers, percentages, or direct quotes, add an entry to the `citations` array.',
436
+ '- citations[].source must be a neutral attribution ("Per company beta data", "Industry research, Q2 2026") — never the source publication name.',
437
+ '- Citations render as small footnotes at the bottom of the issue. If nothing is worth citing, return an empty array.',
438
+ '',
439
+ 'CONTENT REQUIREMENTS:',
440
+ '- subject: ≤60 chars, declarative, no clickbait. Reads like a wire-service headline.',
441
+ '- preheader: ≤100 chars, complements subject.',
442
+ '- tldr: 2 short sentences max, ~200 chars total. Present tense. Reads like a terminal briefing — what changed, why it matters.',
443
+ '- dateline: one city or "REMOTE" — sets where the issue is filed from. UPPERCASE. Example: "LOS ANGELES" / "REMOTE" / "NEW YORK".',
444
+ '- dispatches: 3-5 items, each is a discrete filed story.',
445
+ ' - kicker: a single uppercase mono label like "DISPATCH", "FIELD NOTES", "WATCH", "BRIEF", "READOUT", "BULLETIN". Pick the best fit.',
446
+ ' - headline: tight, declarative, ≤90 chars. NOT a question. NOT a list. NOT clickbait.',
447
+ ` - byline: short attribution line like "Filed by The ${brandName} growth desk" or "Filed by The ${brandName} platform desk". Be specific to the topic.`,
448
+ ' - location: one of "REMOTE" / "NEW YORK" / "SAN FRANCISCO" / "LONDON" / "OAKLAND" / "AUSTIN" / etc. UPPERCASE. Match the subject when plausible.',
449
+ ' - lede: one paragraph (1-2 sentences), italic-serif quality, sets the scene in present tense. Reads like the opening of a New Yorker article.',
450
+ ' - dispatch: the body. 90-160 words. Markdown allowed. Present tense. Specific. End with the practical implication for the reader.',
451
+ ' - dataPoints: 2-4 short label/value pairs IF there are meaningful numbers in the topic. Example: [{label:"USERS REACHED",value:"12.4K"},{label:"WoW GROWTH",value:"+38%"}]. SKIP this (empty array) if the topic has no quantifiable data.',
452
+ ' - cta: { label, url } OR null. Label is mono, uppercase, short (≤24 chars). Examples: "READ THE BRIEF", "SEE THE NUMBERS".',
453
+ ' - image_prompt: one-sentence visual brief for an illustrator. Specific. Think editorial illustration, not stock photo.',
454
+ `- signoff: render as TWO LINES with a literal \\n between them. Format: a short closing phrase + the desk name. Examples:\n "— Stay sharp,\\nThe ${brandName} Desk"\n "— Until next dispatch,\\nThe ${brandName} Editorial Desk"\n Do NOT write a summary, motto, or thematic sentence. This is a literal sign-off.`,
455
+ '',
456
+ 'OUTPUT:',
457
+ '- Respond with valid JSON only. No markdown fences. No preamble.',
458
+ ].filter(Boolean).join('\n');
459
+
460
+ const summaries = (sources || [])
461
+ .map((s, i) => {
462
+ const raw = s.source || {};
463
+ const headline = s.ai?.headline || raw.subject || s.subject || `Topic ${i + 1}`;
464
+ const summary = s.ai?.summary || '';
465
+ const takeaways = (s.ai?.takeaways || []).join('; ');
466
+ const rawContent = !summary && raw.content
467
+ ? raw.content.slice(0, 1500)
468
+ : '';
469
+
470
+ return [
471
+ `[Research ${i + 1}]`,
472
+ `Topic: ${headline}`,
473
+ summary ? `Summary: ${summary}` : '',
474
+ takeaways ? `Key takeaways: ${takeaways}` : '',
475
+ rawContent ? `Raw content (excerpt):\n${rawContent}` : '',
476
+ ].filter(Boolean).join('\n');
477
+ })
478
+ .join('\n\n');
479
+
480
+ const user = `File a wire-service-style issue using the following research as background. Do not name or reference these research items — synthesize each topic into a dispatch as if you reported it yourself.\n\n${summaries}`;
481
+
482
+ return { system, user };
483
+ }
484
+
485
+ module.exports = {
486
+ build,
487
+ meta: {
488
+ name: 'field-report',
489
+ description: 'Wire-service correspondent × Bloomberg terminal. Dispatch kickers, datelines, mono data callouts, end-of-dispatch terminators.',
490
+ requires: ['subject', 'preheader', 'tldr', 'dateline', 'dispatches', 'signoff'],
491
+ optional: ['citations', 'dataPoints', 'cta', 'image_prompt'],
492
+ supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
493
+ },
494
+ schema: FIELD_REPORT_SCHEMA,
495
+ normalize: normalizeFieldReport,
496
+ buildPrompt,
497
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Template registry — newsletter layouts.
3
+ *
4
+ * Each template module exports:
5
+ * - build({ structure, imagePaths, theme, brandName, brandUrl, brandAddress, now, sponsorships }) → MJML string
6
+ * - meta: { name, description, requires, optional, supports }
7
+ *
8
+ * Choose via `marketing.beehiiv.content.template` in config (defaults to `clean`).
9
+ */
10
+ const clean = require('./clean.js');
11
+ const editorial = require('./editorial.js');
12
+ const fieldReport = require('./field-report.js');
13
+
14
+ const TEMPLATES = {
15
+ clean,
16
+ editorial,
17
+ 'field-report': fieldReport,
18
+ };
19
+
20
+ function resolveTemplate(name) {
21
+ return TEMPLATES[name] || TEMPLATES.clean;
22
+ }
23
+
24
+ function listTemplates() {
25
+ return Object.values(TEMPLATES).map((t) => t.meta);
26
+ }
27
+
28
+ module.exports = { TEMPLATES, resolveTemplate, listTemplates };