backend-manager 5.1.1 → 5.1.4
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 +38 -0
- package/CLAUDE.md +0 -2
- package/README.md +15 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +45 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +18 -1
- package/src/cli/commands/test.js +18 -0
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/index.js +82 -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/blocked-local-patterns.js +2 -2
- package/src/manager/libraries/email/data/disposable-domains.json +12 -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 +152 -5
- package/src/manager/libraries/email/providers/beehiiv.js +7 -1
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +11 -8
- package/src/test/utils/test-mode-file.js +192 -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 +62 -48
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
|
@@ -2135,6 +2135,7 @@
|
|
|
2135
2135
|
"hazelnuts4u.com",
|
|
2136
2136
|
"hazmatshipping.org",
|
|
2137
2137
|
"hccmail.win",
|
|
2138
|
+
"hdiscord.xyz",
|
|
2138
2139
|
"headstrong.de",
|
|
2139
2140
|
"healthforwomen.info",
|
|
2140
2141
|
"healxo.org",
|
|
@@ -2299,6 +2300,7 @@
|
|
|
2299
2300
|
"imails.info",
|
|
2300
2301
|
"imailt.com",
|
|
2301
2302
|
"imap.fr.nf",
|
|
2303
|
+
"imashr.com",
|
|
2302
2304
|
"imfaya.com",
|
|
2303
2305
|
"img-free.com",
|
|
2304
2306
|
"imgof.com",
|
|
@@ -2870,6 +2872,7 @@
|
|
|
2870
2872
|
"mail7.io",
|
|
2871
2873
|
"mail707.com",
|
|
2872
2874
|
"mail72.com",
|
|
2875
|
+
"mailaddress.de",
|
|
2873
2876
|
"mailadresi.tk",
|
|
2874
2877
|
"mailapp.top",
|
|
2875
2878
|
"mailapril.org",
|
|
@@ -2899,6 +2902,7 @@
|
|
|
2899
2902
|
"mailbucket.org",
|
|
2900
2903
|
"mailcat.biz",
|
|
2901
2904
|
"mailcatch.com",
|
|
2905
|
+
"mailchannels.de",
|
|
2902
2906
|
"mailchop.com",
|
|
2903
2907
|
"mailcker.com",
|
|
2904
2908
|
"maildax.me",
|
|
@@ -3412,6 +3416,7 @@
|
|
|
3412
3416
|
"nezzart.com",
|
|
3413
3417
|
"nfast.net",
|
|
3414
3418
|
"nghienplus.io.vn",
|
|
3419
|
+
"nghienplus.store",
|
|
3415
3420
|
"nguyenusedcars.com",
|
|
3416
3421
|
"nh3.ro",
|
|
3417
3422
|
"nhmi1.com",
|
|
@@ -3559,6 +3564,7 @@
|
|
|
3559
3564
|
"oloh.ru",
|
|
3560
3565
|
"oloh.store",
|
|
3561
3566
|
"olypmall.ru",
|
|
3567
|
+
"omail.de",
|
|
3562
3568
|
"omail.pro",
|
|
3563
3569
|
"omarnasrrr.com",
|
|
3564
3570
|
"omfg.run",
|
|
@@ -4546,12 +4552,14 @@
|
|
|
4546
4552
|
"tempemail.biz",
|
|
4547
4553
|
"tempemail.co.za",
|
|
4548
4554
|
"tempemail.com",
|
|
4555
|
+
"tempemail.de",
|
|
4549
4556
|
"tempemail.net",
|
|
4550
4557
|
"tempemailgen.com",
|
|
4551
4558
|
"tempemaill.com",
|
|
4552
4559
|
"tempemailo.org",
|
|
4553
4560
|
"tempinbox.co.uk",
|
|
4554
4561
|
"tempinbox.com",
|
|
4562
|
+
"tempmail.at",
|
|
4555
4563
|
"tempmail.best",
|
|
4556
4564
|
"tempmail.cc",
|
|
4557
4565
|
"tempmail.cn",
|
|
@@ -4638,6 +4646,7 @@
|
|
|
4638
4646
|
"thejoker5.com",
|
|
4639
4647
|
"thelightningmail.net",
|
|
4640
4648
|
"thelimestones.com",
|
|
4649
|
+
"themailer.de",
|
|
4641
4650
|
"thembones.com.au",
|
|
4642
4651
|
"themegreview.com",
|
|
4643
4652
|
"themostemail.com",
|
|
@@ -4895,6 +4904,7 @@
|
|
|
4895
4904
|
"uiu.us",
|
|
4896
4905
|
"ujijima1129.gq",
|
|
4897
4906
|
"uk.to",
|
|
4907
|
+
"uki.io.vn",
|
|
4898
4908
|
"ukm.ovh",
|
|
4899
4909
|
"ultra.fyi",
|
|
4900
4910
|
"ultrada.ru",
|
|
@@ -5181,6 +5191,7 @@
|
|
|
5181
5191
|
"whatpaas.com",
|
|
5182
5192
|
"whatsaas.com",
|
|
5183
5193
|
"whiffles.org",
|
|
5194
|
+
"whispermail.org",
|
|
5184
5195
|
"whitehousecalculator.com",
|
|
5185
5196
|
"whopy.com",
|
|
5186
5197
|
"whyspam.me",
|
|
@@ -5226,6 +5237,7 @@
|
|
|
5226
5237
|
"writeme.us",
|
|
5227
5238
|
"wronghead.com",
|
|
5228
5239
|
"ws.gy",
|
|
5240
|
+
"wshu.net",
|
|
5229
5241
|
"wsym.de",
|
|
5230
5242
|
"wsypc.com",
|
|
5231
5243
|
"wudet.men",
|
|
@@ -32,6 +32,10 @@ const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/$
|
|
|
32
32
|
const IMAGE_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
|
|
33
33
|
// `{brandId}/{campaignId}/newsletter.html` — fixed file name, same folder
|
|
34
34
|
const HTML_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.html$/;
|
|
35
|
+
// `{brandId}/{campaignId}/newsletter.md` — markdown view, same folder
|
|
36
|
+
const MARKDOWN_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/newsletter\.md$/;
|
|
37
|
+
// `{brandId}/{campaignId}/summary.md` — short editorial recap, same folder
|
|
38
|
+
const SUMMARY_PATH_REGEX = /^[a-z0-9-]+\/[A-Za-z0-9_-]+\/summary\.md$/;
|
|
35
39
|
|
|
36
40
|
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
|
37
41
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -45,6 +49,10 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
45
49
|
* you only want to upload the HTML (rare).
|
|
46
50
|
* @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
|
|
47
51
|
* `{brandId}/{campaignId}/newsletter.html`.
|
|
52
|
+
* @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
|
|
53
|
+
* Uploaded as `{brandId}/{campaignId}/newsletter.md`.
|
|
54
|
+
* @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
|
|
55
|
+
* as `{brandId}/{campaignId}/summary.md`.
|
|
48
56
|
* @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
|
|
49
57
|
* @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
|
|
50
58
|
* Folder names use this verbatim — stable forever.
|
|
@@ -55,12 +63,14 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
55
63
|
* @param {object} [args.assistant] - logger
|
|
56
64
|
* @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, folderUrl: string, commitSha: string }>}
|
|
57
65
|
*/
|
|
58
|
-
async function uploadAssets({ images, html, brandId, campaignId, subject, commitMessage, token, assistant }) {
|
|
66
|
+
async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, token, assistant }) {
|
|
59
67
|
const hasImages = Array.isArray(images) && images.length > 0;
|
|
60
68
|
const hasHtml = typeof html === 'string' && html.length > 0;
|
|
69
|
+
const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
|
|
70
|
+
const hasSummary = typeof summary === 'string' && summary.length > 0;
|
|
61
71
|
|
|
62
|
-
if (!hasImages && !hasHtml) {
|
|
63
|
-
throw new Error('image-host: at least one of images[]
|
|
72
|
+
if (!hasImages && !hasHtml && !hasMarkdown && !hasSummary) {
|
|
73
|
+
throw new Error('image-host: at least one of images[] / html / markdown / summary must be provided');
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
validateBrandId(brandId);
|
|
@@ -116,13 +126,43 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
|
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
if (hasMarkdown) {
|
|
130
|
+
const path = `${brandId}/${campaignId}/newsletter.md`;
|
|
131
|
+
|
|
132
|
+
if (!MARKDOWN_PATH_REGEX.test(path)) {
|
|
133
|
+
throw new Error(`image-host: refusing to upload — invalid markdown path "${path}"`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
files.push({
|
|
137
|
+
path,
|
|
138
|
+
contentBase64: Buffer.from(markdown, 'utf8').toString('base64'),
|
|
139
|
+
kind: 'markdown',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (hasSummary) {
|
|
144
|
+
const path = `${brandId}/${campaignId}/summary.md`;
|
|
145
|
+
|
|
146
|
+
if (!SUMMARY_PATH_REGEX.test(path)) {
|
|
147
|
+
throw new Error(`image-host: refusing to upload — invalid summary path "${path}"`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
files.push({
|
|
151
|
+
path,
|
|
152
|
+
contentBase64: Buffer.from(summary, 'utf8').toString('base64'),
|
|
153
|
+
kind: 'summary',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
119
157
|
const imageCount = files.filter((f) => f.kind === 'image').length;
|
|
120
|
-
const
|
|
158
|
+
const fileSummary = [
|
|
121
159
|
imageCount ? `${imageCount} PNG${imageCount === 1 ? '' : 's'}` : null,
|
|
122
160
|
hasHtml ? 'newsletter.html' : null,
|
|
161
|
+
hasMarkdown ? 'newsletter.md' : null,
|
|
162
|
+
hasSummary ? 'summary.md' : null,
|
|
123
163
|
].filter(Boolean).join(' + ');
|
|
124
164
|
|
|
125
|
-
log(`uploading ${
|
|
165
|
+
log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME} → ${brandId}/${campaignId}/`);
|
|
126
166
|
|
|
127
167
|
const octokit = new Octokit({ auth: githubToken });
|
|
128
168
|
|
|
@@ -189,9 +229,11 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
|
|
|
189
229
|
sha: newCommit.sha,
|
|
190
230
|
});
|
|
191
231
|
|
|
192
|
-
// 7. Split the URL list by kind so callers can grab
|
|
232
|
+
// 7. Split the URL list by kind so callers can grab each independently.
|
|
193
233
|
const imageFiles = files.filter((f) => f.kind === 'image');
|
|
194
234
|
const htmlFile = files.find((f) => f.kind === 'html');
|
|
235
|
+
const markdownFile = files.find((f) => f.kind === 'markdown');
|
|
236
|
+
const summaryFile = files.find((f) => f.kind === 'summary');
|
|
195
237
|
|
|
196
238
|
const result = {
|
|
197
239
|
urls: imageFiles.map((f) => `${RAW_BASE}/${f.path}`),
|
|
@@ -205,6 +247,16 @@ async function uploadAssets({ images, html, brandId, campaignId, subject, commit
|
|
|
205
247
|
result.htmlPath = htmlFile.path;
|
|
206
248
|
}
|
|
207
249
|
|
|
250
|
+
if (markdownFile) {
|
|
251
|
+
result.markdownUrl = `${RAW_BASE}/${markdownFile.path}`;
|
|
252
|
+
result.markdownPath = markdownFile.path;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (summaryFile) {
|
|
256
|
+
result.summaryUrl = `${RAW_BASE}/${summaryFile.path}`;
|
|
257
|
+
result.summaryPath = summaryFile.path;
|
|
258
|
+
}
|
|
259
|
+
|
|
208
260
|
log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
|
|
209
261
|
|
|
210
262
|
return result;
|
|
@@ -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,
|