backend-manager 5.1.2 → 5.2.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/.claude/settings.local.json +12 -0
- package/CHANGELOG.md +52 -0
- package/CLAUDE.md +2 -1
- package/README.md +30 -0
- package/docs/common-mistakes.md +1 -0
- package/docs/consent.md +333 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +81 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +62 -9
- package/src/cli/commands/serve.js +73 -7
- package/src/cli/commands/test.js +65 -1
- package/src/cli/commands/watch.js +15 -3
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/helpers/user.js +29 -0
- package/src/manager/index.js +111 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +20 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +154 -7
- package/src/manager/libraries/email/providers/beehiiv.js +8 -1
- package/src/manager/libraries/payment/processors/stripe.js +12 -0
- package/src/manager/libraries/payment/processors/test.js +8 -1
- package/src/manager/routes/admin/infer-contact/post.js +3 -2
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/marketing/email-preferences/post.js +165 -37
- package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
- package/src/manager/routes/marketing/webhook/post.js +180 -0
- package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
- package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
- package/src/manager/routes/payments/cancel/post.js +2 -2
- package/src/manager/routes/payments/cancel/processors/test.js +5 -2
- package/src/manager/routes/payments/intent/processors/test.js +7 -3
- package/src/manager/routes/payments/refund/processors/test.js +4 -1
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/manager/routes/user/signup/post.js +65 -1
- package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
- package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
- package/src/manager/schemas/marketing/webhook/post.js +7 -0
- package/src/manager/schemas/payments/cancel/post.js +5 -0
- package/src/manager/schemas/user/signup/post.js +5 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +72 -26
- package/src/test/test-accounts.js +94 -12
- package/src/test/utils/http-client.js +4 -3
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
- package/test/events/payments/journey-payments-cancel.js +4 -5
- package/test/events/payments/journey-payments-failure.js +0 -1
- package/test/events/payments/journey-payments-one-time-failure.js +6 -3
- package/test/events/payments/journey-payments-one-time.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +5 -5
- package/test/events/payments/journey-payments-refund-webhook.js +2 -3
- package/test/events/payments/journey-payments-suspend.js +4 -5
- package/test/events/payments/journey-payments-trial-cancel.js +3 -12
- package/test/events/payments/journey-payments-trial.js +2 -3
- package/test/events/payments/journey-payments-uid-resolution.js +2 -3
- package/test/functions/admin/database-read.js +0 -14
- package/test/functions/admin/database-write.js +0 -14
- package/test/functions/admin/firestore-query.js +0 -14
- package/test/functions/admin/firestore-read.js +0 -15
- package/test/functions/admin/firestore-write.js +0 -11
- package/test/functions/general/add-marketing-contact.js +16 -14
- package/test/helpers/email.js +1 -1
- package/test/helpers/infer-contact.js +3 -3
- package/test/helpers/user.js +241 -2
- package/test/helpers/webhook-forward.js +392 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +78 -54
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
- package/test/routes/admin/database.js +0 -13
- package/test/routes/admin/firestore-query.js +0 -13
- package/test/routes/admin/firestore.js +0 -14
- package/test/routes/admin/infer-contact.js +6 -3
- package/test/routes/admin/post.js +4 -2
- package/test/routes/marketing/contact.js +60 -26
- package/test/routes/marketing/email-preferences.js +145 -69
- package/test/routes/marketing/webhook-forward.js +54 -0
- package/test/routes/marketing/webhook.js +582 -0
- package/test/routes/payments/cancel.js +2 -7
- package/test/routes/payments/dispute-alert.js +0 -39
- package/test/routes/payments/refund.js +3 -1
- package/test/routes/payments/webhook.js +5 -26
- package/test/routes/test/usage.js +2 -2
- package/test/routes/user/signup.js +114 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown renderer — deterministic, no AI cost. Walks the `structure` object
|
|
3
|
+
* produced by structure.js and emits a markdown document suitable for pasting
|
|
4
|
+
* into Beehiiv's block editor (one section per `## heading` block, so you can
|
|
5
|
+
* drop ad blocks between dispatches).
|
|
6
|
+
*
|
|
7
|
+
* Why this exists:
|
|
8
|
+
* - The MJML-rendered HTML is one giant styled block. Pasting it into Beehiiv
|
|
9
|
+
* defeats the block editor — ads can't be inserted between sections.
|
|
10
|
+
* - Markdown gives Beehiiv (and any future provider) a clean per-section
|
|
11
|
+
* structure that maps to native blocks.
|
|
12
|
+
*
|
|
13
|
+
* Why programmatic, not AI:
|
|
14
|
+
* - Cost: zero. Layout never changes between issues for a given template.
|
|
15
|
+
* - Determinism: same `structure` → same markdown. Easy to test.
|
|
16
|
+
* - SSOT: the AI-authored `structure` is the source of truth; markdown and
|
|
17
|
+
* HTML are two views of the same data.
|
|
18
|
+
*
|
|
19
|
+
* Template-awareness:
|
|
20
|
+
* - `clean`/`editorial` use the classic shape (intro + sections[]).
|
|
21
|
+
* - `field-report` uses dispatches[] with kickers, bylines, dataPoints.
|
|
22
|
+
* - The renderer reads `structure._meta.template` (set by structure.js) to
|
|
23
|
+
* pick the body strategy; falls back to "classic sections" if absent.
|
|
24
|
+
*
|
|
25
|
+
* Sections render as standalone blocks so each can be pasted independently:
|
|
26
|
+
* - heading (## ...)
|
|
27
|
+
* - image (markdown  — only if image URL is present)
|
|
28
|
+
* - body
|
|
29
|
+
* - CTA as a markdown link on its own line
|
|
30
|
+
* - horizontal rule between sections (---)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Render a newsletter structure as markdown.
|
|
35
|
+
*
|
|
36
|
+
* @param {object} args
|
|
37
|
+
* @param {object} args.structure - The output of generateStructure()
|
|
38
|
+
* @param {object} [args.brand] - { name, url, id }
|
|
39
|
+
* @param {string[]} [args.imagePaths] - Per-section image URLs (same order as sections/dispatches)
|
|
40
|
+
* @param {Array<{position, html, image_url, link_url}>} [args.sponsorships] - Optional sponsorships
|
|
41
|
+
* @returns {string} Markdown document
|
|
42
|
+
*/
|
|
43
|
+
function renderMarkdown({ structure, brand, imagePaths, sponsorships }) {
|
|
44
|
+
if (!structure) {
|
|
45
|
+
throw new Error('markdown-renderer: structure is required');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const template = structure._meta?.template || 'clean';
|
|
49
|
+
const parts = [];
|
|
50
|
+
|
|
51
|
+
// ----- Header -----
|
|
52
|
+
if (structure.subject) {
|
|
53
|
+
parts.push(`# ${structure.subject}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (structure.preheader) {
|
|
57
|
+
parts.push(`_${structure.preheader}_`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Field-report-only opener: TLDR strip + dateline
|
|
61
|
+
if (template === 'field-report') {
|
|
62
|
+
if (structure.dateline) {
|
|
63
|
+
parts.push(`**${structure.dateline.toUpperCase()} —** _Filed today_`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (structure.tldr) {
|
|
67
|
+
parts.push(`> **TL;DR** — ${structure.tldr}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Classic intro
|
|
72
|
+
if (structure.intro) {
|
|
73
|
+
parts.push(structure.intro);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Top sponsorship (above sections)
|
|
77
|
+
const topSponsorship = pickSponsorship(sponsorships, 'top');
|
|
78
|
+
if (topSponsorship) {
|
|
79
|
+
parts.push('---');
|
|
80
|
+
parts.push(renderSponsorshipMarkdown(topSponsorship));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
parts.push('---');
|
|
84
|
+
|
|
85
|
+
// ----- Body -----
|
|
86
|
+
const sections = getBodySections(structure, template);
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < sections.length; i++) {
|
|
89
|
+
const block = renderSection(sections[i], i, imagePaths, template);
|
|
90
|
+
|
|
91
|
+
if (!block) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
parts.push(block);
|
|
96
|
+
parts.push('---');
|
|
97
|
+
|
|
98
|
+
// Mid-section sponsorship (after the middle dispatch)
|
|
99
|
+
const middleIdx = Math.floor(sections.length / 2);
|
|
100
|
+
|
|
101
|
+
if (i === middleIdx - 1) {
|
|
102
|
+
const midSponsorship = pickSponsorship(sponsorships, 'middle');
|
|
103
|
+
|
|
104
|
+
if (midSponsorship) {
|
|
105
|
+
parts.push(renderSponsorshipMarkdown(midSponsorship));
|
|
106
|
+
parts.push('---');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// End sponsorship (above signoff)
|
|
112
|
+
const endSponsorship = pickSponsorship(sponsorships, 'end');
|
|
113
|
+
if (endSponsorship) {
|
|
114
|
+
parts.push(renderSponsorshipMarkdown(endSponsorship));
|
|
115
|
+
parts.push('---');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ----- Footer -----
|
|
119
|
+
if (structure.signoff) {
|
|
120
|
+
// Signoffs are stored with literal "\n" — convert to a markdown line break.
|
|
121
|
+
parts.push(structure.signoff.replace(/\n/g, ' \n'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (Array.isArray(structure.citations) && structure.citations.length) {
|
|
125
|
+
parts.push('---');
|
|
126
|
+
parts.push('## Notes');
|
|
127
|
+
|
|
128
|
+
for (let i = 0; i < structure.citations.length; i++) {
|
|
129
|
+
const c = structure.citations[i];
|
|
130
|
+
|
|
131
|
+
if (!c?.note) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const src = c.source ? ` — _${c.source}_` : '';
|
|
136
|
+
parts.push(`${i + 1}. ${c.note}${src}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(structure.tags) && structure.tags.length) {
|
|
141
|
+
parts.push(`_Tags: ${structure.tags.map((t) => `#${t}`).join(' ')}_`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (brand?.name && brand?.url) {
|
|
145
|
+
parts.push(`---\n_You're receiving this because you subscribed to [${brand.name}](${brand.url})._`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Join with blank-line separators; collapse any accidental triple-newlines.
|
|
149
|
+
return parts.join('\n\n').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Extract the "body sections" — what we'll loop over to render as ## headings.
|
|
154
|
+
* The shape depends on the template.
|
|
155
|
+
*/
|
|
156
|
+
function getBodySections(structure, template) {
|
|
157
|
+
if (template === 'field-report' && Array.isArray(structure.dispatches)) {
|
|
158
|
+
return structure.dispatches.map((d) => ({
|
|
159
|
+
kind: 'dispatch',
|
|
160
|
+
title: d.headline,
|
|
161
|
+
kicker: d.kicker,
|
|
162
|
+
byline: d.byline,
|
|
163
|
+
location: d.location,
|
|
164
|
+
lede: d.lede,
|
|
165
|
+
body: d.dispatch,
|
|
166
|
+
dataPoints: d.dataPoints,
|
|
167
|
+
image_prompt: d.image_prompt,
|
|
168
|
+
}));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Classic shape (clean, editorial)
|
|
172
|
+
if (Array.isArray(structure.sections)) {
|
|
173
|
+
return structure.sections.map((s) => ({
|
|
174
|
+
kind: 'section',
|
|
175
|
+
title: s.title,
|
|
176
|
+
body: s.body,
|
|
177
|
+
image_prompt: s.image_prompt,
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return [];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Render a single section/dispatch as a self-contained markdown block.
|
|
186
|
+
* Empty sections (no title and no body) return null so the caller can skip
|
|
187
|
+
* them entirely — avoids "## undefined" stubs and dangling `---` dividers.
|
|
188
|
+
*/
|
|
189
|
+
function renderSection(section, idx, imagePaths, template) {
|
|
190
|
+
if (!section?.title && !section?.body) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const lines = [];
|
|
195
|
+
|
|
196
|
+
// Kicker prefix (field-report only)
|
|
197
|
+
if (section.kicker) {
|
|
198
|
+
lines.push(`**${section.kicker.toUpperCase()}**`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (section.title) {
|
|
202
|
+
lines.push(`## ${section.title}`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Byline / location bar (field-report only)
|
|
206
|
+
if (section.byline || section.location) {
|
|
207
|
+
const parts = [];
|
|
208
|
+
if (section.location) parts.push(`**${section.location.toUpperCase()}**`);
|
|
209
|
+
if (section.byline) parts.push(`_${section.byline}_`);
|
|
210
|
+
lines.push(parts.join(' — '));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Image (if hosted)
|
|
214
|
+
const imageUrl = imagePaths && imagePaths[idx];
|
|
215
|
+
|
|
216
|
+
if (imageUrl && !imageUrl.startsWith('about:')) {
|
|
217
|
+
const alt = section.image_prompt || section.title || `Section ${idx + 1}`;
|
|
218
|
+
lines.push(`![${alt.replace(/[\[\]]/g, '')}](${imageUrl})`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Lede (field-report)
|
|
222
|
+
if (section.lede) {
|
|
223
|
+
lines.push(`_${section.lede}_`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Data points (field-report)
|
|
227
|
+
if (Array.isArray(section.dataPoints) && section.dataPoints.length) {
|
|
228
|
+
const rows = section.dataPoints
|
|
229
|
+
.map((dp) => `| **${dp.label || ''}** | ${dp.value || ''} |`)
|
|
230
|
+
.join('\n');
|
|
231
|
+
lines.push(`| Metric | Value |\n|---|---|\n${rows}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Body
|
|
235
|
+
if (section.body) {
|
|
236
|
+
lines.push(section.body);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return lines.join('\n\n');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Pick the first sponsorship matching the requested position. Position is
|
|
244
|
+
* one of 'top' | 'middle' | 'end'. Returns null if no match.
|
|
245
|
+
*/
|
|
246
|
+
function pickSponsorship(sponsorships, position) {
|
|
247
|
+
if (!Array.isArray(sponsorships) || !sponsorships.length) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return sponsorships.find((s) => (s?.position || 'top') === position) || null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Render a sponsorship as markdown. Sponsorships in the HTML template are
|
|
256
|
+
* styled blocks; in markdown they're a simple "Sponsored by" callout with
|
|
257
|
+
* optional image and link.
|
|
258
|
+
*/
|
|
259
|
+
function renderSponsorshipMarkdown(sp) {
|
|
260
|
+
const lines = ['**Sponsored**'];
|
|
261
|
+
|
|
262
|
+
if (sp.image_url && sp.link_url) {
|
|
263
|
+
lines.push(`[](${sp.link_url})`);
|
|
264
|
+
} else if (sp.image_url) {
|
|
265
|
+
lines.push(``);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (sp.html) {
|
|
269
|
+
// Strip HTML tags for the markdown view; keep the text.
|
|
270
|
+
const text = String(sp.html).replace(/<[^>]+>/g, '').trim();
|
|
271
|
+
if (text) {
|
|
272
|
+
lines.push(text);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (sp.link_url) {
|
|
277
|
+
lines.push(`**[Learn more →](${sp.link_url})**`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return lines.join('\n\n');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
module.exports = {
|
|
284
|
+
renderMarkdown,
|
|
285
|
+
};
|
|
@@ -44,11 +44,23 @@ const DEFAULT_MODELS = {
|
|
|
44
44
|
const BASE_SCHEMA = {
|
|
45
45
|
type: 'object',
|
|
46
46
|
additionalProperties: false,
|
|
47
|
-
required: ['subject', 'preheader', 'signoff', 'citations'],
|
|
47
|
+
required: ['subject', 'preheader', 'signoff', 'citations', 'tags', 'summary'],
|
|
48
48
|
properties: {
|
|
49
49
|
subject: { type: 'string', maxLength: 80 },
|
|
50
50
|
preheader: { type: 'string', maxLength: 120 },
|
|
51
51
|
signoff: { type: 'string' },
|
|
52
|
+
// Two-to-three-sentence editorial summary of the issue. Used as the body of
|
|
53
|
+
// `summary.md` alongside the newsletter, and as a preview snippet when the
|
|
54
|
+
// issue is shared. Distinct from preheader (which is an inbox-preview hook).
|
|
55
|
+
summary: { type: 'string', maxLength: 600 },
|
|
56
|
+
// Topical tags for the issue. Flow into Beehiiv's `content_tags` field on
|
|
57
|
+
// the post draft (array of strings, lowercase, kebab-case preferred).
|
|
58
|
+
// Empty array is valid.
|
|
59
|
+
tags: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
maxItems: 5,
|
|
62
|
+
items: { type: 'string', maxLength: 40 },
|
|
63
|
+
},
|
|
52
64
|
// Citations for hard data (statistics, numbers, direct quotes) pulled from sources.
|
|
53
65
|
// Rendered as a small footnote section at the bottom of the newsletter — never inline.
|
|
54
66
|
// Empty array is valid (most newsletters won't need citations).
|
|
@@ -169,6 +181,8 @@ async function generateStructure({ sources, brand, newsletterConfig, ai, assista
|
|
|
169
181
|
structure.preheader = structure.preheader || '';
|
|
170
182
|
structure.signoff = structure.signoff || `Best,\nThe ${brand?.name || 'Team'} Team`;
|
|
171
183
|
structure.citations = Array.isArray(structure.citations) ? structure.citations : [];
|
|
184
|
+
structure.tags = Array.isArray(structure.tags) ? structure.tags : [];
|
|
185
|
+
structure.summary = typeof structure.summary === 'string' ? structure.summary : '';
|
|
172
186
|
|
|
173
187
|
// Let the template normalize its own fields (e.g. sections defaults).
|
|
174
188
|
// Falls back to a sane default if the template doesn't ship one.
|
|
@@ -226,10 +240,13 @@ function buildClassicSystemPrompt(brand, config) {
|
|
|
226
240
|
'CONTENT REQUIREMENTS:',
|
|
227
241
|
'- Subject (≤60 chars, no emojis, attention-grabbing but not clickbait)',
|
|
228
242
|
'- Preheader (≤100 chars, complements the subject)',
|
|
243
|
+
'- Summary (2-3 sentences, plain text, no markdown) — an editorial recap of the issue, written like a TL;DR. Distinct from preheader (which is an inbox hook). This is what someone reads if they only have 10 seconds.',
|
|
244
|
+
'- Tags (3-5 short topical tags, lowercase, kebab-case, no spaces) — e.g. "linkedin", "creator-economy", "platform-policy". Empty array is fine if nothing fits.',
|
|
229
245
|
'- Intro (1-2 sentences, markdown allowed) — frame the issue as if you are setting up your own reporting',
|
|
230
246
|
'- 3-5 sections — each is ONE topic, rewritten in your voice as original content',
|
|
231
|
-
'- Each section: title (compelling, scannable), body (80-150 words, markdown OK)
|
|
247
|
+
'- Each section: title (compelling, scannable), body (80-150 words, markdown OK)',
|
|
232
248
|
'- Each section: image_prompt — one-sentence visual description for an illustrator. Be specific about subject/style.',
|
|
249
|
+
'- Do NOT include CTAs, "read more" links, or any URLs in section bodies. The newsletter is a self-contained read — never invent links or send readers off-property.',
|
|
233
250
|
`- Signoff: a SHORT human sign-off, formatted as two lines with \\n between them. First line is a closing phrase like "Best,", "Cheers,", "Until next week,", or "Stay sharp,". Second line is the team name like "The ${brand?.name || 'Team'} Team". Example: "Best,\\nThe ${brand?.name || 'Team'} Team". Do NOT write a summary, tagline, motto, or thematic conclusion sentence — this is the literal way you sign off the email, like the end of a letter.`,
|
|
234
251
|
'- citations: array of { note, source } for any hard data referenced. Empty array if none.',
|
|
235
252
|
'',
|
|
@@ -2,16 +2,25 @@
|
|
|
2
2
|
* SVG illustrator — AI authors per-section SVG illustrations, rasterized to PNG.
|
|
3
3
|
*
|
|
4
4
|
* Each section in a newsletter gets one illustration. Default provider is
|
|
5
|
-
*
|
|
5
|
+
* OpenAI Codex (gpt-5.3-codex) — markup/code-specialized GPT-5 variant tuned
|
|
6
|
+
* for structured output. SVG is just structured markup, so Codex is the right
|
|
7
|
+
* fit. Anthropic is supported as a fallback provider.
|
|
6
8
|
*
|
|
7
9
|
* Output is both the raw SVG string (for debugging) and a rasterized PNG buffer
|
|
8
10
|
* (for embedding). Local file persistence is the caller's responsibility — this
|
|
9
11
|
* module returns buffers only.
|
|
12
|
+
*
|
|
13
|
+
* Provider-specific default models:
|
|
14
|
+
* openai → gpt-5.3-codex (Codex family is markup/code-specialized; SVG is
|
|
15
|
+
* structured markup. ~$0.005-0.015/image.)
|
|
16
|
+
* anthropic → claude-opus (Claude is good at artistic SVG.)
|
|
10
17
|
*/
|
|
11
18
|
const { Resvg } = require('@resvg/resvg-js');
|
|
12
19
|
|
|
20
|
+
const DEFAULT_PROVIDER = 'openai';
|
|
21
|
+
|
|
13
22
|
const DEFAULT_MODELS = {
|
|
14
|
-
openai: 'gpt-5.
|
|
23
|
+
openai: 'gpt-5.3-codex',
|
|
15
24
|
anthropic: 'claude-opus',
|
|
16
25
|
'claude-code': 'claude-opus-4-7',
|
|
17
26
|
};
|
|
@@ -30,7 +39,7 @@ const PNG_WIDTH = 800; // 2x display width of 400px container
|
|
|
30
39
|
* @returns {Promise<{svg: string, png: Buffer, fallback: boolean}>}
|
|
31
40
|
*/
|
|
32
41
|
async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
|
|
33
|
-
const provider = newsletterConfig?.provider?.svg ||
|
|
42
|
+
const provider = newsletterConfig?.provider?.svg || DEFAULT_PROVIDER;
|
|
34
43
|
const model = newsletterConfig?.model?.svg || DEFAULT_MODELS[provider];
|
|
35
44
|
const startTime = Date.now();
|
|
36
45
|
|
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Both templates consume the same shape:
|
|
5
5
|
* - intro: 1-2 sentence preamble
|
|
6
|
-
* - sections: list of {title, body,
|
|
6
|
+
* - sections: list of {title, body, image_prompt}
|
|
7
7
|
*
|
|
8
8
|
* Defined here so adding a new field (e.g. an eyebrow per section) updates
|
|
9
9
|
* every classic-style template at once. Templates with fundamentally
|
|
10
10
|
* different content shapes (Field Report, Postcard, Almanac) declare their
|
|
11
11
|
* own schema instead.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: CTAs / outbound links are intentionally NOT part of the schema. The
|
|
14
|
+
* AI cannot reliably author URLs without inventing them (it can't browse the
|
|
15
|
+
* brand's site and has no real source URLs), so we forbid the field entirely.
|
|
16
|
+
* Newsletters are self-contained — link out from the rendered HTML manually
|
|
17
|
+
* (sponsorship blocks, footer) rather than from generated section bodies.
|
|
12
18
|
*/
|
|
13
19
|
const CLASSIC_SCHEMA = {
|
|
14
20
|
required: ['intro', 'sections'],
|
|
@@ -21,19 +27,10 @@ const CLASSIC_SCHEMA = {
|
|
|
21
27
|
items: {
|
|
22
28
|
type: 'object',
|
|
23
29
|
additionalProperties: false,
|
|
24
|
-
required: ['title', 'body', 'image_prompt'
|
|
30
|
+
required: ['title', 'body', 'image_prompt'],
|
|
25
31
|
properties: {
|
|
26
32
|
title: { type: 'string' },
|
|
27
33
|
body: { type: 'string' },
|
|
28
|
-
cta: {
|
|
29
|
-
type: ['object', 'null'],
|
|
30
|
-
additionalProperties: false,
|
|
31
|
-
required: ['label', 'url'],
|
|
32
|
-
properties: {
|
|
33
|
-
label: { type: 'string' },
|
|
34
|
-
url: { type: 'string' },
|
|
35
|
-
},
|
|
36
|
-
},
|
|
37
34
|
image_prompt: { type: 'string' },
|
|
38
35
|
},
|
|
39
36
|
},
|
|
@@ -43,7 +40,7 @@ const CLASSIC_SCHEMA = {
|
|
|
43
40
|
|
|
44
41
|
/**
|
|
45
42
|
* Normalize a classic structure post-AI-call. Ensures every section has the
|
|
46
|
-
* fields the templates expect, even when the AI omits an optional
|
|
43
|
+
* fields the templates expect, even when the AI omits an optional.
|
|
47
44
|
*/
|
|
48
45
|
function normalizeClassic(structure) {
|
|
49
46
|
if (!Array.isArray(structure.sections)) {
|
|
@@ -53,7 +50,6 @@ function normalizeClassic(structure) {
|
|
|
53
50
|
structure.sections = structure.sections.map((s, i) => ({
|
|
54
51
|
title: s.title || `Section ${i + 1}`,
|
|
55
52
|
body: s.body || '',
|
|
56
|
-
cta: s.cta || null,
|
|
57
53
|
image_prompt: s.image_prompt || '',
|
|
58
54
|
}));
|
|
59
55
|
|
|
@@ -74,7 +74,7 @@ module.exports = {
|
|
|
74
74
|
name: 'clean',
|
|
75
75
|
description: 'Stripe / Linear marketing aesthetic. Safe, conservative, works everywhere.',
|
|
76
76
|
requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
|
|
77
|
-
optional: ['citations', 'image_prompt'
|
|
77
|
+
optional: ['citations', 'image_prompt'],
|
|
78
78
|
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
79
79
|
},
|
|
80
80
|
schema: CLASSIC_SCHEMA,
|
|
@@ -309,7 +309,7 @@ module.exports = {
|
|
|
309
309
|
name: 'editorial',
|
|
310
310
|
description: 'Magazine-style: masthead, drop-cap intro, numbered sections, pull-quotes, italic signoff.',
|
|
311
311
|
requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
|
|
312
|
-
optional: ['citations', 'image_prompt'
|
|
312
|
+
optional: ['citations', 'image_prompt'],
|
|
313
313
|
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
314
314
|
},
|
|
315
315
|
schema: CLASSIC_SCHEMA,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Content schema (template-owned — different from the classic schema):
|
|
18
18
|
* { tldr, dateline, dispatches: [{ kicker, headline, byline, location,
|
|
19
|
-
* lede, dispatch, dataPoints?,
|
|
19
|
+
* lede, dispatch, dataPoints?, image_prompt }] }
|
|
20
20
|
*/
|
|
21
21
|
const {
|
|
22
22
|
shell,
|
|
@@ -337,7 +337,7 @@ const FIELD_REPORT_SCHEMA = {
|
|
|
337
337
|
items: {
|
|
338
338
|
type: 'object',
|
|
339
339
|
additionalProperties: false,
|
|
340
|
-
required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', '
|
|
340
|
+
required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'dataPoints'],
|
|
341
341
|
properties: {
|
|
342
342
|
kicker: { type: 'string', maxLength: 30 }, // "DISPATCH" / "FIELD NOTES" / "WATCH" / "BRIEF"
|
|
343
343
|
headline: { type: 'string', maxLength: 90 }, // Tight, declarative
|
|
@@ -358,15 +358,10 @@ const FIELD_REPORT_SCHEMA = {
|
|
|
358
358
|
},
|
|
359
359
|
},
|
|
360
360
|
},
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
properties: {
|
|
366
|
-
label: { type: 'string' },
|
|
367
|
-
url: { type: 'string' },
|
|
368
|
-
},
|
|
369
|
-
},
|
|
361
|
+
// CTAs intentionally not part of the contract — the AI cannot author URLs
|
|
362
|
+
// reliably (no source URLs available, no brand-site knowledge). Newsletters
|
|
363
|
+
// are self-contained; outbound links come from sponsorship blocks rendered
|
|
364
|
+
// by the template shell, not from generated dispatch bodies.
|
|
370
365
|
image_prompt: { type: 'string' },
|
|
371
366
|
},
|
|
372
367
|
},
|
|
@@ -390,7 +385,6 @@ function normalizeFieldReport(structure, { brand } = {}) {
|
|
|
390
385
|
lede: d.lede || '',
|
|
391
386
|
dispatch: d.dispatch || '',
|
|
392
387
|
dataPoints: Array.isArray(d.dataPoints) ? d.dataPoints.slice(0, 4) : [],
|
|
393
|
-
cta: d.cta || null,
|
|
394
388
|
image_prompt: d.image_prompt || '',
|
|
395
389
|
}));
|
|
396
390
|
|
|
@@ -439,6 +433,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
|
|
|
439
433
|
'CONTENT REQUIREMENTS:',
|
|
440
434
|
'- subject: ≤60 chars, declarative, no clickbait. Reads like a wire-service headline.',
|
|
441
435
|
'- preheader: ≤100 chars, complements subject.',
|
|
436
|
+
'- summary: 2-3 sentences, plain text, no markdown. An editorial recap of the whole issue (distinct from the in-template `tldr` strip — the summary is consumed by external surfaces like the share preview / summary.md file).',
|
|
437
|
+
'- tags: 3-5 topical tags. Lowercase, kebab-case, no spaces. Examples: "linkedin", "creator-economy", "platform-policy". Empty array OK if nothing fits cleanly.',
|
|
442
438
|
'- tldr: 2 short sentences max, ~200 chars total. Present tense. Reads like a terminal briefing — what changed, why it matters.',
|
|
443
439
|
'- dateline: one city or "REMOTE" — sets where the issue is filed from. UPPERCASE. Example: "LOS ANGELES" / "REMOTE" / "NEW YORK".',
|
|
444
440
|
'- dispatches: 3-5 items, each is a discrete filed story.',
|
|
@@ -449,8 +445,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
|
|
|
449
445
|
' - 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
446
|
' - dispatch: the body. 90-160 words. Markdown allowed. Present tense. Specific. End with the practical implication for the reader.',
|
|
451
447
|
' - 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
448
|
' - image_prompt: one-sentence visual brief for an illustrator. Specific. Think editorial illustration, not stock photo.',
|
|
449
|
+
' - DO NOT invent CTAs, "read more" links, or any URLs anywhere in the dispatch. The dispatch must stand on its own without sending the reader off-property.',
|
|
454
450
|
`- 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
451
|
'',
|
|
456
452
|
'OUTPUT:',
|
|
@@ -488,7 +484,7 @@ module.exports = {
|
|
|
488
484
|
name: 'field-report',
|
|
489
485
|
description: 'Wire-service correspondent × Bloomberg terminal. Dispatch kickers, datelines, mono data callouts, end-of-dispatch terminators.',
|
|
490
486
|
requires: ['subject', 'preheader', 'tldr', 'dateline', 'dispatches', 'signoff'],
|
|
491
|
-
optional: ['citations', 'dataPoints', '
|
|
487
|
+
optional: ['citations', 'dataPoints', 'image_prompt'],
|
|
492
488
|
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
493
489
|
},
|
|
494
490
|
schema: FIELD_REPORT_SCHEMA,
|