backend-manager 5.10.2 → 5.11.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.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/newsletter.js +147 -69
- 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
|
@@ -42,7 +42,7 @@ const { renderMarkdown } = require('./lib/markdown-renderer.js');
|
|
|
42
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) {
|
|
@@ -378,40 +384,45 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
378
384
|
}
|
|
379
385
|
}
|
|
380
386
|
|
|
381
|
-
// 3d.
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
})
|
|
402
|
-
}
|
|
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
|
+
});
|
|
403
409
|
|
|
404
|
-
// 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.
|
|
405
414
|
const admin = Manager.libraries?.admin;
|
|
406
415
|
if (admin) {
|
|
407
416
|
for (const source of sources) {
|
|
408
|
-
const
|
|
409
|
-
await trackContentSource(admin, {
|
|
417
|
+
const tracking = source.trackingData || {
|
|
410
418
|
url: source.url || source.id,
|
|
411
|
-
origin,
|
|
412
|
-
feedUrl: origin.startsWith('$feed:') ? origin.slice(6) : undefined,
|
|
419
|
+
origin: '$parent',
|
|
413
420
|
itemId: source.id,
|
|
414
421
|
itemTitle: source.title || '',
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
await trackContentSource(admin, {
|
|
425
|
+
...tracking,
|
|
415
426
|
usedBy: 'newsletter',
|
|
416
427
|
brandId: brand?.id || '',
|
|
417
428
|
}).catch((e) => {
|
|
@@ -555,12 +566,14 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
555
566
|
}
|
|
556
567
|
|
|
557
568
|
try {
|
|
569
|
+
const sourceUrls = sources.map(s => s.url || s.id).filter(Boolean);
|
|
558
570
|
const result = await publishArticle(assistant, {
|
|
559
571
|
brand: publicConfig,
|
|
560
572
|
article,
|
|
561
573
|
id: Math.round(Date.now() / 1000),
|
|
562
574
|
author: config?.article?.author,
|
|
563
575
|
postPath: 'newsletter',
|
|
576
|
+
source: sourceUrls.join(', '),
|
|
564
577
|
});
|
|
565
578
|
|
|
566
579
|
if (result?.path) {
|
|
@@ -590,43 +603,52 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
590
603
|
* @param {object} assistant
|
|
591
604
|
* @param {object} args
|
|
592
605
|
* @param {object} args.brand
|
|
593
|
-
* @param {string} args.subject
|
|
606
|
+
* @param {string} args.subject
|
|
594
607
|
* @param {string} args.preheader
|
|
595
608
|
* @param {string[]} args.tags
|
|
596
|
-
* @param {string} args.htmlUrl
|
|
597
|
-
* @param {string} [args.markdownUrl]
|
|
598
|
-
* @param {string} [args.previewUrl]
|
|
599
|
-
* @param {string} [args.summaryUrl]
|
|
600
|
-
* @param {string} [args.folderUrl]
|
|
601
|
-
* @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
|
|
602
618
|
*/
|
|
603
|
-
async function
|
|
604
|
-
// Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
|
|
605
|
-
// The `sender: 'internal'` SENDERS entry already resolves the FROM address
|
|
606
|
-
// to this; we mirror the same domain for the TO so it's a self-addressed
|
|
607
|
-
// operational alert (no human inbox involved).
|
|
619
|
+
async function sendNewsletterReportEmail(Manager, assistant, args) {
|
|
608
620
|
const brandDomain = Manager.config?.brand?.contact?.email?.split('@')[1];
|
|
609
621
|
|
|
610
622
|
if (!brandDomain) {
|
|
611
|
-
assistant.log('Newsletter generator:
|
|
623
|
+
assistant.log('Newsletter generator: report email skipped — no brand.contact.email');
|
|
612
624
|
return;
|
|
613
625
|
}
|
|
614
626
|
|
|
615
627
|
const alertsEmail = `alerts@${brandDomain}`;
|
|
628
|
+
const beehiivOk = !!args.beehiivPostId;
|
|
616
629
|
|
|
617
630
|
try {
|
|
618
631
|
const email = Manager.Email(assistant);
|
|
619
632
|
const messageLines = [];
|
|
620
633
|
|
|
621
|
-
|
|
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
|
+
}
|
|
622
645
|
messageLines.push('');
|
|
623
646
|
|
|
624
|
-
// ---
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
messageLines.push('');
|
|
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
|
+
}
|
|
630
652
|
|
|
631
653
|
// --- Details ---
|
|
632
654
|
messageLines.push('<strong>Newsletter details</strong>');
|
|
@@ -640,14 +662,13 @@ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
|
640
662
|
messageLines.push('');
|
|
641
663
|
|
|
642
664
|
// --- Full HTML ---
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
messageLines.push(`<li><a href="${args.
|
|
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('');
|
|
647
671
|
}
|
|
648
|
-
messageLines.push(`<li><a href="${args.htmlUrl}">View raw HTML</a></li>`);
|
|
649
|
-
messageLines.push('</ul>');
|
|
650
|
-
messageLines.push('');
|
|
651
672
|
|
|
652
673
|
// --- Markdown ---
|
|
653
674
|
if (args.markdownUrl) {
|
|
@@ -669,13 +690,33 @@ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
|
669
690
|
|
|
670
691
|
// --- Linked articles (only published ones) ---
|
|
671
692
|
if (args.articles?.length) {
|
|
672
|
-
messageLines.push('');
|
|
673
693
|
messageLines.push('<strong>Linked articles</strong>');
|
|
674
694
|
messageLines.push('<ul>');
|
|
675
695
|
for (const article of args.articles) {
|
|
676
696
|
messageLines.push(`<li><a href="${article.url}">${article.title || 'Article'}</a></li>`);
|
|
677
697
|
}
|
|
678
698
|
messageLines.push('</ul>');
|
|
699
|
+
messageLines.push('');
|
|
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('');
|
|
679
720
|
}
|
|
680
721
|
|
|
681
722
|
// --- All assets ---
|
|
@@ -686,29 +727,33 @@ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
|
686
727
|
messageLines.push('</ul>');
|
|
687
728
|
}
|
|
688
729
|
|
|
730
|
+
const subjectLine = beehiivOk
|
|
731
|
+
? `Newsletter generated: "${args.subject}"`
|
|
732
|
+
: `Newsletter ready for manual upload: "${args.subject}"`;
|
|
733
|
+
|
|
689
734
|
await email.send({
|
|
690
|
-
sender: 'internal',
|
|
735
|
+
sender: 'internal',
|
|
691
736
|
to: alertsEmail,
|
|
692
|
-
copy: false,
|
|
693
|
-
subject:
|
|
737
|
+
copy: false,
|
|
738
|
+
subject: subjectLine,
|
|
694
739
|
template: 'card',
|
|
695
|
-
categories: ['marketing/newsletter-
|
|
740
|
+
categories: ['marketing/newsletter-report'],
|
|
696
741
|
data: {
|
|
697
742
|
email: {
|
|
698
|
-
preview:
|
|
743
|
+
preview: beehiivOk
|
|
744
|
+
? `Newsletter generated and uploaded to Beehiiv`
|
|
745
|
+
: `Beehiiv upload failed — newsletter awaiting manual upload`,
|
|
699
746
|
},
|
|
700
747
|
content: {
|
|
701
|
-
title: 'Newsletter Ready for Manual Upload',
|
|
748
|
+
title: beehiivOk ? 'Newsletter Generated' : 'Newsletter Ready for Manual Upload',
|
|
702
749
|
message: messageLines.join('\n'),
|
|
703
750
|
},
|
|
704
751
|
},
|
|
705
752
|
});
|
|
706
753
|
|
|
707
|
-
assistant.log(`Newsletter generator:
|
|
754
|
+
assistant.log(`Newsletter generator: report email sent to ${alertsEmail}`);
|
|
708
755
|
} catch (e) {
|
|
709
|
-
|
|
710
|
-
// setup to break the cron's Firestore write.
|
|
711
|
-
assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
|
|
756
|
+
assistant.error(`Newsletter generator: report email failed — ${e.message}`);
|
|
712
757
|
}
|
|
713
758
|
}
|
|
714
759
|
|
|
@@ -745,8 +790,41 @@ function aggregateTotals(filterMeta, structureMeta, images) {
|
|
|
745
790
|
};
|
|
746
791
|
}
|
|
747
792
|
|
|
748
|
-
// fetchSources() removed — replaced by resolveNewsletterSources() from source-resolver.js
|
|
749
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
|
+
}
|
|
750
828
|
|
|
751
829
|
/**
|
|
752
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
|
|
|
@@ -2,11 +2,11 @@ WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
|
|
|
2
2
|
WARNING: sun.misc.Unsafe::objectFieldOffset has been called by akka.util.Unsafe (file:/Users/ian/.cache/firebase/emulators/firebase-database-emulator-v4.11.2.jar)
|
|
3
3
|
WARNING: Please consider reporting this to the maintainers of class akka.util.Unsafe
|
|
4
4
|
WARNING: sun.misc.Unsafe::objectFieldOffset will be removed in a future release
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
5
|
+
22:16:03.306 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started
|
|
6
|
+
22:16:03.405 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000
|
|
7
|
+
22:16:13.397 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - demo-backend-manager-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 69ms, init: 0ms
|
|
8
|
+
22:16:13.433 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace demo-backend-manager-default-rtdb status Active to Active
|
|
9
|
+
22:16:25.709 [Thread-0] INFO com.firebase.server.forge.App$ - Attempting graceful shutdown.
|
|
10
|
+
22:16:25.716 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.Terminator$Terminator - 1 actors left to terminate: demo-backend-manager-default-rtdb
|
|
11
|
+
22:16:25.720 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - stopped namespace actor for demo-backend-manager-default-rtdb
|
|
12
|
+
22:16:25.723 [Thread-0] INFO com.firebase.server.forge.App$ - Graceful shutdown complete.
|