backend-manager 5.10.0 → 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.
|
@@ -231,16 +231,19 @@ AI provider defaults live in code (openai for structure, anthropic for SVG — e
|
|
|
231
231
|
|
|
232
232
|
## Asset hosting (production cron flow)
|
|
233
233
|
|
|
234
|
-
The frequent cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo
|
|
234
|
+
The frequent cron uploads per-section PNGs + the rendered `newsletter.html` + `newsletter.md` + `summary.md` to the public `itw-creative-works/newsletter-assets` repo. Each brand pushes to its own branch (branch name = brandId) — zero contention when multiple brands upload concurrently. New brand branches are created automatically as empty orphans on first upload. Folder layout:
|
|
235
235
|
|
|
236
236
|
```
|
|
237
|
-
{brandId}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
Branch: {brandId}
|
|
238
|
+
content/{campaignId}/
|
|
239
|
+
section-N.png — per-section illustration (embedded in HTML)
|
|
240
|
+
newsletter.html — final rendered email-safe HTML
|
|
241
|
+
newsletter.md — programmatic markdown view (per-section ## blocks, ready for Beehiiv paste)
|
|
242
|
+
summary.md — short editorial recap (2-3 sentences)
|
|
242
243
|
```
|
|
243
244
|
|
|
245
|
+
The `main` branch is a viewer hub (GitHub Pages) — no newsletter content.
|
|
246
|
+
|
|
244
247
|
`newsletter.md` is built programmatically from the same `structure` JSON the HTML is rendered from (no AI cost) by `lib/markdown-renderer.js`. Each section/dispatch becomes a standalone `## heading` block — drop it into the Beehiiv editor one block at a time and insert ad blocks between dispatches.
|
|
245
248
|
|
|
246
249
|
The `campaignId` is the same Firestore doc ID the cron uses for the generated `marketing-campaigns/{newId}` doc, reserved up front so the GitHub URLs and the Firestore doc always match.
|
|
@@ -252,7 +255,7 @@ marketing-campaigns/{newId}: {
|
|
|
252
255
|
settings: { subject, preheader, contentHtml, ... },
|
|
253
256
|
assets: {
|
|
254
257
|
campaignId, // same as the doc id
|
|
255
|
-
folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/
|
|
258
|
+
folderUrl, // https://github.com/itw-creative-works/newsletter-assets/tree/{brandId}/content/{campaignId}
|
|
256
259
|
htmlUrl, // https://raw.githubusercontent.com/.../newsletter.html — paste this into Beehiiv as one block
|
|
257
260
|
markdownUrl, // https://raw.githubusercontent.com/.../newsletter.md — per-section blocks (ads between)
|
|
258
261
|
summaryUrl, // https://raw.githubusercontent.com/.../summary.md — share-snippet recap
|
package/package.json
CHANGED
|
@@ -3,18 +3,23 @@
|
|
|
3
3
|
* rendered `newsletter.html`) to the public `itw-creative-works/newsletter-assets`
|
|
4
4
|
* GitHub repo as a single atomic commit.
|
|
5
5
|
*
|
|
6
|
+
* Each brand pushes to its own branch (branch name = brandId). This
|
|
7
|
+
* eliminates race conditions — 30+ brands can upload concurrently with
|
|
8
|
+
* zero contention. The `main` branch hosts only the viewer page
|
|
9
|
+
* (index.html on GitHub Pages) for browser-renderable previews.
|
|
10
|
+
*
|
|
6
11
|
* Returns publicly resolvable URLs:
|
|
7
|
-
* - Image URLs (`raw.githubusercontent.com
|
|
8
|
-
*
|
|
12
|
+
* - Image URLs (`raw.githubusercontent.com/{owner}/{repo}/{brandId}/...`):
|
|
13
|
+
* embedded in the HTML via <img src=...>
|
|
9
14
|
* - HTML URL (`raw.githubusercontent.com`): download link for manual paste
|
|
10
|
-
*
|
|
11
|
-
*
|
|
15
|
+
* - Preview URL (`*.github.io/?path=...`): viewer page that fetches and
|
|
16
|
+
* renders the newsletter HTML in-browser
|
|
12
17
|
*
|
|
13
18
|
* Public-safety guarantees baked in:
|
|
14
19
|
* - Only accepts PNG buffers for images — verified by magic-byte check
|
|
15
20
|
* - HTML must be a non-empty string (no buffers, no other types)
|
|
16
21
|
* - Path validated against a strict allowlist regex per file kind
|
|
17
|
-
* - Repo
|
|
22
|
+
* - Repo owner hardcoded (no env override) so a misconfigured caller
|
|
18
23
|
* can't redirect uploads elsewhere
|
|
19
24
|
* - One atomic commit per newsletter (Git Trees API)
|
|
20
25
|
*
|
|
@@ -25,19 +30,24 @@ const { Octokit } = require('@octokit/rest');
|
|
|
25
30
|
|
|
26
31
|
const REPO_OWNER = 'itw-creative-works';
|
|
27
32
|
const REPO_NAME = 'newsletter-assets';
|
|
28
|
-
const REPO_BRANCH = 'main';
|
|
29
33
|
|
|
30
|
-
|
|
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
|
+
|
|
31
40
|
const PAGES_BASE = `https://${REPO_OWNER}.github.io/${REPO_NAME}`;
|
|
32
41
|
|
|
33
|
-
//
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
42
|
+
// Fallback RAW_BASE for callers that don't have a brand context
|
|
43
|
+
const RAW_BASE = `https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/main`;
|
|
44
|
+
|
|
45
|
+
// `content/{campaignId}/section-N.png` — brand is the branch, not the path
|
|
46
|
+
const CONTENT_DIR = 'content';
|
|
47
|
+
const IMAGE_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/section-\d+\.png$/;
|
|
48
|
+
const HTML_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.html$/;
|
|
49
|
+
const MARKDOWN_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/newsletter\.md$/;
|
|
50
|
+
const SUMMARY_PATH_REGEX = /^content\/[A-Za-z0-9_-]+\/summary\.md$/;
|
|
41
51
|
|
|
42
52
|
// PNG magic bytes: 89 50 4E 47 0D 0A 1A 0A
|
|
43
53
|
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
@@ -50,22 +60,25 @@ const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
|
50
60
|
* Optional — pass an empty array or omit if
|
|
51
61
|
* you only want to upload the HTML (rare).
|
|
52
62
|
* @param {string} [args.html] - The final rendered newsletter HTML. Uploaded as
|
|
53
|
-
* `
|
|
63
|
+
* `content/{campaignId}/newsletter.html`.
|
|
54
64
|
* @param {string} [args.markdown] - Programmatic markdown view of the newsletter.
|
|
55
|
-
* Uploaded as `
|
|
65
|
+
* Uploaded as `content/{campaignId}/newsletter.md`.
|
|
56
66
|
* @param {string} [args.summary] - Short editorial recap (2-3 sentences). Uploaded
|
|
57
|
-
* as `
|
|
67
|
+
* as `content/{campaignId}/summary.md`.
|
|
58
68
|
* @param {string} args.brandId - lowercase brand slug (e.g. 'somiibo')
|
|
59
69
|
* @param {string} args.campaignId - Consumer-side `marketing-campaigns/{id}` Firestore doc ID.
|
|
60
70
|
* Folder names use this verbatim — stable forever.
|
|
61
71
|
* @param {string} [args.subject] - Newsletter subject. Embedded in the commit message so
|
|
62
72
|
* `git log` reads as a human-browseable history.
|
|
63
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.
|
|
64
77
|
* @param {string} [args.token] - GitHub token (defaults to process.env.GH_TOKEN)
|
|
65
78
|
* @param {object} [args.assistant] - logger
|
|
66
79
|
* @returns {Promise<{ urls: string[], paths: string[], htmlUrl?: string, htmlPath?: string, previewUrl?: string, folderUrl: string, commitSha: string }>}
|
|
67
80
|
*/
|
|
68
|
-
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 }) {
|
|
69
82
|
const hasImages = Array.isArray(images) && images.length > 0;
|
|
70
83
|
const hasHtml = typeof html === 'string' && html.length > 0;
|
|
71
84
|
const hasMarkdown = typeof markdown === 'string' && markdown.length > 0;
|
|
@@ -86,13 +99,21 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
86
99
|
|
|
87
100
|
const log = (msg) => assistant?.log ? assistant.log(`[image-host] ${msg}`) : null;
|
|
88
101
|
|
|
102
|
+
// Brand branch — each brand gets its own branch, zero cross-brand contention
|
|
103
|
+
const branch = brandId;
|
|
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;
|
|
109
|
+
|
|
89
110
|
// 1. Build the list of files to commit. Validate each before we touch GitHub.
|
|
90
111
|
const files = [];
|
|
91
112
|
|
|
92
113
|
if (hasImages) {
|
|
93
114
|
for (let i = 0; i < images.length; i++) {
|
|
94
115
|
const img = images[i];
|
|
95
|
-
const path = `${
|
|
116
|
+
const path = `${CONTENT_DIR}/${campaignId}/section-${i + 1}.png`;
|
|
96
117
|
|
|
97
118
|
if (!IMAGE_PATH_REGEX.test(path)) {
|
|
98
119
|
throw new Error(`image-host: refusing to upload — invalid image path "${path}"`);
|
|
@@ -115,7 +136,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
115
136
|
}
|
|
116
137
|
|
|
117
138
|
if (hasHtml) {
|
|
118
|
-
const path = `${
|
|
139
|
+
const path = `${CONTENT_DIR}/${campaignId}/newsletter.html`;
|
|
119
140
|
|
|
120
141
|
if (!HTML_PATH_REGEX.test(path)) {
|
|
121
142
|
throw new Error(`image-host: refusing to upload — invalid html path "${path}"`);
|
|
@@ -129,7 +150,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
129
150
|
}
|
|
130
151
|
|
|
131
152
|
if (hasMarkdown) {
|
|
132
|
-
const path = `${
|
|
153
|
+
const path = `${CONTENT_DIR}/${campaignId}/newsletter.md`;
|
|
133
154
|
|
|
134
155
|
if (!MARKDOWN_PATH_REGEX.test(path)) {
|
|
135
156
|
throw new Error(`image-host: refusing to upload — invalid markdown path "${path}"`);
|
|
@@ -143,7 +164,7 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
143
164
|
}
|
|
144
165
|
|
|
145
166
|
if (hasSummary) {
|
|
146
|
-
const path = `${
|
|
167
|
+
const path = `${CONTENT_DIR}/${campaignId}/summary.md`;
|
|
147
168
|
|
|
148
169
|
if (!SUMMARY_PATH_REGEX.test(path)) {
|
|
149
170
|
throw new Error(`image-host: refusing to upload — invalid summary path "${path}"`);
|
|
@@ -164,26 +185,41 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
164
185
|
hasSummary ? 'summary.md' : null,
|
|
165
186
|
].filter(Boolean).join(' + ');
|
|
166
187
|
|
|
167
|
-
log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME} → ${
|
|
188
|
+
log(`uploading ${fileSummary} to ${REPO_OWNER}/${REPO_NAME}:${branch} → ${CONTENT_DIR}/${campaignId}/`);
|
|
168
189
|
|
|
169
190
|
const octokit = new Octokit({ auth: githubToken });
|
|
170
191
|
|
|
171
|
-
// 2.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
ref: `heads/${
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
// 2. Ensure the brand branch exists. New branches start empty (orphan
|
|
193
|
+
// commit with an empty tree) — no files from main, no shared history.
|
|
194
|
+
let branchSha;
|
|
195
|
+
try {
|
|
196
|
+
const { data } = await octokit.rest.git.getRef({ owner: REPO_OWNER, repo: REPO_NAME, ref: `heads/${branch}` });
|
|
197
|
+
branchSha = data.object.sha;
|
|
198
|
+
} catch (e) {
|
|
199
|
+
if (e.status === 404) {
|
|
200
|
+
// Create an empty tree → orphan commit → brand branch
|
|
201
|
+
const { data: emptyTree } = await octokit.rest.git.createTree({
|
|
202
|
+
owner: REPO_OWNER, repo: REPO_NAME, tree: [],
|
|
203
|
+
});
|
|
204
|
+
const { data: orphanCommit } = await octokit.rest.git.createCommit({
|
|
205
|
+
owner: REPO_OWNER, repo: REPO_NAME,
|
|
206
|
+
message: `Initialize ${branch} branch`,
|
|
207
|
+
tree: emptyTree.sha,
|
|
208
|
+
parents: [],
|
|
209
|
+
});
|
|
210
|
+
await octokit.rest.git.createRef({
|
|
211
|
+
owner: REPO_OWNER, repo: REPO_NAME,
|
|
212
|
+
ref: `refs/heads/${branch}`,
|
|
213
|
+
sha: orphanCommit.sha,
|
|
214
|
+
});
|
|
215
|
+
branchSha = orphanCommit.sha;
|
|
216
|
+
log(`created empty branch "${branch}"`);
|
|
217
|
+
} else {
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
185
221
|
|
|
186
|
-
// 3. Create
|
|
222
|
+
// 3. Create blobs (content-addressed — safe to reuse if we ever add retries)
|
|
187
223
|
const treeItems = [];
|
|
188
224
|
|
|
189
225
|
for (const file of files) {
|
|
@@ -202,34 +238,55 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
202
238
|
});
|
|
203
239
|
}
|
|
204
240
|
|
|
205
|
-
// 4.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
base_tree: baseCommit.tree.sha,
|
|
210
|
-
tree: treeItems,
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
// 5. Commit. Default message format: "[brand] campaignId — Subject" so
|
|
214
|
-
// `git log` doubles as a human-readable index of the (opaque) folder names.
|
|
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.
|
|
215
245
|
const defaultSubject = subject ? subject.trim() : `${files.length} newsletter asset${files.length === 1 ? '' : 's'}`;
|
|
216
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;
|
|
217
255
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
+
});
|
|
265
|
+
|
|
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
|
+
}
|
|
233
290
|
|
|
234
291
|
// 7. Split the URL list by kind so callers can grab each independently.
|
|
235
292
|
const imageFiles = files.filter((f) => f.kind === 'image');
|
|
@@ -238,29 +295,29 @@ async function uploadAssets({ images, html, markdown, summary, brandId, campaign
|
|
|
238
295
|
const summaryFile = files.find((f) => f.kind === 'summary');
|
|
239
296
|
|
|
240
297
|
const result = {
|
|
241
|
-
urls: imageFiles.map((f) => `${
|
|
298
|
+
urls: imageFiles.map((f) => `${assetBase}/${f.path}`),
|
|
242
299
|
paths: imageFiles.map((f) => f.path),
|
|
243
|
-
folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${
|
|
300
|
+
folderUrl: `https://github.com/${REPO_OWNER}/${REPO_NAME}/tree/${branch}/${CONTENT_DIR}/${campaignId}`,
|
|
244
301
|
commitSha: newCommit.sha,
|
|
245
302
|
};
|
|
246
303
|
|
|
247
304
|
if (htmlFile) {
|
|
248
|
-
result.htmlUrl = `${
|
|
305
|
+
result.htmlUrl = `${assetBase}/${htmlFile.path}`;
|
|
249
306
|
result.htmlPath = htmlFile.path;
|
|
250
|
-
result.previewUrl = `${PAGES_BASE}
|
|
307
|
+
result.previewUrl = `${PAGES_BASE}/?path=${encodeURIComponent(htmlFile.path)}&branch=${encodeURIComponent(branch)}`;
|
|
251
308
|
}
|
|
252
309
|
|
|
253
310
|
if (markdownFile) {
|
|
254
|
-
result.markdownUrl = `${
|
|
311
|
+
result.markdownUrl = `${assetBase}/${markdownFile.path}`;
|
|
255
312
|
result.markdownPath = markdownFile.path;
|
|
256
313
|
}
|
|
257
314
|
|
|
258
315
|
if (summaryFile) {
|
|
259
|
-
result.summaryUrl = `${
|
|
316
|
+
result.summaryUrl = `${assetBase}/${summaryFile.path}`;
|
|
260
317
|
result.summaryPath = summaryFile.path;
|
|
261
318
|
}
|
|
262
319
|
|
|
263
|
-
log(`committed ${newCommit.sha.slice(0, 7)} — folder: ${result.folderUrl}`);
|
|
320
|
+
log(`committed ${newCommit.sha.slice(0, 7)} to ${branch} — folder: ${result.folderUrl}`);
|
|
264
321
|
|
|
265
322
|
return result;
|
|
266
323
|
}
|
|
@@ -279,9 +336,9 @@ function validateCampaignId(campaignId) {
|
|
|
279
336
|
|
|
280
337
|
module.exports = {
|
|
281
338
|
uploadAssets,
|
|
339
|
+
USE_CDN_URLS,
|
|
282
340
|
REPO_OWNER,
|
|
283
341
|
REPO_NAME,
|
|
284
|
-
REPO_BRANCH,
|
|
285
342
|
RAW_BASE,
|
|
286
343
|
PAGES_BASE,
|
|
287
344
|
};
|
|
@@ -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}
|
|
@@ -96,8 +96,10 @@ module.exports = {
|
|
|
96
96
|
const env = process.env;
|
|
97
97
|
|
|
98
98
|
// --- Apply env overrides into newsletterConfig ---
|
|
99
|
-
// Newsletter config
|
|
100
|
-
const
|
|
99
|
+
// Newsletter config lives under marketing.newsletter.content (array or object).
|
|
100
|
+
const rawContent = config.marketing?.newsletter?.content;
|
|
101
|
+
const contentEntry = Array.isArray(rawContent) ? rawContent[0] : rawContent;
|
|
102
|
+
const newsletterConfig = JSON.parse(JSON.stringify(contentEntry || {}));
|
|
101
103
|
|
|
102
104
|
if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
|
|
103
105
|
newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };
|