backend-manager 5.1.2 → 5.1.4
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/CHANGELOG.md +29 -0
- package/README.md +15 -0
- package/docs/marketing-campaigns.md +41 -4
- package/docs/testing.md +45 -0
- package/package.json +1 -1
- package/src/cli/commands/emulator.js +18 -1
- package/src/cli/commands/test.js +18 -0
- package/src/defaults/CLAUDE.md +7 -5
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
- package/src/manager/index.js +82 -5
- package/src/manager/libraries/ai/index.js +21 -0
- package/src/manager/libraries/ai/providers/openai.js +75 -0
- package/src/manager/libraries/email/data/disposable-domains.json +12 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
- package/src/manager/libraries/email/generators/lib/structure.js +19 -2
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
- package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
- package/src/manager/libraries/email/generators/newsletter.js +152 -5
- package/src/manager/libraries/email/providers/beehiiv.js +7 -1
- package/src/manager/routes/admin/post/post.js +3 -3
- package/src/manager/routes/test/health/get.js +17 -0
- package/src/test/run-tests.js +30 -0
- package/src/test/runner.js +11 -8
- package/src/test/utils/test-mode-file.js +192 -0
- package/test/marketing/fixtures/clean.json +2 -3
- package/test/marketing/fixtures/editorial.json +2 -3
- package/test/marketing/fixtures/field-report.json +3 -4
- package/test/marketing/newsletter-generate.js +62 -48
- package/test/marketing/newsletter-templates.js +12 -33
- package/test/routes/admin/create-post.js +2 -2
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*
|
|
17
17
|
* Content schema (template-owned — different from the classic schema):
|
|
18
18
|
* { tldr, dateline, dispatches: [{ kicker, headline, byline, location,
|
|
19
|
-
* lede, dispatch, dataPoints?,
|
|
19
|
+
* lede, dispatch, dataPoints?, image_prompt }] }
|
|
20
20
|
*/
|
|
21
21
|
const {
|
|
22
22
|
shell,
|
|
@@ -337,7 +337,7 @@ const FIELD_REPORT_SCHEMA = {
|
|
|
337
337
|
items: {
|
|
338
338
|
type: 'object',
|
|
339
339
|
additionalProperties: false,
|
|
340
|
-
required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', '
|
|
340
|
+
required: ['kicker', 'headline', 'byline', 'location', 'lede', 'dispatch', 'image_prompt', 'dataPoints'],
|
|
341
341
|
properties: {
|
|
342
342
|
kicker: { type: 'string', maxLength: 30 }, // "DISPATCH" / "FIELD NOTES" / "WATCH" / "BRIEF"
|
|
343
343
|
headline: { type: 'string', maxLength: 90 }, // Tight, declarative
|
|
@@ -358,15 +358,10 @@ const FIELD_REPORT_SCHEMA = {
|
|
|
358
358
|
},
|
|
359
359
|
},
|
|
360
360
|
},
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
properties: {
|
|
366
|
-
label: { type: 'string' },
|
|
367
|
-
url: { type: 'string' },
|
|
368
|
-
},
|
|
369
|
-
},
|
|
361
|
+
// CTAs intentionally not part of the contract — the AI cannot author URLs
|
|
362
|
+
// reliably (no source URLs available, no brand-site knowledge). Newsletters
|
|
363
|
+
// are self-contained; outbound links come from sponsorship blocks rendered
|
|
364
|
+
// by the template shell, not from generated dispatch bodies.
|
|
370
365
|
image_prompt: { type: 'string' },
|
|
371
366
|
},
|
|
372
367
|
},
|
|
@@ -390,7 +385,6 @@ function normalizeFieldReport(structure, { brand } = {}) {
|
|
|
390
385
|
lede: d.lede || '',
|
|
391
386
|
dispatch: d.dispatch || '',
|
|
392
387
|
dataPoints: Array.isArray(d.dataPoints) ? d.dataPoints.slice(0, 4) : [],
|
|
393
|
-
cta: d.cta || null,
|
|
394
388
|
image_prompt: d.image_prompt || '',
|
|
395
389
|
}));
|
|
396
390
|
|
|
@@ -439,6 +433,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
|
|
|
439
433
|
'CONTENT REQUIREMENTS:',
|
|
440
434
|
'- subject: ≤60 chars, declarative, no clickbait. Reads like a wire-service headline.',
|
|
441
435
|
'- preheader: ≤100 chars, complements subject.',
|
|
436
|
+
'- summary: 2-3 sentences, plain text, no markdown. An editorial recap of the whole issue (distinct from the in-template `tldr` strip — the summary is consumed by external surfaces like the share preview / summary.md file).',
|
|
437
|
+
'- tags: 3-5 topical tags. Lowercase, kebab-case, no spaces. Examples: "linkedin", "creator-economy", "platform-policy". Empty array OK if nothing fits cleanly.',
|
|
442
438
|
'- tldr: 2 short sentences max, ~200 chars total. Present tense. Reads like a terminal briefing — what changed, why it matters.',
|
|
443
439
|
'- dateline: one city or "REMOTE" — sets where the issue is filed from. UPPERCASE. Example: "LOS ANGELES" / "REMOTE" / "NEW YORK".',
|
|
444
440
|
'- dispatches: 3-5 items, each is a discrete filed story.',
|
|
@@ -449,8 +445,8 @@ function buildPrompt({ brand, newsletterConfig, sources }) {
|
|
|
449
445
|
' - lede: one paragraph (1-2 sentences), italic-serif quality, sets the scene in present tense. Reads like the opening of a New Yorker article.',
|
|
450
446
|
' - dispatch: the body. 90-160 words. Markdown allowed. Present tense. Specific. End with the practical implication for the reader.',
|
|
451
447
|
' - dataPoints: 2-4 short label/value pairs IF there are meaningful numbers in the topic. Example: [{label:"USERS REACHED",value:"12.4K"},{label:"WoW GROWTH",value:"+38%"}]. SKIP this (empty array) if the topic has no quantifiable data.',
|
|
452
|
-
' - cta: { label, url } OR null. Label is mono, uppercase, short (≤24 chars). Examples: "READ THE BRIEF", "SEE THE NUMBERS".',
|
|
453
448
|
' - image_prompt: one-sentence visual brief for an illustrator. Specific. Think editorial illustration, not stock photo.',
|
|
449
|
+
' - DO NOT invent CTAs, "read more" links, or any URLs anywhere in the dispatch. The dispatch must stand on its own without sending the reader off-property.',
|
|
454
450
|
`- signoff: render as TWO LINES with a literal \\n between them. Format: a short closing phrase + the desk name. Examples:\n "— Stay sharp,\\nThe ${brandName} Desk"\n "— Until next dispatch,\\nThe ${brandName} Editorial Desk"\n Do NOT write a summary, motto, or thematic sentence. This is a literal sign-off.`,
|
|
455
451
|
'',
|
|
456
452
|
'OUTPUT:',
|
|
@@ -488,7 +484,7 @@ module.exports = {
|
|
|
488
484
|
name: 'field-report',
|
|
489
485
|
description: 'Wire-service correspondent × Bloomberg terminal. Dispatch kickers, datelines, mono data callouts, end-of-dispatch terminators.',
|
|
490
486
|
requires: ['subject', 'preheader', 'tldr', 'dateline', 'dispatches', 'signoff'],
|
|
491
|
-
optional: ['citations', 'dataPoints', '
|
|
487
|
+
optional: ['citations', 'dataPoints', 'image_prompt'],
|
|
492
488
|
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
493
489
|
},
|
|
494
490
|
schema: FIELD_REPORT_SCHEMA,
|
|
@@ -24,6 +24,7 @@ const { filterSources } = require('./lib/filter.js');
|
|
|
24
24
|
const { generateStructure } = require('./lib/structure.js');
|
|
25
25
|
const { generateSectionImage } = require('./lib/svg-illustrator.js');
|
|
26
26
|
const { renderNewsletter } = require('./lib/mjml-template.js');
|
|
27
|
+
const { renderMarkdown } = require('./lib/markdown-renderer.js');
|
|
27
28
|
const { uploadAssets, RAW_BASE } = require('./lib/image-host.js');
|
|
28
29
|
|
|
29
30
|
/**
|
|
@@ -208,16 +209,34 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
208
209
|
sponsorships,
|
|
209
210
|
});
|
|
210
211
|
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
212
|
+
// 3a. Programmatic markdown view — same `structure`, walked by code (no AI).
|
|
213
|
+
// The markdown is split into ## blocks per section so it can be pasted
|
|
214
|
+
// into Beehiiv's block editor with ad blocks inserted between dispatches.
|
|
215
|
+
// `summary` is a separate short editorial recap, written by the AI
|
|
216
|
+
// during the structure step.
|
|
217
|
+
const markdown = renderMarkdown({
|
|
218
|
+
structure,
|
|
219
|
+
brand,
|
|
220
|
+
imagePaths,
|
|
221
|
+
sponsorships,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const summaryText = (structure.summary || '').trim();
|
|
225
|
+
|
|
226
|
+
// 3b. Upload the rendered HTML + markdown + summary to GitHub alongside the
|
|
227
|
+
// images. All four kinds live in the same {brandId}/{campaignId}/ folder.
|
|
228
|
+
// The folder URL becomes the canonical archive of the issue.
|
|
214
229
|
let assetsFolderUrl = null;
|
|
215
230
|
let htmlUrl = null;
|
|
231
|
+
let markdownUrl = null;
|
|
232
|
+
let summaryUrl = null;
|
|
216
233
|
|
|
217
234
|
if (host === 'github') {
|
|
218
235
|
try {
|
|
219
236
|
const upload = await uploadAssets({
|
|
220
237
|
html,
|
|
238
|
+
markdown,
|
|
239
|
+
summary: summaryText || undefined,
|
|
221
240
|
brandId: brand?.id,
|
|
222
241
|
campaignId,
|
|
223
242
|
subject: structure.subject,
|
|
@@ -225,6 +244,8 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
225
244
|
});
|
|
226
245
|
assetsFolderUrl = upload.folderUrl;
|
|
227
246
|
htmlUrl = upload.htmlUrl;
|
|
247
|
+
markdownUrl = upload.markdownUrl || null;
|
|
248
|
+
summaryUrl = upload.summaryUrl || null;
|
|
228
249
|
} catch (e) {
|
|
229
250
|
assistant.error(`Newsletter generator: HTML upload failed — ${e.message}`);
|
|
230
251
|
}
|
|
@@ -238,6 +259,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
238
259
|
// succeeds regardless. beehiivConfig was already resolved at the top
|
|
239
260
|
// of the function for the initial enabled-check.
|
|
240
261
|
let beehiivPostId = null;
|
|
262
|
+
let beehiivFailureReason = null;
|
|
241
263
|
|
|
242
264
|
if (host === 'github' && beehiivConfig?.enabled) {
|
|
243
265
|
try {
|
|
@@ -248,6 +270,7 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
248
270
|
subject: structure.subject,
|
|
249
271
|
preheader: structure.preheader,
|
|
250
272
|
content: html,
|
|
273
|
+
contentTags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
251
274
|
status: 'draft',
|
|
252
275
|
});
|
|
253
276
|
|
|
@@ -256,13 +279,36 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
256
279
|
assistant.log(`Newsletter generator: Beehiiv draft created — ${beehiivPostId}`);
|
|
257
280
|
} else {
|
|
258
281
|
// Expected today until Enterprise plan — log, do not throw.
|
|
259
|
-
|
|
282
|
+
beehiivFailureReason = result?.error || 'unknown error';
|
|
283
|
+
assistant.log(`Newsletter generator: Beehiiv draft upload skipped/failed — ${beehiivFailureReason}`);
|
|
260
284
|
}
|
|
261
285
|
} catch (e) {
|
|
286
|
+
beehiivFailureReason = e.message;
|
|
262
287
|
assistant.error(`Newsletter generator: Beehiiv draft upload threw — ${e.message}`);
|
|
263
288
|
}
|
|
264
289
|
}
|
|
265
290
|
|
|
291
|
+
// 3d. Fallback alert email — sent to the brand's internal alerts inbox when
|
|
292
|
+
// Beehiiv draft creation fails. The newsletter is fully generated and
|
|
293
|
+
// archived to GitHub at this point, so the email contains everything
|
|
294
|
+
// needed for a human to manually upload to Beehiiv: HTML URL (one-shot
|
|
295
|
+
// paste), markdown URL (per-section blocks for ad insertion), summary
|
|
296
|
+
// URL, and the tags to set. Failure of THIS email is logged but never
|
|
297
|
+
// blocks the cron — the campaign doc is still written either way.
|
|
298
|
+
if (beehiivFailureReason && htmlUrl) {
|
|
299
|
+
await sendBeehiivFallbackEmail(Manager, assistant, {
|
|
300
|
+
brand,
|
|
301
|
+
subject: structure.subject,
|
|
302
|
+
preheader: structure.preheader,
|
|
303
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
304
|
+
htmlUrl,
|
|
305
|
+
markdownUrl,
|
|
306
|
+
summaryUrl,
|
|
307
|
+
folderUrl: assetsFolderUrl,
|
|
308
|
+
reason: beehiivFailureReason,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
// 4. Mark sources as used on parent server (unless caller opted out)
|
|
267
313
|
if (!opts.skipClaim) {
|
|
268
314
|
const parentUrl = Manager.config?.parent;
|
|
@@ -308,8 +354,11 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
308
354
|
campaignId,
|
|
309
355
|
folderUrl: assetsFolderUrl,
|
|
310
356
|
htmlUrl,
|
|
357
|
+
markdownUrl,
|
|
358
|
+
summaryUrl,
|
|
311
359
|
imageUrls: imagePaths,
|
|
312
360
|
beehiivPostId,
|
|
361
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
313
362
|
} : null;
|
|
314
363
|
|
|
315
364
|
return {
|
|
@@ -318,14 +367,112 @@ async function generate(Manager, assistant, settings, opts = {}) {
|
|
|
318
367
|
preheader: structure.preheader,
|
|
319
368
|
content: '', // legacy markdown field, unused when contentHtml is set
|
|
320
369
|
contentHtml: html, // pre-rendered email-safe HTML
|
|
370
|
+
contentMarkdown: markdown, // programmatic markdown view (per-section blocks for Beehiiv paste)
|
|
371
|
+
summary: summaryText, // editorial recap (separate from preheader)
|
|
372
|
+
tags: Array.isArray(structure.tags) ? structure.tags : [],
|
|
321
373
|
structure, // structured copy for debugging / migration
|
|
322
374
|
mjml, // raw MJML for debugging
|
|
323
375
|
images: opts._lastImages || [], // image buffers for the iteration test to persist locally
|
|
324
|
-
assets, // GitHub asset URLs (folder, html, images) — null in local mode
|
|
376
|
+
assets, // GitHub asset URLs (folder, html, md, summary, images) — null in local mode
|
|
325
377
|
meta, // per-step provider/model/cost/timing telemetry
|
|
326
378
|
};
|
|
327
379
|
}
|
|
328
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Send an internal alert email when Beehiiv draft creation fails so the
|
|
383
|
+
* brand team knows there's a ready newsletter waiting for manual upload.
|
|
384
|
+
*
|
|
385
|
+
* Uses `sender: 'internal'` which auto-resolves to `alerts@{brandDomain}` via
|
|
386
|
+
* the SENDERS table in email/constants.js. Recipient is the same alerts@
|
|
387
|
+
* address — a self-addressed operational alert, no human inbox involved.
|
|
388
|
+
*
|
|
389
|
+
* Errors here are logged but never thrown — the alert is best-effort. If
|
|
390
|
+
* brand.url is unset, the email is skipped entirely.
|
|
391
|
+
*
|
|
392
|
+
* @param {object} Manager
|
|
393
|
+
* @param {object} assistant
|
|
394
|
+
* @param {object} args
|
|
395
|
+
* @param {object} args.brand
|
|
396
|
+
* @param {string} args.subject - The newsletter's subject (used in the alert subject)
|
|
397
|
+
* @param {string} args.preheader
|
|
398
|
+
* @param {string[]} args.tags
|
|
399
|
+
* @param {string} args.htmlUrl - GitHub raw URL to the fully-rendered HTML
|
|
400
|
+
* @param {string} [args.markdownUrl] - GitHub raw URL to the per-section markdown
|
|
401
|
+
* @param {string} [args.summaryUrl] - GitHub raw URL to the 2-3 sentence summary
|
|
402
|
+
* @param {string} [args.folderUrl] - GitHub folder URL (browseable archive)
|
|
403
|
+
* @param {string} args.reason - Why Beehiiv upload failed (API error message)
|
|
404
|
+
*/
|
|
405
|
+
async function sendBeehiivFallbackEmail(Manager, assistant, args) {
|
|
406
|
+
// Send TO and FROM the same internal alerts inbox — alerts@{brandDomain}.
|
|
407
|
+
// The `sender: 'internal'` SENDERS entry already resolves the FROM address
|
|
408
|
+
// to this; we mirror the same domain for the TO so it's a self-addressed
|
|
409
|
+
// operational alert (no human inbox involved).
|
|
410
|
+
const brandDomain = (Manager.config?.brand?.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
411
|
+
|
|
412
|
+
if (!brandDomain) {
|
|
413
|
+
assistant.log('Newsletter generator: Beehiiv fallback email skipped — no brand.url');
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const alertsEmail = `alerts@${brandDomain}`;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const email = Manager.Email(assistant);
|
|
421
|
+
const messageLines = [];
|
|
422
|
+
|
|
423
|
+
messageLines.push(`<strong>Beehiiv draft creation failed</strong> — the newsletter is generated and archived, but needs to be manually uploaded to Beehiiv.`);
|
|
424
|
+
messageLines.push('');
|
|
425
|
+
messageLines.push(`<strong>Failure reason:</strong> ${args.reason}`);
|
|
426
|
+
messageLines.push('');
|
|
427
|
+
messageLines.push('<strong>Newsletter details:</strong>');
|
|
428
|
+
messageLines.push('<ul>');
|
|
429
|
+
messageLines.push(`<li><strong>Subject:</strong> ${args.subject}</li>`);
|
|
430
|
+
messageLines.push(`<li><strong>Preheader:</strong> ${args.preheader || '(none)'}</li>`);
|
|
431
|
+
if (args.tags?.length) {
|
|
432
|
+
messageLines.push(`<li><strong>Tags:</strong> ${args.tags.join(', ')}</li>`);
|
|
433
|
+
}
|
|
434
|
+
messageLines.push('</ul>');
|
|
435
|
+
messageLines.push('');
|
|
436
|
+
messageLines.push('<strong>Assets (manual upload links):</strong>');
|
|
437
|
+
messageLines.push('<ul>');
|
|
438
|
+
messageLines.push(`<li><strong>Full HTML</strong> (one-shot paste): <a href="${args.htmlUrl}">${args.htmlUrl}</a></li>`);
|
|
439
|
+
if (args.markdownUrl) {
|
|
440
|
+
messageLines.push(`<li><strong>Per-section markdown</strong> (paste as separate blocks, ads between): <a href="${args.markdownUrl}">${args.markdownUrl}</a></li>`);
|
|
441
|
+
}
|
|
442
|
+
if (args.summaryUrl) {
|
|
443
|
+
messageLines.push(`<li><strong>Summary</strong> (2-3 sentence recap): <a href="${args.summaryUrl}">${args.summaryUrl}</a></li>`);
|
|
444
|
+
}
|
|
445
|
+
if (args.folderUrl) {
|
|
446
|
+
messageLines.push(`<li><strong>All assets</strong>: <a href="${args.folderUrl}">${args.folderUrl}</a></li>`);
|
|
447
|
+
}
|
|
448
|
+
messageLines.push('</ul>');
|
|
449
|
+
|
|
450
|
+
await email.send({
|
|
451
|
+
sender: 'internal', // resolves to alerts@{brandDomain}
|
|
452
|
+
to: alertsEmail,
|
|
453
|
+
copy: false, // self-addressed operational alert — no CC/BCC clutter
|
|
454
|
+
subject: `Newsletter ready for manual Beehiiv upload: "${args.subject}"`,
|
|
455
|
+
template: 'core/card',
|
|
456
|
+
categories: ['marketing/newsletter-manual-upload'],
|
|
457
|
+
data: {
|
|
458
|
+
email: {
|
|
459
|
+
preview: `Beehiiv upload failed — newsletter awaiting manual upload from ${args.folderUrl || 'GitHub archive'}`,
|
|
460
|
+
},
|
|
461
|
+
body: {
|
|
462
|
+
title: 'Newsletter Ready for Manual Upload',
|
|
463
|
+
message: messageLines.join('\n'),
|
|
464
|
+
},
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
assistant.log(`Newsletter generator: Beehiiv fallback alert sent to ${alertsEmail}`);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// Best-effort — log and move on. We don't want a misconfigured email
|
|
471
|
+
// setup to break the cron's Firestore write.
|
|
472
|
+
assistant.error(`Newsletter generator: Beehiiv fallback email failed — ${e.message}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
329
476
|
/**
|
|
330
477
|
* Sum tokens + cost across all steps (filter, structure, all SVGs).
|
|
331
478
|
*/
|
|
@@ -329,6 +329,7 @@ async function resolveSegmentIds() {
|
|
|
329
329
|
* @param {string} [options.subject] - Email subject line (defaults to title)
|
|
330
330
|
* @param {string} [options.preheader] - Email preview text
|
|
331
331
|
* @param {string} [options.content] - HTML content body
|
|
332
|
+
* @param {Array<string>} [options.contentTags] - Topical tags attached to the post (Beehiiv `content_tags`)
|
|
332
333
|
* @param {string} [options.status] - 'draft' or 'confirmed' (default: confirmed = send)
|
|
333
334
|
* @param {string} [options.sendAt] - ISO datetime to schedule, or null for immediate
|
|
334
335
|
* @param {Array<string>} [options.segments] - Segment IDs to include
|
|
@@ -342,7 +343,7 @@ async function createPost(options) {
|
|
|
342
343
|
return { success: false, error: 'Publication not found' };
|
|
343
344
|
}
|
|
344
345
|
|
|
345
|
-
const { title, subject, preheader, content, status, sendAt, segments, excludeSegments } = options;
|
|
346
|
+
const { title, subject, preheader, content, contentTags, status, sendAt, segments, excludeSegments } = options;
|
|
346
347
|
|
|
347
348
|
try {
|
|
348
349
|
const body = {
|
|
@@ -355,6 +356,11 @@ async function createPost(options) {
|
|
|
355
356
|
body.body_content = content;
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
// Tags (Beehiiv field is `content_tags`, array of strings)
|
|
360
|
+
if (Array.isArray(contentTags) && contentTags.length) {
|
|
361
|
+
body.content_tags = contentTags;
|
|
362
|
+
}
|
|
363
|
+
|
|
358
364
|
// Scheduling
|
|
359
365
|
if (sendAt && sendAt !== 'now') {
|
|
360
366
|
body.scheduled_at = new Date(sendAt).toISOString();
|
|
@@ -82,7 +82,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
82
82
|
settings.layout = settings.layout;
|
|
83
83
|
settings.date = moment(settings.date || now).subtract(1, 'days').format('YYYY-MM-DD');
|
|
84
84
|
settings.id = settings.id || Math.round(new Date(now).getTime() / 1000);
|
|
85
|
-
settings.
|
|
85
|
+
settings.directory = `src/_posts/${moment(now).format('YYYY')}/${settings.postPath}`;
|
|
86
86
|
settings.githubUser = settings.githubUser || bemRepo.user;
|
|
87
87
|
settings.githubRepo = settings.githubRepo || bemRepo.name;
|
|
88
88
|
|
|
@@ -108,7 +108,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
// Build post file entry
|
|
111
|
-
|
|
111
|
+
settings.path = `${settings.directory}/${settings.date}-${settings.url}.md`;
|
|
112
112
|
const allFiles = [
|
|
113
113
|
...imageFiles.map(img => ({
|
|
114
114
|
path: img.githubPath,
|
|
@@ -116,7 +116,7 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
|
|
|
116
116
|
encoding: 'base64',
|
|
117
117
|
})),
|
|
118
118
|
{
|
|
119
|
-
path:
|
|
119
|
+
path: settings.path,
|
|
120
120
|
content: Buffer.from(formattedContent).toString('base64'),
|
|
121
121
|
encoding: 'base64',
|
|
122
122
|
},
|
|
@@ -1,5 +1,22 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { readTestMode, applyEnvFromFile } = require('../../../../test/utils/test-mode-file.js');
|
|
3
|
+
|
|
1
4
|
module.exports = async ({ assistant, Manager }) => {
|
|
2
5
|
|
|
6
|
+
// Belt-and-suspenders freshness check: re-read the test-mode file before
|
|
7
|
+
// reporting `testExtendedMode`. fs.watch installed in Manager.init usually
|
|
8
|
+
// catches changes within ~50ms, but this handler hits the disk directly to
|
|
9
|
+
// guarantee the runner sees the actual current value even if the watcher
|
|
10
|
+
// missed an event. ~1ms cost on a debug endpoint.
|
|
11
|
+
try {
|
|
12
|
+
const projectDir = path.dirname(Manager.cwd);
|
|
13
|
+
const data = readTestMode(projectDir);
|
|
14
|
+
applyEnvFromFile(data);
|
|
15
|
+
} catch (e) {
|
|
16
|
+
// Non-fatal — if the file can't be read, fall through to whatever
|
|
17
|
+
// process.env already has.
|
|
18
|
+
}
|
|
19
|
+
|
|
3
20
|
const response = {
|
|
4
21
|
status: 'healthy',
|
|
5
22
|
timestamp: new Date().toISOString(),
|
package/src/test/run-tests.js
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
* It reads configuration from BEM_TEST_CONFIG environment variable and runs the test suite
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
// Mark this process as the test runner BEFORE loading any BEM code. Manager.init()
|
|
10
|
+
// auto-detects this and skips Firebase Functions / server / Sentry wiring (which
|
|
11
|
+
// can't run outside a real Functions runtime). This is what lets tests receive a
|
|
12
|
+
// fully-wired Manager + assistant in their context — no per-test stub.
|
|
13
|
+
process.env.BEM_TEST_RUNNER = '1';
|
|
14
|
+
|
|
15
|
+
const path = require('path');
|
|
9
16
|
const TestRunner = require('./runner.js');
|
|
10
17
|
|
|
11
18
|
async function main() {
|
|
@@ -33,10 +40,33 @@ async function main() {
|
|
|
33
40
|
console.error('Warning: Could not initialize Firebase Admin:', error.message);
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// Boot a real Manager. With BEM_TEST_RUNNER set, init() loads libraries +
|
|
44
|
+
// resolves project config but skips the parts that need a Functions runtime
|
|
45
|
+
// (handler wiring, server boot, Sentry, admin.initializeApp re-init).
|
|
46
|
+
// The resulting Manager + assistant are passed into every test context, so
|
|
47
|
+
// tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
|
|
48
|
+
// like production code does — no hand-rolled stubs.
|
|
49
|
+
let Manager = null;
|
|
50
|
+
let assistant = null;
|
|
51
|
+
try {
|
|
52
|
+
const projectDir = testConfig.projectDir || process.cwd();
|
|
53
|
+
const BackendManager = require('../manager/index.js');
|
|
54
|
+
Manager = new BackendManager();
|
|
55
|
+
Manager.init(null, {
|
|
56
|
+
cwd: path.join(projectDir, 'functions'),
|
|
57
|
+
log: false,
|
|
58
|
+
});
|
|
59
|
+
assistant = Manager.Assistant({}, { functionName: 'bem-test-runner', accept: 'json' });
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.error('Warning: Could not initialize BEM Manager for tests:', error.message);
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
// Create and run the test runner
|
|
37
65
|
const runner = new TestRunner({
|
|
38
66
|
...testConfig,
|
|
39
67
|
admin,
|
|
68
|
+
Manager,
|
|
69
|
+
assistant,
|
|
40
70
|
});
|
|
41
71
|
|
|
42
72
|
const results = await runner.run();
|
package/src/test/runner.js
CHANGED
|
@@ -186,15 +186,13 @@ class TestRunner {
|
|
|
186
186
|
if (response.success) {
|
|
187
187
|
console.log(chalk.green('✓'));
|
|
188
188
|
|
|
189
|
-
//
|
|
190
|
-
|
|
189
|
+
// Report the live mode the emulator just confirmed. The test command
|
|
190
|
+
// writes `.temp/test-mode.json` before invoking us; the emulator's
|
|
191
|
+
// file-watcher mutates its `process.env.TEST_EXTENDED_MODE` to match;
|
|
192
|
+
// the health endpoint re-reads the file as a freshness guard. By
|
|
193
|
+
// construction these are equal — no mismatch warning needed.
|
|
191
194
|
const emulatorExtended = !!response.data?.testExtendedMode;
|
|
192
|
-
|
|
193
|
-
if (runnerExtended !== emulatorExtended) {
|
|
194
|
-
console.log(chalk.red.bold(`\n ⚠️⚠️⚠️ TEST_EXTENDED_MODE mismatch (runner=${runnerExtended}, emulator=${emulatorExtended}) ⚠️⚠️⚠️`));
|
|
195
|
-
console.log(chalk.red(' Both must match or tests will behave unexpectedly.'));
|
|
196
|
-
console.log(chalk.red(` Restart with: ${runnerExtended ? '' : 'TEST_EXTENDED_MODE=true '}npx bm emulator\n`));
|
|
197
|
-
}
|
|
195
|
+
console.log(chalk.gray(` Mode: ${emulatorExtended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
|
|
198
196
|
|
|
199
197
|
return true;
|
|
200
198
|
}
|
|
@@ -706,6 +704,11 @@ class TestRunner {
|
|
|
706
704
|
pubsub,
|
|
707
705
|
skip,
|
|
708
706
|
admin: this.config.admin,
|
|
707
|
+
// Real BEM Manager + assistant, booted by run-tests.js with BEM_TEST_RUNNER=1.
|
|
708
|
+
// Tests can call Manager.AI(), Manager.Email(), Manager.User(), etc. exactly
|
|
709
|
+
// like production code — no stubs.
|
|
710
|
+
Manager: this.config.Manager,
|
|
711
|
+
assistant: this.config.assistant,
|
|
709
712
|
rules: this.rulesContext,
|
|
710
713
|
config: this.config,
|
|
711
714
|
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared state file that lets the test command and the running emulator
|
|
3
|
+
* agree on a small set of env vars without coordinated shell flags.
|
|
4
|
+
*
|
|
5
|
+
* The test command writes this file pre-flight (before invoking the runner).
|
|
6
|
+
* The emulator process watches it and mutates the corresponding entries in
|
|
7
|
+
* `process.env` in place when it changes. All existing branch sites read
|
|
8
|
+
* `process.env.X` per call so the mutation is invisible to them — no
|
|
9
|
+
* code-branch refactor needed.
|
|
10
|
+
*
|
|
11
|
+
* File lives at `<consumerProject>/.temp/test-mode.json` — `.temp/` is the
|
|
12
|
+
* standard transient cache directory across UJM/BXM/EM/BEM consumer projects
|
|
13
|
+
* (sits at the repo root, gitignored by default).
|
|
14
|
+
*
|
|
15
|
+
* ## Allowlist
|
|
16
|
+
*
|
|
17
|
+
* `SYNCED_ENV_KEYS` is the explicit list of env vars allowed to flow from the
|
|
18
|
+
* test command into the emulator. Adding a new live-sync var = one-line addition.
|
|
19
|
+
*
|
|
20
|
+
* Why an allowlist (not "sync everything"):
|
|
21
|
+
* - Some env vars are process-specific (e.g. FIRESTORE_EMULATOR_HOST is only
|
|
22
|
+
* correct on the test runner, never on the emulator) and would break things
|
|
23
|
+
* if synced. The allowlist prevents that.
|
|
24
|
+
* - Sensitive values (API keys) shouldn't be silently overwritten on the
|
|
25
|
+
* emulator just because the test runner happens to have them set.
|
|
26
|
+
* - Keeps mutation explicit and reviewable.
|
|
27
|
+
*
|
|
28
|
+
* File format:
|
|
29
|
+
* {
|
|
30
|
+
* "env": {
|
|
31
|
+
* "TEST_EXTENDED_MODE": "true"
|
|
32
|
+
* },
|
|
33
|
+
* "updatedAt": "2026-05-14T..."
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* Values are strings to match `process.env` semantics. Empty string means
|
|
37
|
+
* "unset" — applyEnvFromFile() will `delete process.env[key]` when the value
|
|
38
|
+
* is empty, matching the way Node treats unset vs falsy env vars.
|
|
39
|
+
*/
|
|
40
|
+
const path = require('path');
|
|
41
|
+
const jetpack = require('fs-jetpack');
|
|
42
|
+
|
|
43
|
+
const TEST_MODE_FILENAME = 'test-mode.json';
|
|
44
|
+
const TEMP_DIR_NAME = '.temp';
|
|
45
|
+
|
|
46
|
+
// Explicit allowlist of env vars that flow from the test command into the
|
|
47
|
+
// running emulator. Add a key here to make it live-syncable; nothing else
|
|
48
|
+
// flows through.
|
|
49
|
+
const SYNCED_ENV_KEYS = [
|
|
50
|
+
'TEST_EXTENDED_MODE',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolve the absolute path to the test-mode file for a given consumer project.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} projectDir - Consumer project root (the directory that
|
|
57
|
+
* contains `firebase.json` / `functions/`).
|
|
58
|
+
* @returns {string} Absolute path to `<projectDir>/.temp/test-mode.json`.
|
|
59
|
+
*/
|
|
60
|
+
function getTestModeFilePath(projectDir) {
|
|
61
|
+
return path.join(projectDir, TEMP_DIR_NAME, TEST_MODE_FILENAME);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read the current test-mode payload from disk. Tolerant — returns `null`
|
|
66
|
+
* if the file is missing or unreadable, never throws.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} projectDir
|
|
69
|
+
* @returns {{ env: Object<string, string>, updatedAt: string } | null}
|
|
70
|
+
*/
|
|
71
|
+
function readTestMode(projectDir) {
|
|
72
|
+
const filePath = getTestModeFilePath(projectDir);
|
|
73
|
+
|
|
74
|
+
if (!jetpack.exists(filePath)) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
const data = jetpack.read(filePath, 'json');
|
|
80
|
+
if (!data || typeof data !== 'object') {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
env: (data.env && typeof data.env === 'object') ? data.env : {},
|
|
85
|
+
updatedAt: data.updatedAt || null,
|
|
86
|
+
};
|
|
87
|
+
} catch (e) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Write the desired env subset to disk. Atomic via fs-jetpack. Creates the
|
|
94
|
+
* `.temp/` directory if missing. Filters input through SYNCED_ENV_KEYS so
|
|
95
|
+
* only allowlisted keys are persisted, even if the caller passes extras.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} projectDir
|
|
98
|
+
* @param {Object<string, string|undefined>} envInput - Map of env vars to sync.
|
|
99
|
+
* Keys outside SYNCED_ENV_KEYS are dropped.
|
|
100
|
+
* Empty/undefined values are persisted as ''
|
|
101
|
+
* (meaning "unset on receiving side").
|
|
102
|
+
* @returns {string} Absolute path of the written file (for logging).
|
|
103
|
+
*/
|
|
104
|
+
function writeTestMode(projectDir, envInput) {
|
|
105
|
+
const filePath = getTestModeFilePath(projectDir);
|
|
106
|
+
const env = {};
|
|
107
|
+
|
|
108
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
109
|
+
const v = envInput?.[key];
|
|
110
|
+
env[key] = v == null ? '' : String(v);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const payload = {
|
|
114
|
+
env,
|
|
115
|
+
updatedAt: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
jetpack.write(filePath, payload, { atomic: true });
|
|
119
|
+
|
|
120
|
+
return filePath;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Capture the allowlisted subset of `process.env` into a plain object.
|
|
125
|
+
* Convenience for callers that want to write "whatever I currently have".
|
|
126
|
+
*
|
|
127
|
+
* @param {NodeJS.ProcessEnv} [source=process.env]
|
|
128
|
+
* @returns {Object<string, string>}
|
|
129
|
+
*/
|
|
130
|
+
function captureSyncedEnv(source) {
|
|
131
|
+
const src = source || process.env;
|
|
132
|
+
const out = {};
|
|
133
|
+
|
|
134
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
135
|
+
out[key] = src[key] == null ? '' : String(src[key]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Apply a `data.env` payload to the current process's `process.env`. Used by
|
|
143
|
+
* the watcher inside the emulator process. Returns a list of `{key, was, now}`
|
|
144
|
+
* for any key that actually changed (caller can log these).
|
|
145
|
+
*
|
|
146
|
+
* Empty-string values in the payload are treated as "unset" — the
|
|
147
|
+
* corresponding `process.env[key]` is deleted. This matches Node semantics
|
|
148
|
+
* where `delete process.env.X` makes `process.env.X === undefined` and
|
|
149
|
+
* `!!process.env.X === false`.
|
|
150
|
+
*
|
|
151
|
+
* @param {{ env: Object<string, string> } | null} data
|
|
152
|
+
* @returns {Array<{ key: string, was: string|undefined, now: string|undefined }>}
|
|
153
|
+
*/
|
|
154
|
+
function applyEnvFromFile(data) {
|
|
155
|
+
if (!data || !data.env) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const changed = [];
|
|
160
|
+
|
|
161
|
+
for (const key of SYNCED_ENV_KEYS) {
|
|
162
|
+
if (!Object.prototype.hasOwnProperty.call(data.env, key)) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const was = process.env[key];
|
|
167
|
+
const next = data.env[key];
|
|
168
|
+
|
|
169
|
+
if (next === '' || next == null) {
|
|
170
|
+
if (was != null) {
|
|
171
|
+
delete process.env[key];
|
|
172
|
+
changed.push({ key, was, now: undefined });
|
|
173
|
+
}
|
|
174
|
+
} else if (was !== next) {
|
|
175
|
+
process.env[key] = next;
|
|
176
|
+
changed.push({ key, was, now: next });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return changed;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = {
|
|
184
|
+
TEST_MODE_FILENAME,
|
|
185
|
+
TEMP_DIR_NAME,
|
|
186
|
+
SYNCED_ENV_KEYS,
|
|
187
|
+
getTestModeFilePath,
|
|
188
|
+
readTestMode,
|
|
189
|
+
writeTestMode,
|
|
190
|
+
captureSyncedEnv,
|
|
191
|
+
applyEnvFromFile,
|
|
192
|
+
};
|