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.
@@ -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, resolveNewsletterSources } = require('../../../libraries/content/source-resolver.js');
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
- sources = await resolveNewsletterSources({
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
- const images = await Promise.all(
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 first (writes to disk, etc.). The local
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(images.map((img, i) => opts.persistImage(img, i)))
227
+ ? await Promise.all(generatedImages.map((img, i) => opts.persistImage(img, i)))
205
228
  : null;
206
229
 
207
230
  if (host === 'github') {
208
- try {
209
- const { urls } = await uploadAssets({
210
- images,
211
- brandId: brand?.id,
212
- campaignId,
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
- // 'local' use persisted paths if available, else placeholder
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: ${images.length} images rendered`);
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 the rendered HTML + markdown + summary to GitHub alongside the
311
- // images. All four kinds live in the same {brandId}/{campaignId}/ folder.
312
- // The folder URL becomes the canonical archive of the issue.
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: HTML upload failed — ${e.message}`);
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. Fallback alert email — sent to the brand's internal alerts inbox when
374
- // Beehiiv draft creation fails. The newsletter is fully generated and
375
- // archived to GitHub at this point, so the email contains everything
376
- // needed for a human to manually upload to Beehiiv: HTML URL (one-shot
377
- // paste), markdown URL (per-section blocks for ad insertion), summary
378
- // URL, and the tags to set. Failure of THIS email is logged but never
379
- // blocks the cron — the campaign doc is still written either way.
380
- if (beehiivFailureReason && htmlUrl) {
381
- await sendBeehiivFallbackEmail(Manager, assistant, {
382
- brand,
383
- subject: structure.subject,
384
- preheader: structure.preheader,
385
- tags: Array.isArray(structure.tags) ? structure.tags : [],
386
- htmlUrl,
387
- previewUrl,
388
- markdownUrl,
389
- summaryUrl,
390
- folderUrl: assetsFolderUrl,
391
- reason: beehiivFailureReason,
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. Track all sources in the unified content-sources collection
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 origin = source.source?.from?.startsWith?.('http') ? `$feed:${source.source.from}` : '$parent';
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
- const result = await publishArticle(assistant, {
549
- brand: publicConfig,
550
- article,
551
- id: Math.round(Date.now() / 1000),
552
- author: config?.article?.author,
553
- postPath: 'newsletter',
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
- return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
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 - The newsletter's subject (used in the alert subject)
606
+ * @param {string} args.subject
575
607
  * @param {string} args.preheader
576
608
  * @param {string[]} args.tags
577
- * @param {string} args.htmlUrl - GitHub raw URL to the fully-rendered HTML
578
- * @param {string} [args.markdownUrl] - GitHub raw URL to the per-section markdown
579
- * @param {string} [args.previewUrl] - GitHub Pages URL for browser-rendered HTML preview
580
- * @param {string} [args.summaryUrl] - GitHub raw URL to the 2-3 sentence summary
581
- * @param {string} [args.folderUrl] - GitHub folder URL (browseable archive)
582
- * @param {string} args.reason - Why Beehiiv upload failed (API error message)
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 sendBeehiivFallbackEmail(Manager, assistant, args) {
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: Beehiiv fallback email skipped — no brand.contact.email');
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
- messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
603
- messageLines.push('');
604
- messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
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
- messageLines.push('<strong>Newsletter details:</strong>');
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><strong>Subject:</strong> ${args.subject}</li>`);
609
- messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</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><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
659
+ messageLines.push(`<li>Tags: ${args.tags.join(', ')}</li>`);
612
660
  }
613
661
  messageLines.push('</ul>');
614
662
  messageLines.push('');
615
- messageLines.push('<strong>Assets:</strong>');
616
- messageLines.push('<ul>');
617
- messageLines.push('<li><strong>Full HTML</strong> (one-shot paste into Beehiiv)');
618
- messageLines.push('<ul>');
619
- if (args.previewUrl) {
620
- messageLines.push(`<li><a href="${args.previewUrl}">Preview in browser</a></li>`);
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
- messageLines.push(`<li><a href="${args.htmlUrl}">View raw HTML</a></li>`);
623
- messageLines.push('</ul>');
624
- messageLines.push('</li>');
672
+
673
+ // --- Markdown ---
625
674
  if (args.markdownUrl) {
626
- messageLines.push(`<li><a href="${args.markdownUrl}"><strong>Per-section markdown</strong></a> — paste as separate blocks, ads between</li>`);
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(`<li><a href="${args.summaryUrl}"><strong>Summary</strong></a> — 2-3 sentence recap</li>`);
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(`<li><a href="${args.folderUrl}"><strong>All assets</strong></a> — GitHub folder</li>`);
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
- messageLines.push('</ul>');
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', // resolves to alerts@{brandDomain}
735
+ sender: 'internal',
638
736
  to: alertsEmail,
639
- copy: false, // self-addressed operational alert — no CC/BCC clutter
640
- subject: `Newsletter ready for manual Beehiiv upload: "${args.subject}"`,
737
+ copy: false,
738
+ subject: subjectLine,
641
739
  template: 'card',
642
- categories: ['marketing/newsletter-manual-upload'],
740
+ categories: ['marketing/newsletter-report'],
643
741
  data: {
644
742
  email: {
645
- preview: `Beehiiv upload failed — newsletter awaiting manual upload from ${args.folderUrl || 'GitHub archive'}`,
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: Beehiiv fallback alert sent to ${alertsEmail}`);
754
+ assistant.log(`Newsletter generator: report email sent to ${alertsEmail}`);
655
755
  } catch (e) {
656
- // Best-effort log and move on. We don't want a misconfigured email
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 (not ${result.ext})`);
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
 
@@ -2,5 +2,5 @@
2
2
  "env": {
3
3
  "TEST_EXTENDED_MODE": ""
4
4
  },
5
- "updatedAt": "2026-06-30T09:47:03.821Z"
5
+ "updatedAt": "2026-07-02T05:15:55.942Z"
6
6
  }