backend-manager 5.10.1 → 5.10.3
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.md +3 -1
- package/docs/admin-post-route.md +8 -5
- package/docs/cdp-debugging.md +15 -30
- package/docs/ghostii.md +18 -11
- package/docs/marketing-campaigns.md +11 -7
- package/package.json +1 -1
- package/src/cli/index.js +7 -1
- package/src/defaults/CLAUDE.md +2 -0
- package/src/manager/events/cron/daily/blog-auto-publisher.js +61 -150
- package/src/manager/libraries/content/source-resolver.js +296 -109
- package/src/manager/libraries/email/generators/lib/image-host.js +66 -39
- package/src/manager/libraries/email/generators/newsletter.js +240 -109
- package/src/manager/routes/admin/post/post.js +69 -1
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +55 -57
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/templates/backend-manager-config.json +1 -0
- package/test/email/newsletter-generate.js +4 -5
- package/test/helpers/content/blog-auto-publisher.js +116 -132
- package/test/helpers/content/ghostii-feed-integration.js +2 -2
- package/test/routes/admin/post-convert-image.js +158 -0
- package/test/routes/admin/post-download-error.js +89 -0
|
@@ -39,10 +39,10 @@ function resolveSectionImageFn(newsletterConfig) {
|
|
|
39
39
|
return method === 'svg' ? generateSvgSection : generateImageSection;
|
|
40
40
|
}
|
|
41
41
|
const { renderMarkdown } = require('./lib/markdown-renderer.js');
|
|
42
|
-
const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
42
|
+
const { uploadAssets, USE_CDN_URLS, REPO_OWNER, REPO_NAME, RAW_BASE } = require('./lib/image-host.js');
|
|
43
43
|
const { buildPublicConfig } = require('../../../routes/brand/get.js');
|
|
44
44
|
const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
|
|
45
|
-
const { trackContentSource, contentSourceHash,
|
|
45
|
+
const { trackContentSource, contentSourceHash, resolveSources } = require('../../../libraries/content/source-resolver.js');
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
48
|
* Generate newsletter content from parent server sources.
|
|
@@ -60,7 +60,6 @@ const { trackContentSource, contentSourceHash, resolveNewsletterSources } = requ
|
|
|
60
60
|
* @param {string} [opts.campaignId] - Stable ID used as the folder name in newsletter-assets.
|
|
61
61
|
* Defaults to the Firestore campaign doc ID if available.
|
|
62
62
|
* @param {object[]} [opts.sources] - Pre-fetched sources (bypasses parent server claim)
|
|
63
|
-
* @param {boolean} [opts.skipClaim] - Don't call PUT to mark sources as used
|
|
64
63
|
* @param {boolean} [opts.skipImages] - Skip SVG/PNG generation (use placeholders)
|
|
65
64
|
* @param {boolean} [opts.publishArticle] - When the linked-article build runs (config.article.enabled),
|
|
66
65
|
* actually COMMIT the post to the website repo via admin/post.
|
|
@@ -108,13 +107,20 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
108
107
|
|
|
109
108
|
const admin = Manager.libraries?.admin;
|
|
110
109
|
|
|
111
|
-
|
|
110
|
+
// Unified resolver — same picking/fallback/dedup as the blog pipeline.
|
|
111
|
+
// config.sourceCount controls how many sources feed the issue (default 6).
|
|
112
|
+
const resolved = await resolveSources({
|
|
112
113
|
sources: contentSources,
|
|
114
|
+
count: config.sourceCount || 6,
|
|
113
115
|
categories,
|
|
114
116
|
admin,
|
|
115
117
|
Manager,
|
|
116
118
|
assistant,
|
|
117
119
|
});
|
|
120
|
+
|
|
121
|
+
sources = resolved
|
|
122
|
+
.filter((r) => r.type === 'feed' || r.type === 'parent')
|
|
123
|
+
.map((r) => toNewsletterSource(r, categories));
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
if (!sources?.length) {
|
|
@@ -162,6 +168,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
162
168
|
// Defaults to 'github' so production cron path "just works" without flag fiddling.
|
|
163
169
|
const host = opts.imageHost || 'github';
|
|
164
170
|
|
|
171
|
+
// CDN base URL — resolves from the parent config.
|
|
172
|
+
// parent: 'https://itwcreativeworks.com' → cdn.itwcreativeworks.com
|
|
173
|
+
// parent: 'self' / '' / null → falls back to brand.url
|
|
174
|
+
let cdnBase = null;
|
|
175
|
+
if (USE_CDN_URLS && host === 'github') {
|
|
176
|
+
const parentUrl = Manager.config?.parent;
|
|
177
|
+
let cdnDomain;
|
|
178
|
+
if (parentUrl && parentUrl !== 'self' && parentUrl !== '$self' && parentUrl.startsWith('http')) {
|
|
179
|
+
cdnDomain = new URL(parentUrl).hostname;
|
|
180
|
+
} else {
|
|
181
|
+
cdnDomain = brand?.url ? new URL(brand.url).hostname : null;
|
|
182
|
+
}
|
|
183
|
+
if (cdnDomain) {
|
|
184
|
+
cdnBase = `https://cdn.${cdnDomain}/newsletters`;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
165
188
|
// campaignId — used as the GitHub folder name (and as the doc ID in production).
|
|
166
189
|
// Resolution priority (most-specific first):
|
|
167
190
|
// 1. opts.campaignId — explicit override (test runs, cron paths)
|
|
@@ -181,6 +204,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
181
204
|
// the article build expands the lead section into a full blog post and
|
|
182
205
|
// returns its public URL, which we inject as a "Read more" CTA before render.
|
|
183
206
|
let imagePaths = [];
|
|
207
|
+
let generatedImages = null;
|
|
184
208
|
|
|
185
209
|
const buildImages = async () => {
|
|
186
210
|
if (opts.skipImages) {
|
|
@@ -188,7 +212,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
const generateSectionImage = resolveSectionImageFn(config);
|
|
191
|
-
|
|
215
|
+
generatedImages = await Promise.all(
|
|
192
216
|
structure.sections.map((s) => generateSectionImage({
|
|
193
217
|
imagePrompt: s.image_prompt,
|
|
194
218
|
brand,
|
|
@@ -198,35 +222,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
198
222
|
}))
|
|
199
223
|
);
|
|
200
224
|
|
|
201
|
-
// Run persistImage side-effects
|
|
202
|
-
// paths it returns are used as a fallback if no host produces URLs.
|
|
225
|
+
// Run persistImage side-effects (writes to disk for iteration test).
|
|
203
226
|
const persistedPaths = typeof opts.persistImage === 'function'
|
|
204
|
-
? await Promise.all(
|
|
227
|
+
? await Promise.all(generatedImages.map((img, i) => opts.persistImage(img, i)))
|
|
205
228
|
: null;
|
|
206
229
|
|
|
207
230
|
if (host === 'github') {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
subject: structure.subject,
|
|
214
|
-
assistant,
|
|
215
|
-
});
|
|
216
|
-
imagePaths = urls;
|
|
217
|
-
} catch (e) {
|
|
218
|
-
assistant.error(`Newsletter generator: image upload failed — ${e.message}`);
|
|
219
|
-
imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
|
|
220
|
-
}
|
|
231
|
+
// Compute image URLs from the deterministic path — no upload needed yet.
|
|
232
|
+
// The URLs are based on branch + path, not commit SHA, so they're valid
|
|
233
|
+
// before the commit exists. Everything uploads in one atomic commit later.
|
|
234
|
+
const imgBase = cdnBase ? `${cdnBase}/${brand?.id}` : `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${brand?.id}`;
|
|
235
|
+
imagePaths = generatedImages.map((_, i) => `${imgBase}/content/${campaignId}/section-${i + 1}.png`);
|
|
221
236
|
} else {
|
|
222
|
-
|
|
223
|
-
imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
|
|
237
|
+
imagePaths = persistedPaths || generatedImages.map((_, i) => `about:blank#section-${i + 1}`);
|
|
224
238
|
}
|
|
225
239
|
|
|
226
|
-
assistant.log(`Newsletter generator: ${
|
|
227
|
-
|
|
228
|
-
// Stash images on the return for callers that want to access raw buffers
|
|
229
|
-
opts._lastImages = images;
|
|
240
|
+
assistant.log(`Newsletter generator: ${generatedImages.length} images rendered`);
|
|
241
|
+
opts._lastImages = generatedImages;
|
|
230
242
|
};
|
|
231
243
|
|
|
232
244
|
// The linked-article build is gated by config.article.enabled and needs a lead
|
|
@@ -307,9 +319,9 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
307
319
|
|
|
308
320
|
const summaryText = (structure.summary || '').trim();
|
|
309
321
|
|
|
310
|
-
// 3b. Upload
|
|
311
|
-
//
|
|
312
|
-
//
|
|
322
|
+
// 3b. Upload EVERYTHING to GitHub in one atomic commit — images + HTML +
|
|
323
|
+
// markdown + summary. Image URLs were pre-computed from the deterministic
|
|
324
|
+
// path, so the HTML already embeds the correct URLs before the commit exists.
|
|
313
325
|
let assetsFolderUrl = null;
|
|
314
326
|
let htmlUrl = null;
|
|
315
327
|
let previewUrl = null;
|
|
@@ -319,12 +331,14 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
319
331
|
if (host === 'github') {
|
|
320
332
|
try {
|
|
321
333
|
const upload = await uploadAssets({
|
|
334
|
+
images: generatedImages,
|
|
322
335
|
html,
|
|
323
336
|
markdown,
|
|
324
337
|
summary: summaryText || undefined,
|
|
325
338
|
brandId: brand?.id,
|
|
326
339
|
campaignId,
|
|
327
340
|
subject: structure.subject,
|
|
341
|
+
cdnBase,
|
|
328
342
|
assistant,
|
|
329
343
|
});
|
|
330
344
|
assetsFolderUrl = upload.folderUrl;
|
|
@@ -333,7 +347,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
333
347
|
markdownUrl = upload.markdownUrl || null;
|
|
334
348
|
summaryUrl = upload.summaryUrl || null;
|
|
335
349
|
} catch (e) {
|
|
336
|
-
assistant.error(`Newsletter generator:
|
|
350
|
+
assistant.error(`Newsletter generator: asset upload failed — ${e.message}`);
|
|
337
351
|
}
|
|
338
352
|
}
|
|
339
353
|
|
|
@@ -370,39 +384,45 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
370
384
|
}
|
|
371
385
|
}
|
|
372
386
|
|
|
373
|
-
// 3d.
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
387
|
+
// 3d. Newsletter report email — always sent to the brand's internal alerts
|
|
388
|
+
// inbox after generation completes. Contains everything needed for
|
|
389
|
+
// review and manual Beehiiv upload if needed.
|
|
390
|
+
await sendNewsletterReportEmail(Manager, assistant, {
|
|
391
|
+
brand,
|
|
392
|
+
subject: structure.subject,
|
|
393
|
+
preheader: structure.preheader,
|
|
394
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
395
|
+
htmlUrl,
|
|
396
|
+
previewUrl,
|
|
397
|
+
markdownUrl,
|
|
398
|
+
summaryUrl,
|
|
399
|
+
folderUrl: assetsFolderUrl,
|
|
400
|
+
beehiivPostId,
|
|
401
|
+
beehiivFailureReason,
|
|
402
|
+
articles: (articleResult?.published) ? [{ title: articleResult.article?.title || structure.sections?.[0]?.title, url: articleResult.url }] : [],
|
|
403
|
+
sources: sources.map(s => ({
|
|
404
|
+
title: s.title || '',
|
|
405
|
+
url: (s.url && s.url.startsWith('http')) ? s.url : '',
|
|
406
|
+
from: s.source?.from || '',
|
|
407
|
+
})),
|
|
408
|
+
});
|
|
394
409
|
|
|
395
|
-
// 4.
|
|
410
|
+
// 4. Mark all sources used in the unified content-sources collection —
|
|
411
|
+
// only NOW that the newsletter actually generated successfully.
|
|
412
|
+
// trackingData comes from the resolver; pre-fetched test sources
|
|
413
|
+
// without it get a best-effort fallback.
|
|
396
414
|
const admin = Manager.libraries?.admin;
|
|
397
415
|
if (admin) {
|
|
398
416
|
for (const source of sources) {
|
|
399
|
-
const
|
|
400
|
-
await trackContentSource(admin, {
|
|
417
|
+
const tracking = source.trackingData || {
|
|
401
418
|
url: source.url || source.id,
|
|
402
|
-
origin,
|
|
403
|
-
feedUrl: origin.startsWith('$feed:') ? origin.slice(6) : undefined,
|
|
419
|
+
origin: '$parent',
|
|
404
420
|
itemId: source.id,
|
|
405
421
|
itemTitle: source.title || '',
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
await trackContentSource(admin, {
|
|
425
|
+
...tracking,
|
|
406
426
|
usedBy: 'newsletter',
|
|
407
427
|
brandId: brand?.id || '',
|
|
408
428
|
}).catch((e) => {
|
|
@@ -545,15 +565,27 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
545
565
|
return { url, slug, path: null, published: false, article };
|
|
546
566
|
}
|
|
547
567
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
568
|
+
try {
|
|
569
|
+
const sourceUrls = sources.map(s => s.url || s.id).filter(Boolean);
|
|
570
|
+
const result = await publishArticle(assistant, {
|
|
571
|
+
brand: publicConfig,
|
|
572
|
+
article,
|
|
573
|
+
id: Math.round(Date.now() / 1000),
|
|
574
|
+
author: config?.article?.author,
|
|
575
|
+
postPath: 'newsletter',
|
|
576
|
+
source: sourceUrls.join(', '),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
if (result?.path) {
|
|
580
|
+
return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
|
|
581
|
+
}
|
|
555
582
|
|
|
556
|
-
|
|
583
|
+
assistant.log(`Newsletter generator: publishArticle returned no path — treating as unpublished`);
|
|
584
|
+
return { url, slug, path: null, published: false, article };
|
|
585
|
+
} catch (e) {
|
|
586
|
+
assistant.error(`Newsletter generator: publishArticle failed — ${e.message}`);
|
|
587
|
+
return { url, slug, path: null, published: false, article };
|
|
588
|
+
}
|
|
557
589
|
}
|
|
558
590
|
|
|
559
591
|
/**
|
|
@@ -571,91 +603,157 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
571
603
|
* @param {object} assistant
|
|
572
604
|
* @param {object} args
|
|
573
605
|
* @param {object} args.brand
|
|
574
|
-
* @param {string} args.subject
|
|
606
|
+
* @param {string} args.subject
|
|
575
607
|
* @param {string} args.preheader
|
|
576
608
|
* @param {string[]} args.tags
|
|
577
|
-
* @param {string} args.htmlUrl
|
|
578
|
-
* @param {string} [args.markdownUrl]
|
|
579
|
-
* @param {string} [args.previewUrl]
|
|
580
|
-
* @param {string} [args.summaryUrl]
|
|
581
|
-
* @param {string} [args.folderUrl]
|
|
582
|
-
* @param {string} args.
|
|
609
|
+
* @param {string} [args.htmlUrl]
|
|
610
|
+
* @param {string} [args.markdownUrl]
|
|
611
|
+
* @param {string} [args.previewUrl]
|
|
612
|
+
* @param {string} [args.summaryUrl]
|
|
613
|
+
* @param {string} [args.folderUrl]
|
|
614
|
+
* @param {string} [args.beehiivPostId] - Beehiiv post ID (if upload succeeded)
|
|
615
|
+
* @param {string} [args.beehiivFailureReason] - Why Beehiiv upload failed (null if succeeded)
|
|
616
|
+
* @param {object[]} [args.articles] - Published linked articles
|
|
617
|
+
* @param {object[]} [args.sources] - Content sources used for generation
|
|
583
618
|
*/
|
|
584
|
-
async function
|
|
585
|
-
// Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
|
|
586
|
-
// The `sender: 'internal'` SENDERS entry already resolves the FROM address
|
|
587
|
-
// to this; we mirror the same domain for the TO so it's a self-addressed
|
|
588
|
-
// operational alert (no human inbox involved).
|
|
619
|
+
async function sendNewsletterReportEmail(Manager, assistant, args) {
|
|
589
620
|
const brandDomain = Manager.config?.brand?.contact?.email?.split('@')[1];
|
|
590
621
|
|
|
591
622
|
if (!brandDomain) {
|
|
592
|
-
assistant.log('Newsletter generator:
|
|
623
|
+
assistant.log('Newsletter generator: report email skipped — no brand.contact.email');
|
|
593
624
|
return;
|
|
594
625
|
}
|
|
595
626
|
|
|
596
627
|
const alertsEmail = `alerts@${brandDomain}`;
|
|
628
|
+
const beehiivOk = !!args.beehiivPostId;
|
|
597
629
|
|
|
598
630
|
try {
|
|
599
631
|
const email = Manager.Email(assistant);
|
|
600
632
|
const messageLines = [];
|
|
601
633
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
634
|
+
// --- Status ---
|
|
635
|
+
if (beehiivOk) {
|
|
636
|
+
messageLines.push(`The newsletter has been generated and uploaded to Beehiiv.`);
|
|
637
|
+
} else {
|
|
638
|
+
messageLines.push(`The newsletter has been generated but Beehiiv upload failed. It needs to be manually uploaded.`);
|
|
639
|
+
messageLines.push('');
|
|
640
|
+
messageLines.push('<strong>Beehiiv failure reason</strong>');
|
|
641
|
+
messageLines.push('<ul>');
|
|
642
|
+
messageLines.push(`<li>${args.beehiivFailureReason || 'unknown'}</li>`);
|
|
643
|
+
messageLines.push('</ul>');
|
|
644
|
+
}
|
|
605
645
|
messageLines.push('');
|
|
606
|
-
|
|
646
|
+
|
|
647
|
+
// --- Preview button (big, prominent) ---
|
|
648
|
+
if (args.previewUrl) {
|
|
649
|
+
messageLines.push(`<p style="text-align:center;margin:16px 0;"><a href="${args.previewUrl}" style="display:inline-block;padding:14px 32px;background:#1a1a2e;color:#ffffff;text-decoration:none;border-radius:6px;font-weight:bold;font-size:16px;">Preview Newsletter</a></p>`);
|
|
650
|
+
messageLines.push('');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// --- Details ---
|
|
654
|
+
messageLines.push('<strong>Newsletter details</strong>');
|
|
607
655
|
messageLines.push('<ul>');
|
|
608
|
-
messageLines.push(`<li
|
|
609
|
-
messageLines.push(`<li
|
|
656
|
+
messageLines.push(`<li>Subject: ${args.subject}</li>`);
|
|
657
|
+
messageLines.push(`<li>Preheader: ${args.preheader || '(none)'}</li>`);
|
|
610
658
|
if (args.tags?.length) {
|
|
611
|
-
messageLines.push(`<li
|
|
659
|
+
messageLines.push(`<li>Tags: ${args.tags.join(', ')}</li>`);
|
|
612
660
|
}
|
|
613
661
|
messageLines.push('</ul>');
|
|
614
662
|
messageLines.push('');
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
messageLines.push(`<li><a href="${args.
|
|
663
|
+
|
|
664
|
+
// --- Full HTML ---
|
|
665
|
+
if (args.htmlUrl) {
|
|
666
|
+
messageLines.push('<strong>Full HTML</strong> — one-shot paste into Beehiiv');
|
|
667
|
+
messageLines.push('<ul>');
|
|
668
|
+
messageLines.push(`<li><a href="${args.htmlUrl}">View raw HTML</a></li>`);
|
|
669
|
+
messageLines.push('</ul>');
|
|
670
|
+
messageLines.push('');
|
|
621
671
|
}
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
messageLines.push('</li>');
|
|
672
|
+
|
|
673
|
+
// --- Markdown ---
|
|
625
674
|
if (args.markdownUrl) {
|
|
626
|
-
messageLines.push(
|
|
675
|
+
messageLines.push('<strong>Per-section markdown</strong> — paste as separate blocks, ads between');
|
|
676
|
+
messageLines.push('<ul>');
|
|
677
|
+
messageLines.push(`<li><a href="${args.markdownUrl}">View markdown</a></li>`);
|
|
678
|
+
messageLines.push('</ul>');
|
|
679
|
+
messageLines.push('');
|
|
627
680
|
}
|
|
681
|
+
|
|
682
|
+
// --- Summary ---
|
|
628
683
|
if (args.summaryUrl) {
|
|
629
|
-
messageLines.push(
|
|
684
|
+
messageLines.push('<strong>Summary</strong> — 2-3 sentence recap');
|
|
685
|
+
messageLines.push('<ul>');
|
|
686
|
+
messageLines.push(`<li><a href="${args.summaryUrl}">View summary</a></li>`);
|
|
687
|
+
messageLines.push('</ul>');
|
|
688
|
+
messageLines.push('');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// --- Linked articles (only published ones) ---
|
|
692
|
+
if (args.articles?.length) {
|
|
693
|
+
messageLines.push('<strong>Linked articles</strong>');
|
|
694
|
+
messageLines.push('<ul>');
|
|
695
|
+
for (const article of args.articles) {
|
|
696
|
+
messageLines.push(`<li><a href="${article.url}">${article.title || 'Article'}</a></li>`);
|
|
697
|
+
}
|
|
698
|
+
messageLines.push('</ul>');
|
|
699
|
+
messageLines.push('');
|
|
630
700
|
}
|
|
701
|
+
|
|
702
|
+
// --- Sources ---
|
|
703
|
+
if (args.sources?.length) {
|
|
704
|
+
messageLines.push(`<strong>Sources</strong> — ${args.sources.length} used`);
|
|
705
|
+
messageLines.push('<ul>');
|
|
706
|
+
for (const source of args.sources) {
|
|
707
|
+
const label = source.title || source.url || 'Untitled';
|
|
708
|
+
let fromLabel = '';
|
|
709
|
+
if (source.from) {
|
|
710
|
+
try { fromLabel = ` (${new URL(source.from).hostname})`; } catch { fromLabel = ` (${source.from})`; }
|
|
711
|
+
}
|
|
712
|
+
if (source.url) {
|
|
713
|
+
messageLines.push(`<li><a href="${source.url}">${label}</a>${fromLabel}</li>`);
|
|
714
|
+
} else {
|
|
715
|
+
messageLines.push(`<li>${label}${fromLabel}</li>`);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
messageLines.push('</ul>');
|
|
719
|
+
messageLines.push('');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// --- All assets ---
|
|
631
723
|
if (args.folderUrl) {
|
|
632
|
-
messageLines.push(
|
|
724
|
+
messageLines.push('<strong>All assets</strong> — full GitHub archive');
|
|
725
|
+
messageLines.push('<ul>');
|
|
726
|
+
messageLines.push(`<li><a href="${args.folderUrl}">Browse folder</a></li>`);
|
|
727
|
+
messageLines.push('</ul>');
|
|
633
728
|
}
|
|
634
|
-
|
|
729
|
+
|
|
730
|
+
const subjectLine = beehiivOk
|
|
731
|
+
? `Newsletter generated: "${args.subject}"`
|
|
732
|
+
: `Newsletter ready for manual upload: "${args.subject}"`;
|
|
635
733
|
|
|
636
734
|
await email.send({
|
|
637
|
-
sender: 'internal',
|
|
735
|
+
sender: 'internal',
|
|
638
736
|
to: alertsEmail,
|
|
639
|
-
copy: false,
|
|
640
|
-
subject:
|
|
737
|
+
copy: false,
|
|
738
|
+
subject: subjectLine,
|
|
641
739
|
template: 'card',
|
|
642
|
-
categories: ['marketing/newsletter-
|
|
740
|
+
categories: ['marketing/newsletter-report'],
|
|
643
741
|
data: {
|
|
644
742
|
email: {
|
|
645
|
-
preview:
|
|
743
|
+
preview: beehiivOk
|
|
744
|
+
? `Newsletter generated and uploaded to Beehiiv`
|
|
745
|
+
: `Beehiiv upload failed — newsletter awaiting manual upload`,
|
|
646
746
|
},
|
|
647
747
|
content: {
|
|
648
|
-
title: 'Newsletter Ready for Manual Upload',
|
|
748
|
+
title: beehiivOk ? 'Newsletter Generated' : 'Newsletter Ready for Manual Upload',
|
|
649
749
|
message: messageLines.join('\n'),
|
|
650
750
|
},
|
|
651
751
|
},
|
|
652
752
|
});
|
|
653
753
|
|
|
654
|
-
assistant.log(`Newsletter generator:
|
|
754
|
+
assistant.log(`Newsletter generator: report email sent to ${alertsEmail}`);
|
|
655
755
|
} catch (e) {
|
|
656
|
-
|
|
657
|
-
// setup to break the cron's Firestore write.
|
|
658
|
-
assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
|
|
756
|
+
assistant.error(`Newsletter generator: report email failed — ${e.message}`);
|
|
659
757
|
}
|
|
660
758
|
}
|
|
661
759
|
|
|
@@ -692,8 +790,41 @@ function aggregateTotals(filterMeta, structureMeta, images) {
|
|
|
692
790
|
};
|
|
693
791
|
}
|
|
694
792
|
|
|
695
|
-
// fetchSources() removed — replaced by resolveNewsletterSources() from source-resolver.js
|
|
696
793
|
|
|
794
|
+
/**
|
|
795
|
+
* Map a resolved source (from resolveSources) to the newsletter item shape
|
|
796
|
+
* the structure generator expects. Parent items already arrive in that shape
|
|
797
|
+
* from the parent server; feed items get normalized.
|
|
798
|
+
*
|
|
799
|
+
* @param {object} resolved - resolved source ({ type, id, title, url, content, feedUrl?, raw, trackingData })
|
|
800
|
+
* @param {string[]} categories - the entry's configured categories
|
|
801
|
+
* @returns {object} newsletter source record (+ trackingData for used-marking)
|
|
802
|
+
*/
|
|
803
|
+
function toNewsletterSource(resolved, categories) {
|
|
804
|
+
if (resolved.type === 'parent') {
|
|
805
|
+
return { ...resolved.raw, trackingData: resolved.trackingData };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// feed item
|
|
809
|
+
let hostname = '';
|
|
810
|
+
try { hostname = new URL(resolved.feedUrl).hostname; } catch (e) { /* ignore */ }
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
id: resolved.id,
|
|
814
|
+
title: resolved.title,
|
|
815
|
+
subject: resolved.title,
|
|
816
|
+
category: (categories && categories[0]) || '',
|
|
817
|
+
categories: categories || [],
|
|
818
|
+
url: resolved.url,
|
|
819
|
+
source: {
|
|
820
|
+
from: hostname,
|
|
821
|
+
subject: resolved.title,
|
|
822
|
+
content: resolved.content,
|
|
823
|
+
},
|
|
824
|
+
ai: null,
|
|
825
|
+
trackingData: resolved.trackingData,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
697
828
|
|
|
698
829
|
/**
|
|
699
830
|
* Generate a 20-character Firebase push ID (RTDB-style).
|
|
@@ -24,6 +24,11 @@ const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
|
|
|
24
24
|
const IMAGE_MAX_DIMENSION = 2048;
|
|
25
25
|
const IMAGE_JPEG_QUALITY = 80;
|
|
26
26
|
|
|
27
|
+
// Non-JPG raster sources are converted to JPEG at ingest rather than rejected
|
|
28
|
+
// — stock CDNs beyond Unsplash (Pexels, Pixabay) commonly serve png/webp.
|
|
29
|
+
// Anything not in this list (and not already .jpg) is still rejected.
|
|
30
|
+
const CONVERTIBLE_IMAGE_EXTS = ['.png', '.webp'];
|
|
31
|
+
|
|
27
32
|
module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
28
33
|
|
|
29
34
|
// Require authentication
|
|
@@ -221,12 +226,35 @@ function applyImageCDNParams(src) {
|
|
|
221
226
|
}
|
|
222
227
|
}
|
|
223
228
|
|
|
229
|
+
if (url.hostname === 'images.pexels.com') {
|
|
230
|
+
if (!url.searchParams.has('w')) {
|
|
231
|
+
url.searchParams.set('w', String(IMAGE_MAX_DIMENSION));
|
|
232
|
+
}
|
|
233
|
+
if (!url.searchParams.has('auto')) {
|
|
234
|
+
url.searchParams.set('auto', 'compress');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
224
238
|
return url.toString();
|
|
225
239
|
} catch (e) {
|
|
226
240
|
return src;
|
|
227
241
|
}
|
|
228
242
|
}
|
|
229
243
|
|
|
244
|
+
// Helper: Build a readable message for a failed image download. Fetch errors
|
|
245
|
+
// can carry raw HTML bodies as the message (CDN 404 pages) — strip tags and
|
|
246
|
+
// truncate so callers surface "Could not download image (<url>): 404" instead
|
|
247
|
+
// of markup that downstream HTML rendering swallows.
|
|
248
|
+
function formatImageDownloadError(src, e) {
|
|
249
|
+
const reason = (e && typeof e.message === 'string' ? e.message : `${e}`)
|
|
250
|
+
.replace(/<[^>]*>/g, ' ')
|
|
251
|
+
.replace(/\s+/g, ' ')
|
|
252
|
+
.trim();
|
|
253
|
+
const truncated = reason.length > 200 ? `${reason.slice(0, 197)}...` : reason;
|
|
254
|
+
|
|
255
|
+
return `Could not download image (${src}): ${truncated || 'unknown error'}`;
|
|
256
|
+
}
|
|
257
|
+
|
|
230
258
|
// Helper: Download image
|
|
231
259
|
async function downloadImage(assistant, src, alt) {
|
|
232
260
|
const fetch = assistant.Manager.require('wonderful-fetch');
|
|
@@ -242,6 +270,8 @@ async function downloadImage(assistant, src, alt) {
|
|
|
242
270
|
const result = await fetch(url, {
|
|
243
271
|
method: 'get',
|
|
244
272
|
download: `${assistant.tmpdir}/${hyphenated}`,
|
|
273
|
+
}).catch(e => {
|
|
274
|
+
throw new Error(formatImageDownloadError(src, e));
|
|
245
275
|
});
|
|
246
276
|
|
|
247
277
|
result.filename = path.basename(result.path);
|
|
@@ -249,8 +279,13 @@ async function downloadImage(assistant, src, alt) {
|
|
|
249
279
|
|
|
250
280
|
assistant.log('downloadImage(): Result', result.path);
|
|
251
281
|
|
|
282
|
+
// Convert supported non-JPG formats in place (mutates result.path/filename/ext)
|
|
283
|
+
if (CONVERTIBLE_IMAGE_EXTS.includes(result.ext)) {
|
|
284
|
+
await convertToJpeg(assistant, result);
|
|
285
|
+
}
|
|
286
|
+
|
|
252
287
|
if (result.ext !== '.jpg') {
|
|
253
|
-
throw new Error(`Images must be .jpg (
|
|
288
|
+
throw new Error(`Images must be .jpg, .png, or .webp (got ${result.ext}): ${src}`);
|
|
254
289
|
}
|
|
255
290
|
|
|
256
291
|
// Resize in place if the long edge exceeds IMAGE_MAX_DIMENSION
|
|
@@ -259,6 +294,36 @@ async function downloadImage(assistant, src, alt) {
|
|
|
259
294
|
return result;
|
|
260
295
|
}
|
|
261
296
|
|
|
297
|
+
// Helper: Convert a downloaded png/webp to progressive JPEG in place. Alpha is
|
|
298
|
+
// flattened onto white (JPEG has no transparency; default flatten is black).
|
|
299
|
+
// Mutates result.path/filename/ext to the new .jpg file and removes the
|
|
300
|
+
// original so the rest of the pipeline (resize, base64, GitHub path) only ever
|
|
301
|
+
// sees JPGs.
|
|
302
|
+
async function convertToJpeg(assistant, result) {
|
|
303
|
+
const sharp = assistant.Manager.require('sharp');
|
|
304
|
+
sharp.cache(false);
|
|
305
|
+
|
|
306
|
+
const newPath = result.path.replace(/\.[^.]+$/, '.jpg');
|
|
307
|
+
const buffer = await sharp(result.path)
|
|
308
|
+
.flatten({ background: '#ffffff' })
|
|
309
|
+
.jpeg({ quality: IMAGE_JPEG_QUALITY, progressive: true })
|
|
310
|
+
.toBuffer();
|
|
311
|
+
|
|
312
|
+
jetpack.write(newPath, buffer);
|
|
313
|
+
|
|
314
|
+
if (newPath !== result.path) {
|
|
315
|
+
jetpack.remove(result.path);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
result.path = newPath;
|
|
319
|
+
result.filename = path.basename(newPath);
|
|
320
|
+
result.ext = '.jpg';
|
|
321
|
+
|
|
322
|
+
assistant.log(`convertToJpeg(): Converted to ${newPath}`);
|
|
323
|
+
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
|
|
262
327
|
// Helper: Resize image in place if the long edge exceeds IMAGE_MAX_DIMENSION.
|
|
263
328
|
// Re-encodes as progressive JPEG at IMAGE_JPEG_QUALITY. Short-circuits when the
|
|
264
329
|
// source is already within the limit.
|
|
@@ -406,6 +471,9 @@ function formatClone(payload) {
|
|
|
406
471
|
|
|
407
472
|
// Expose helpers + constants for tests
|
|
408
473
|
module.exports.resizeImage = resizeImage;
|
|
474
|
+
module.exports.convertToJpeg = convertToJpeg;
|
|
475
|
+
module.exports.formatImageDownloadError = formatImageDownloadError;
|
|
476
|
+
module.exports.applyImageCDNParams = applyImageCDNParams;
|
|
409
477
|
module.exports.IMAGE_MAX_DIMENSION = IMAGE_MAX_DIMENSION;
|
|
410
478
|
module.exports.IMAGE_JPEG_QUALITY = IMAGE_JPEG_QUALITY;
|
|
411
479
|
|