backend-manager 5.10.1 → 5.10.2
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/package.json
CHANGED
|
@@ -31,6 +31,12 @@ const { Octokit } = require('@octokit/rest');
|
|
|
31
31
|
const REPO_OWNER = 'itw-creative-works';
|
|
32
32
|
const REPO_NAME = 'newsletter-assets';
|
|
33
33
|
|
|
34
|
+
// When true, asset URLs use the parent CDN (cdn.{parentDomain}/newsletters/...)
|
|
35
|
+
// instead of raw.githubusercontent.com. Requires a Cloudflare redirect rule
|
|
36
|
+
// on the CDN domain that maps /newsletters/* to raw.githubusercontent.com.
|
|
37
|
+
// Set to false to revert to direct GitHub URLs.
|
|
38
|
+
const USE_CDN_URLS = true;
|
|
39
|
+
|
|
34
40
|
const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
|
|
35
41
|
|
|
36
42
|
// Fallback RAW_BASE for callers that don't have a brand context
|
|
@@ -65,11 +71,14 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
65
71
|
* @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
|
|
66
72
|
* `git log` reads as a human-browseable history.
|
|
67
73
|
* @param {string} [args.commitMessage] - Full override of the default commit message
|
|
74
|
+
* @param {string} [args.cdnBase] - CDN base URL (e.g. 'https://cdn.itwcreativeworks.com/newsletters').
|
|
75
|
+
* When provided and USE_CDN_URLS is true, asset URLs use this
|
|
76
|
+
* instead of raw.githubusercontent.com.
|
|
68
77
|
* @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
|
|
69
78
|
* @param {object} [args.assistant] - logger
|
|
70
79
|
* @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, previewUrl?: string, folderUrl: string, commitSha: string }>}
|
|
71
80
|
*/
|
|
72
|
-
async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, token, assistant }) {
|
|
81
|
+
async function uploadAssets({ images, html, markdown, summary, brandId, campaignId, subject, commitMessage, cdnBase, token, assistant }) {
|
|
73
82
|
const hasImages = Array.isArray(images) && images.length > 0;
|
|
74
83
|
const hasHtml = typeof html === 'string' && html.length > 0;
|
|
75
84
|
const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
|
|
@@ -92,7 +101,11 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
92
101
|
|
|
93
102
|
// Brand branch — each brand gets its own branch, zero cross-brand contention
|
|
94
103
|
const branch = brandId;
|
|
95
|
-
const
|
|
104
|
+
const gitRawBase = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${branch}`;
|
|
105
|
+
|
|
106
|
+
// CDN URLs: cdn.{parentDomain}/newsletters/{brandId}/content/{campaignId}/...
|
|
107
|
+
// The CDN redirect maps /newsletters/{brandId}/* → raw.githubusercontent.com/{brandId}/*
|
|
108
|
+
const assetBase = (USE_CDN_URLS && cdnBase) ? `${cdnBase}/${brandId}` : gitRawBase;
|
|
96
109
|
|
|
97
110
|
// 1. Build the list of files to commit. Validate each before we touch GitHub.
|
|
98
111
|
const files = [];
|
|
@@ -225,42 +238,55 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
225
238
|
});
|
|
226
239
|
}
|
|
227
240
|
|
|
228
|
-
// 4.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
commit_sha: branchSha,
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
const { data: newTree } = await octokit.rest.git.createTree({
|
|
236
|
-
owner: REPO_OWNER,
|
|
237
|
-
repo: REPO_NAME,
|
|
238
|
-
base_tree: baseCommit.tree.sha,
|
|
239
|
-
tree: treeItems,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
// 5. Commit
|
|
241
|
+
// 4. Commit + push with retry. The newsletter pipeline calls uploadAssets
|
|
242
|
+
// twice sequentially (images then HTML). GitHub's API can return a stale
|
|
243
|
+
// ref on the second getRef if the first updateRef hasn't fully propagated.
|
|
244
|
+
// Retry with a fresh ref read + jitter handles this.
|
|
243
245
|
const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
|
|
244
246
|
const message = commitMessage || `[${brandId}] ${campaignId} — ${defaultSubject}`;
|
|
247
|
+
const MAX_RETRIES = 3;
|
|
248
|
+
let newCommit;
|
|
249
|
+
|
|
250
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
251
|
+
const { data: refData } = await octokit.rest.git.getRef({
|
|
252
|
+
owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}`,
|
|
253
|
+
});
|
|
254
|
+
const tipSha = refData.object.sha;
|
|
255
|
+
|
|
256
|
+
const { data: baseCommit } = await octokit.rest.git.getCommit({
|
|
257
|
+
owner: REPO_OWNER, repo: REPO_NAME, commit_sha: tipSha,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
const { data: newTree } = await octokit.rest.git.createTree({
|
|
261
|
+
owner: REPO_OWNER, repo: REPO_NAME,
|
|
262
|
+
base_tree: baseCommit.tree.sha,
|
|
263
|
+
tree: treeItems,
|
|
264
|
+
});
|
|
245
265
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
266
|
+
newCommit = (await octokit.rest.git.createCommit({
|
|
267
|
+
owner: REPO_OWNER, repo: REPO_NAME,
|
|
268
|
+
message,
|
|
269
|
+
tree: newTree.sha,
|
|
270
|
+
parents: [tipSha],
|
|
271
|
+
})).data;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
await octokit.rest.git.updateRef({
|
|
275
|
+
owner: REPO_OWNER, repo: REPO_NAME,
|
|
276
|
+
ref: `heads/${branch}`,
|
|
277
|
+
sha: newCommit.sha,
|
|
278
|
+
});
|
|
279
|
+
break;
|
|
280
|
+
} catch (e) {
|
|
281
|
+
if (attempt < MAX_RETRIES && /fast.forward/i.test(e.message)) {
|
|
282
|
+
const jitter = 500 + Math.floor(Math.random() * 1500);
|
|
283
|
+
log(`updateRef race (attempt ${attempt}/${MAX_RETRIES}), retrying in ${jitter}ms...`);
|
|
284
|
+
await new Promise(r => setTimeout(r, jitter));
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
264
290
|
|
|
265
291
|
// 7. Split the URL list by kind so callers can grab each independently.
|
|
266
292
|
const imageFiles = files.filter((f) => f.kind === 'image');
|
|
@@ -269,25 +295,25 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
269
295
|
const summaryFile = files.find((f) => f.kind === 'summary');
|
|
270
296
|
|
|
271
297
|
const result = {
|
|
272
|
-
urls: imageFiles.map((f) => `${
|
|
298
|
+
urls: imageFiles.map((f) => `${assetBase}/${f.path}`),
|
|
273
299
|
paths: imageFiles.map((f) => f.path),
|
|
274
300
|
folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
|
|
275
301
|
commitSha: newCommit.sha,
|
|
276
302
|
};
|
|
277
303
|
|
|
278
304
|
if (htmlFile) {
|
|
279
|
-
result.htmlUrl = `${
|
|
305
|
+
result.htmlUrl = `${assetBase}/${htmlFile.path}`;
|
|
280
306
|
result.htmlPath = htmlFile.path;
|
|
281
307
|
result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
|
|
282
308
|
}
|
|
283
309
|
|
|
284
310
|
if (markdownFile) {
|
|
285
|
-
result.markdownUrl = `${
|
|
311
|
+
result.markdownUrl = `${assetBase}/${markdownFile.path}`;
|
|
286
312
|
result.markdownPath = markdownFile.path;
|
|
287
313
|
}
|
|
288
314
|
|
|
289
315
|
if (summaryFile) {
|
|
290
|
-
result.summaryUrl = `${
|
|
316
|
+
result.summaryUrl = `${assetBase}/${summaryFile.path}`;
|
|
291
317
|
result.summaryPath = summaryFile.path;
|
|
292
318
|
}
|
|
293
319
|
|
|
@@ -310,6 +336,7 @@ function validateCampaignId(campaignId) {
|
|
|
310
336
|
|
|
311
337
|
module.exports = {
|
|
312
338
|
uploadAssets,
|
|
339
|
+
USE_CDN_URLS,
|
|
313
340
|
REPO_OWNER,
|
|
314
341
|
REPO_NAME,
|
|
315
342
|
RAW_BASE,
|
|
@@ -39,7 +39,7 @@ 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
45
|
const { trackContentSource, contentSourceHash, resolveNewsletterSources } = require('../../../libraries/content/source-resolver.js');
|
|
@@ -162,6 +162,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
162
162
|
// Defaults to 'github' so production cron path "just works" without flag fiddling.
|
|
163
163
|
const host = opts.imageHost || 'github';
|
|
164
164
|
|
|
165
|
+
// CDN base URL — resolves from the parent config.
|
|
166
|
+
// parent: 'https://itwcreativeworks.com' → cdn.itwcreativeworks.com
|
|
167
|
+
// parent: 'self' / '' / null → falls back to brand.url
|
|
168
|
+
let cdnBase = null;
|
|
169
|
+
if (USE_CDN_URLS && host === 'github') {
|
|
170
|
+
const parentUrl = Manager.config?.parent;
|
|
171
|
+
let cdnDomain;
|
|
172
|
+
if (parentUrl && parentUrl !== 'self' && parentUrl !== '$self' && parentUrl.startsWith('http')) {
|
|
173
|
+
cdnDomain = new URL(parentUrl).hostname;
|
|
174
|
+
} else {
|
|
175
|
+
cdnDomain = brand?.url ? new URL(brand.url).hostname : null;
|
|
176
|
+
}
|
|
177
|
+
if (cdnDomain) {
|
|
178
|
+
cdnBase = `https://cdn.${cdnDomain}/newsletters`;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
// campaignId — used as the GitHub folder name (and as the doc ID in production).
|
|
166
183
|
// Resolution priority (most-specific first):
|
|
167
184
|
// 1. opts.campaignId — explicit override (test runs, cron paths)
|
|
@@ -181,6 +198,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
181
198
|
// the article build expands the lead section into a full blog post and
|
|
182
199
|
// returns its public URL, which we inject as a "Read more" CTA before render.
|
|
183
200
|
let imagePaths = [];
|
|
201
|
+
let generatedImages = null;
|
|
184
202
|
|
|
185
203
|
const buildImages = async () => {
|
|
186
204
|
if (opts.skipImages) {
|
|
@@ -188,7 +206,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
188
206
|
}
|
|
189
207
|
|
|
190
208
|
const generateSectionImage = resolveSectionImageFn(config);
|
|
191
|
-
|
|
209
|
+
generatedImages = await Promise.all(
|
|
192
210
|
structure.sections.map((s) => generateSectionImage({
|
|
193
211
|
imagePrompt: s.image_prompt,
|
|
194
212
|
brand,
|
|
@@ -198,35 +216,23 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
198
216
|
}))
|
|
199
217
|
);
|
|
200
218
|
|
|
201
|
-
// Run persistImage side-effects
|
|
202
|
-
// paths it returns are used as a fallback if no host produces URLs.
|
|
219
|
+
// Run persistImage side-effects (writes to disk for iteration test).
|
|
203
220
|
const persistedPaths = typeof opts.persistImage === 'function'
|
|
204
|
-
? await Promise.all(
|
|
221
|
+
? await Promise.all(generatedImages.map((img, i) => opts.persistImage(img, i)))
|
|
205
222
|
: null;
|
|
206
223
|
|
|
207
224
|
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
|
-
}
|
|
225
|
+
// Compute image URLs from the deterministic path — no upload needed yet.
|
|
226
|
+
// The URLs are based on branch + path, not commit SHA, so they're valid
|
|
227
|
+
// before the commit exists. Everything uploads in one atomic commit later.
|
|
228
|
+
const imgBase = cdnBase ? `${cdnBase}/${brand?.id}` : `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${brand?.id}`;
|
|
229
|
+
imagePaths = generatedImages.map((_, i) => `${imgBase}/content/${campaignId}/section-${i + 1}.png`);
|
|
221
230
|
} else {
|
|
222
|
-
|
|
223
|
-
imagePaths = persistedPaths || images.map((_, i) => `about:blank#section-${i + 1}`);
|
|
231
|
+
imagePaths = persistedPaths || generatedImages.map((_, i) => `about:blank#section-${i + 1}`);
|
|
224
232
|
}
|
|
225
233
|
|
|
226
|
-
assistant.log(`Newsletter generator: ${
|
|
227
|
-
|
|
228
|
-
// Stash images on the return for callers that want to access raw buffers
|
|
229
|
-
opts._lastImages = images;
|
|
234
|
+
assistant.log(`Newsletter generator: ${generatedImages.length} images rendered`);
|
|
235
|
+
opts._lastImages = generatedImages;
|
|
230
236
|
};
|
|
231
237
|
|
|
232
238
|
// The linked-article build is gated by config.article.enabled and needs a lead
|
|
@@ -307,9 +313,9 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
307
313
|
|
|
308
314
|
const summaryText = (structure.summary || '').trim();
|
|
309
315
|
|
|
310
|
-
// 3b. Upload
|
|
311
|
-
//
|
|
312
|
-
//
|
|
316
|
+
// 3b. Upload EVERYTHING to GitHub in one atomic commit — images + HTML +
|
|
317
|
+
// markdown + summary. Image URLs were pre-computed from the deterministic
|
|
318
|
+
// path, so the HTML already embeds the correct URLs before the commit exists.
|
|
313
319
|
let assetsFolderUrl = null;
|
|
314
320
|
let htmlUrl = null;
|
|
315
321
|
let previewUrl = null;
|
|
@@ -319,12 +325,14 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
319
325
|
if (host === 'github') {
|
|
320
326
|
try {
|
|
321
327
|
const upload = await uploadAssets({
|
|
328
|
+
images: generatedImages,
|
|
322
329
|
html,
|
|
323
330
|
markdown,
|
|
324
331
|
summary: summaryText || undefined,
|
|
325
332
|
brandId: brand?.id,
|
|
326
333
|
campaignId,
|
|
327
334
|
subject: structure.subject,
|
|
335
|
+
cdnBase,
|
|
328
336
|
assistant,
|
|
329
337
|
});
|
|
330
338
|
assetsFolderUrl = upload.folderUrl;
|
|
@@ -333,7 +341,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
333
341
|
markdownUrl = upload.markdownUrl || null;
|
|
334
342
|
summaryUrl = upload.summaryUrl || null;
|
|
335
343
|
} catch (e) {
|
|
336
|
-
assistant.error(`Newsletter generator:
|
|
344
|
+
assistant.error(`Newsletter generator: asset upload failed — ${e.message}`);
|
|
337
345
|
}
|
|
338
346
|
}
|
|
339
347
|
|
|
@@ -377,7 +385,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
377
385
|
// paste), markdown URL (per-section blocks for ad insertion), summary
|
|
378
386
|
// URL, and the tags to set. Failure of THIS email is logged but never
|
|
379
387
|
// blocks the cron — the campaign doc is still written either way.
|
|
380
|
-
if (beehiivFailureReason
|
|
388
|
+
if (beehiivFailureReason) {
|
|
381
389
|
await sendBeehiivFallbackEmail(Manager, assistant, {
|
|
382
390
|
brand,
|
|
383
391
|
subject: structure.subject,
|
|
@@ -389,6 +397,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
389
397
|
summaryUrl,
|
|
390
398
|
folderUrl: assetsFolderUrl,
|
|
391
399
|
reason: beehiivFailureReason,
|
|
400
|
+
articles: (articleResult?.published) ? [{ title: articleResult.article?.title || structure.sections?.[0]?.title, url: articleResult.url }] : [],
|
|
392
401
|
});
|
|
393
402
|
}
|
|
394
403
|
|
|
@@ -545,15 +554,25 @@ async function buildLinkedArticle({ Manager, assistant, brand, config, structure
|
|
|
545
554
|
return { url, slug, path: null, published: false, article };
|
|
546
555
|
}
|
|
547
556
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
557
|
+
try {
|
|
558
|
+
const result = await publishArticle(assistant, {
|
|
559
|
+
brand: publicConfig,
|
|
560
|
+
article,
|
|
561
|
+
id: Math.round(Date.now() / 1000),
|
|
562
|
+
author: config?.article?.author,
|
|
563
|
+
postPath: 'newsletter',
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
if (result?.path) {
|
|
567
|
+
return { url: result.url || url, slug: result.slug || slug, path: result.path, published: true, article };
|
|
568
|
+
}
|
|
555
569
|
|
|
556
|
-
|
|
570
|
+
assistant.log(`Newsletter generator: publishArticle returned no path — treating as unpublished`);
|
|
571
|
+
return { url, slug, path: null, published: false, article };
|
|
572
|
+
} catch (e) {
|
|
573
|
+
assistant.error(`Newsletter generator: publishArticle failed — ${e.message}`);
|
|
574
|
+
return { url, slug, path: null, published: false, article };
|
|
575
|
+
}
|
|
557
576
|
}
|
|
558
577
|
|
|
559
578
|
/**
|
|
@@ -599,39 +618,73 @@ async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
|
599
618
|
const email = Manager.Email(assistant);
|
|
600
619
|
const messageLines = [];
|
|
601
620
|
|
|
602
|
-
messageLines.push(
|
|
621
|
+
messageLines.push(`The newsletter is generated and archived, but Beehiiv draft creation failed. It needs to be manually uploaded.`);
|
|
603
622
|
messageLines.push('');
|
|
604
|
-
|
|
623
|
+
|
|
624
|
+
// --- Failure ---
|
|
625
|
+
messageLines.push('<strong>Failure reason</strong>');
|
|
626
|
+
messageLines.push('<ul>');
|
|
627
|
+
messageLines.push(`<li>${args.reason}</li>`);
|
|
628
|
+
messageLines.push('</ul>');
|
|
605
629
|
messageLines.push('');
|
|
606
|
-
|
|
630
|
+
|
|
631
|
+
// --- Details ---
|
|
632
|
+
messageLines.push('<strong>Newsletter details</strong>');
|
|
607
633
|
messageLines.push('<ul>');
|
|
608
|
-
messageLines.push(`<li
|
|
609
|
-
messageLines.push(`<li
|
|
634
|
+
messageLines.push(`<li>Subject: ${args.subject}</li>`);
|
|
635
|
+
messageLines.push(`<li>Preheader: ${args.preheader || '(none)'}</li>`);
|
|
610
636
|
if (args.tags?.length) {
|
|
611
|
-
messageLines.push(`<li
|
|
637
|
+
messageLines.push(`<li>Tags: ${args.tags.join(', ')}</li>`);
|
|
612
638
|
}
|
|
613
639
|
messageLines.push('</ul>');
|
|
614
640
|
messageLines.push('');
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
messageLines.push('<
|
|
641
|
+
|
|
642
|
+
// --- Full HTML ---
|
|
643
|
+
messageLines.push('<strong>Full HTML</strong> — one-shot paste into Beehiiv');
|
|
618
644
|
messageLines.push('<ul>');
|
|
619
645
|
if (args.previewUrl) {
|
|
620
646
|
messageLines.push(`<li><a href="${args.previewUrl}">Preview in browser</a></li>`);
|
|
621
647
|
}
|
|
622
648
|
messageLines.push(`<li><a href="${args.htmlUrl}">View raw HTML</a></li>`);
|
|
623
649
|
messageLines.push('</ul>');
|
|
624
|
-
messageLines.push('
|
|
650
|
+
messageLines.push('');
|
|
651
|
+
|
|
652
|
+
// --- Markdown ---
|
|
625
653
|
if (args.markdownUrl) {
|
|
626
|
-
messageLines.push(
|
|
654
|
+
messageLines.push('<strong>Per-section markdown</strong> — paste as separate blocks, ads between');
|
|
655
|
+
messageLines.push('<ul>');
|
|
656
|
+
messageLines.push(`<li><a href="${args.markdownUrl}">View markdown</a></li>`);
|
|
657
|
+
messageLines.push('</ul>');
|
|
658
|
+
messageLines.push('');
|
|
627
659
|
}
|
|
660
|
+
|
|
661
|
+
// --- Summary ---
|
|
628
662
|
if (args.summaryUrl) {
|
|
629
|
-
messageLines.push(
|
|
663
|
+
messageLines.push('<strong>Summary</strong> — 2-3 sentence recap');
|
|
664
|
+
messageLines.push('<ul>');
|
|
665
|
+
messageLines.push(`<li><a href="${args.summaryUrl}">View summary</a></li>`);
|
|
666
|
+
messageLines.push('</ul>');
|
|
667
|
+
messageLines.push('');
|
|
630
668
|
}
|
|
669
|
+
|
|
670
|
+
// --- Linked articles (only published ones) ---
|
|
671
|
+
if (args.articles?.length) {
|
|
672
|
+
messageLines.push('');
|
|
673
|
+
messageLines.push('<strong>Linked articles</strong>');
|
|
674
|
+
messageLines.push('<ul>');
|
|
675
|
+
for (const article of args.articles) {
|
|
676
|
+
messageLines.push(`<li><a href="${article.url}">${article.title || 'Article'}</a></li>`);
|
|
677
|
+
}
|
|
678
|
+
messageLines.push('</ul>');
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// --- All assets ---
|
|
631
682
|
if (args.folderUrl) {
|
|
632
|
-
messageLines.push(
|
|
683
|
+
messageLines.push('<strong>All assets</strong> — full GitHub archive');
|
|
684
|
+
messageLines.push('<ul>');
|
|
685
|
+
messageLines.push(`<li><a href="${args.folderUrl}">Browse folder</a></li>`);
|
|
686
|
+
messageLines.push('</ul>');
|
|
633
687
|
}
|
|
634
|
-
messageLines.push('</ul>');
|
|
635
688
|
|
|
636
689
|
await email.send({
|
|
637
690
|
sender: 'internal', // resolves to alerts@{brandDomain}
|