backend-manager 5.0.203 → 5.1.1
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 +72 -0
- package/CLAUDE.md +100 -1529
- package/TODO-CHARGEBLAST.md +32 -0
- package/TODO-email-auth.md +14 -0
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-mistakes.md +11 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/key-files.md +36 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/scripts/update-disposable-domains.js +1 -1
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +5 -0
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/email/data/blocked-local-parts.json +55 -0
- package/src/manager/libraries/email/data/blocked-local-patterns.js +11 -0
- package/src/manager/libraries/email/data/corporate-domains.json +23 -0
- package/src/manager/libraries/{disposable-domains.json → email/data/disposable-domains.json} +3 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +16 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/email/validation.js +53 -38
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/src/manager/routes/marketing/contact/post.js +5 -1
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/email-validation.js +141 -3
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
- /package/src/manager/libraries/{custom-disposable-domains.json → email/data/custom-disposable-domains.json} +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter structure generator — AI authors the copy + section layout.
|
|
3
|
+
*
|
|
4
|
+
* The schema and AI prompt are NOT fixed here. Each template owns what content
|
|
5
|
+
* it needs and how the AI should write it (template.schema, template.buildPrompt).
|
|
6
|
+
* This module merges the template's contract with the universal base contract
|
|
7
|
+
* (subject, preheader, signoff, citations — things the shell always renders),
|
|
8
|
+
* dispatches the AI call, and normalizes the result.
|
|
9
|
+
*
|
|
10
|
+
* Why template-owned schemas:
|
|
11
|
+
* - Different aesthetics demand fundamentally different content. A "Field
|
|
12
|
+
* Report" template wants bylines + data callouts + dispatch-style prose; a
|
|
13
|
+
* "Postcard" template wants a hand-written note + image caption. Forcing
|
|
14
|
+
* them through one universal `{title, body, cta}` shape produces lookalike
|
|
15
|
+
* output regardless of layout.
|
|
16
|
+
* - The template knows what it's going to render — let it ask for that.
|
|
17
|
+
*
|
|
18
|
+
* Trade-off: theme-only iteration (skipping the AI step on re-runs) only works
|
|
19
|
+
* within the same template. Switching templates means a new AI call, because
|
|
20
|
+
* the cached structure won't match the new template's schema. That's correct
|
|
21
|
+
* behavior — different templates produce different content.
|
|
22
|
+
*
|
|
23
|
+
* Provider defaults to OpenAI (structured JSON output is more reliable on GPT
|
|
24
|
+
* via JSON schema). Can be overridden per-brand via
|
|
25
|
+
* `marketing.beehiiv.content.provider.structure`.
|
|
26
|
+
*/
|
|
27
|
+
const { resolveTemplate } = require('./templates/index.js');
|
|
28
|
+
|
|
29
|
+
const DEFAULT_MODELS = {
|
|
30
|
+
openai: 'gpt-5.4-mini',
|
|
31
|
+
anthropic: 'claude-opus',
|
|
32
|
+
'claude-code': 'claude-opus-4-7',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* BASE_SCHEMA — the universal contract that EVERY newsletter must satisfy
|
|
37
|
+
* regardless of template. These are the fields the shell uses unconditionally
|
|
38
|
+
* (subject/preheader for email metadata, signoff for the closing card,
|
|
39
|
+
* citations for the footnote block).
|
|
40
|
+
*
|
|
41
|
+
* Templates extend this with their own `schema` export, which is merged into
|
|
42
|
+
* `properties` and `required` before the AI call.
|
|
43
|
+
*/
|
|
44
|
+
const BASE_SCHEMA = {
|
|
45
|
+
type: 'object',
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
required: ['subject', 'preheader', 'signoff', 'citations'],
|
|
48
|
+
properties: {
|
|
49
|
+
subject: { type: 'string', maxLength: 80 },
|
|
50
|
+
preheader: { type: 'string', maxLength: 120 },
|
|
51
|
+
signoff: { type: 'string' },
|
|
52
|
+
// Citations for hard data (statistics, numbers, direct quotes) pulled from sources.
|
|
53
|
+
// Rendered as a small footnote section at the bottom of the newsletter — never inline.
|
|
54
|
+
// Empty array is valid (most newsletters won't need citations).
|
|
55
|
+
citations: {
|
|
56
|
+
type: 'array',
|
|
57
|
+
maxItems: 10,
|
|
58
|
+
items: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
required: ['note', 'source'],
|
|
62
|
+
properties: {
|
|
63
|
+
note: { type: 'string' }, // The cited fact, e.g. "70% of social media managers report..."
|
|
64
|
+
source: { type: 'string' }, // Free-form attribution, e.g. "Reported in industry coverage, May 2026"
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merge a template's schema fragment into BASE_SCHEMA. The template's
|
|
73
|
+
* `properties` are merged in, and its `required` is concatenated.
|
|
74
|
+
*
|
|
75
|
+
* Templates that don't export a schema get BASE_SCHEMA only — they'll be
|
|
76
|
+
* limited to subject/preheader/signoff/citations. That's a useful escape
|
|
77
|
+
* hatch for transactional / receipt-style newsletters that don't need
|
|
78
|
+
* editorial sections.
|
|
79
|
+
*/
|
|
80
|
+
function mergeSchemas(base, fragment) {
|
|
81
|
+
if (!fragment) {
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
required: [...(base.required || []), ...(fragment.required || [])],
|
|
88
|
+
properties: {
|
|
89
|
+
...base.properties,
|
|
90
|
+
...(fragment.properties || {}),
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Default prompt builder — used by templates that don't override
|
|
97
|
+
* buildPrompt. Produces the "classic" newsletter brief (intro + sections
|
|
98
|
+
* with title/body/cta/image_prompt + signoff).
|
|
99
|
+
*
|
|
100
|
+
* Templates that want a different content shape (Field Report, Almanac,
|
|
101
|
+
* Postcard, etc.) export their own buildPrompt.
|
|
102
|
+
*/
|
|
103
|
+
function defaultBuildPrompt({ brand, newsletterConfig, sources }) {
|
|
104
|
+
return {
|
|
105
|
+
system: buildClassicSystemPrompt(brand, newsletterConfig),
|
|
106
|
+
user: buildClassicUserPrompt(sources),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Generate the newsletter structure from a list of sources.
|
|
112
|
+
*
|
|
113
|
+
* The active template controls the schema and the AI prompt. This function
|
|
114
|
+
* is a generic dispatcher: it resolves the template, merges schemas, asks
|
|
115
|
+
* the template to build the prompt, calls the AI, and normalizes the result.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} args
|
|
118
|
+
* @param {Array<object>} args.sources - Newsletter source records (id, subject, ai: { headline, summary, takeaways })
|
|
119
|
+
* @param {object} args.brand - { name, url, id, description? }
|
|
120
|
+
* @param {object} args.newsletterConfig - marketing.beehiiv.content from BEM config
|
|
121
|
+
* @param {object} args.ai - AI instance from Manager.AI(assistant)
|
|
122
|
+
* @param {object} args.assistant - BEM assistant
|
|
123
|
+
* @returns {Promise<object>} Structured newsletter object
|
|
124
|
+
*/
|
|
125
|
+
async function generateStructure({ sources, brand, newsletterConfig, ai, assistant }) {
|
|
126
|
+
if (!sources?.length) {
|
|
127
|
+
throw new Error('generateStructure requires at least one source');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const templateName = newsletterConfig?.template || 'clean';
|
|
131
|
+
const template = resolveTemplate(templateName);
|
|
132
|
+
|
|
133
|
+
const provider = newsletterConfig?.provider?.structure || 'openai';
|
|
134
|
+
const model = newsletterConfig?.model?.structure || DEFAULT_MODELS[provider];
|
|
135
|
+
const startTime = Date.now();
|
|
136
|
+
|
|
137
|
+
// Build the schema: BASE + template-specific fields
|
|
138
|
+
const schema = mergeSchemas(BASE_SCHEMA, template.schema);
|
|
139
|
+
|
|
140
|
+
// Build the AI prompt: template owns voice/structure brief, base owns attribution rules
|
|
141
|
+
const buildPrompt = template.buildPrompt || defaultBuildPrompt;
|
|
142
|
+
const { system, user } = buildPrompt({ brand, newsletterConfig, sources });
|
|
143
|
+
|
|
144
|
+
assistant.log(`Newsletter structure: template=${templateName} provider=${provider} model=${model} sources=${sources.length}`);
|
|
145
|
+
|
|
146
|
+
const result = await ai.request({
|
|
147
|
+
provider,
|
|
148
|
+
model,
|
|
149
|
+
messages: [
|
|
150
|
+
{ role: 'system', content: system },
|
|
151
|
+
{ role: 'user', content: user },
|
|
152
|
+
],
|
|
153
|
+
response: 'json',
|
|
154
|
+
schema,
|
|
155
|
+
maxTokens: 3000,
|
|
156
|
+
temperature: 0.7,
|
|
157
|
+
moderate: false,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const structure = result.content;
|
|
161
|
+
|
|
162
|
+
// Validate universals
|
|
163
|
+
if (!structure?.subject) {
|
|
164
|
+
throw new Error('AI returned invalid newsletter structure (missing subject)');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Normalize universals
|
|
168
|
+
structure.subject = structure.subject || '';
|
|
169
|
+
structure.preheader = structure.preheader || '';
|
|
170
|
+
structure.signoff = structure.signoff || `Best,\nThe ${brand?.name || 'Team'} Team`;
|
|
171
|
+
structure.citations = Array.isArray(structure.citations) ? structure.citations : [];
|
|
172
|
+
|
|
173
|
+
// Let the template normalize its own fields (e.g. sections defaults).
|
|
174
|
+
// Falls back to a sane default if the template doesn't ship one.
|
|
175
|
+
if (typeof template.normalize === 'function') {
|
|
176
|
+
template.normalize(structure, { brand, newsletterConfig });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Attach metadata (non-enumerable so it doesn't pollute JSON serialization of the structure itself)
|
|
180
|
+
Object.defineProperty(structure, '_meta', {
|
|
181
|
+
enumerable: false,
|
|
182
|
+
value: {
|
|
183
|
+
template: templateName,
|
|
184
|
+
provider,
|
|
185
|
+
model,
|
|
186
|
+
durationMs: Date.now() - startTime,
|
|
187
|
+
sourcesIn: sources.length,
|
|
188
|
+
tokens: result.tokens || null,
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
return structure;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---------- Default "classic" prompt (clean + editorial use this) ----------
|
|
196
|
+
|
|
197
|
+
function buildClassicSystemPrompt(brand, config) {
|
|
198
|
+
const tone = config?.tone || 'professional';
|
|
199
|
+
const instructions = config?.instructions || '';
|
|
200
|
+
const taglineLine = brand?.tagline ? `\nTagline: ${brand.tagline}` : '';
|
|
201
|
+
const descriptionLine = brand?.description ? `\nDescription: ${brand.description}` : '';
|
|
202
|
+
|
|
203
|
+
return [
|
|
204
|
+
`You are a newsletter writer for ${brand?.name || 'a tech company'}.${taglineLine}${descriptionLine}`,
|
|
205
|
+
instructions ? `\nBrand instructions:\n${instructions}` : '',
|
|
206
|
+
`\nTone: ${tone}`,
|
|
207
|
+
'',
|
|
208
|
+
'You will be given a set of "source articles" — these are background research, NOT publications you are writing for or about.',
|
|
209
|
+
'Treat them as raw information. Synthesize the IDEAS into original content written as if you are the original author.',
|
|
210
|
+
'',
|
|
211
|
+
'CRITICAL ATTRIBUTION RULES:',
|
|
212
|
+
'- NEVER name the source publication, newsletter, blog, or author in the body of the newsletter.',
|
|
213
|
+
' (e.g., do NOT write "according to Daily Carnage", "as reported by Morning Brew", "Forbes says…", etc.)',
|
|
214
|
+
'- NEVER use phrases like "a recent article said", "according to sources", "industry coverage", or similar dodges that hint at the source.',
|
|
215
|
+
'- Write the body AS IF the source did not exist — the content should read as original, first-party reporting from the brand.',
|
|
216
|
+
'- If a source mentions a third-party platform, product, or company by name (e.g., LinkedIn, YouTube, Apple), THAT is fine — those are subjects of the news, not the source. Name them freely.',
|
|
217
|
+
'',
|
|
218
|
+
'CITATIONS:',
|
|
219
|
+
'- If the source contains hard data — specific statistics, percentages, dollar amounts, dates, study results, direct quotes — include them in the body.',
|
|
220
|
+
'- Then add a corresponding entry to the `citations` array with:',
|
|
221
|
+
' - note: the cited fact (e.g. "Crosscheck AI flagged 12,000 impersonation attempts in beta")',
|
|
222
|
+
' - source: a neutral attribution that does NOT name the source publication (e.g. "Reported by LinkedIn product team, May 2026", "Per company beta data", "Industry research, Q2 2026")',
|
|
223
|
+
'- Citations render as small footnotes at the BOTTOM of the newsletter — never inline.',
|
|
224
|
+
'- If a section has no hard data worth citing, do not invent citations. Empty array is fine.',
|
|
225
|
+
'',
|
|
226
|
+
'CONTENT REQUIREMENTS:',
|
|
227
|
+
'- Subject (≤60 chars, no emojis, attention-grabbing but not clickbait)',
|
|
228
|
+
'- Preheader (≤100 chars, complements the subject)',
|
|
229
|
+
'- Intro (1-2 sentences, markdown allowed) — frame the issue as if you are setting up your own reporting',
|
|
230
|
+
'- 3-5 sections — each is ONE topic, rewritten in your voice as original content',
|
|
231
|
+
'- Each section: title (compelling, scannable), body (80-150 words, markdown OK), optional cta { label, url }',
|
|
232
|
+
'- Each section: image_prompt — one-sentence visual description for an illustrator. Be specific about subject/style.',
|
|
233
|
+
`- Signoff: a SHORT human sign-off, formatted as two lines with \\n between them. First line is a closing phrase like "Best,", "Cheers,", "Until next week,", or "Stay sharp,". Second line is the team name like "The ${brand?.name || 'Team'} Team". Example: "Best,\\nThe ${brand?.name || 'Team'} Team". Do NOT write a summary, tagline, motto, or thematic conclusion sentence — this is the literal way you sign off the email, like the end of a letter.`,
|
|
234
|
+
'- citations: array of { note, source } for any hard data referenced. Empty array if none.',
|
|
235
|
+
'',
|
|
236
|
+
'STYLE:',
|
|
237
|
+
'- Do NOT copy source text verbatim. Synthesize and rewrite in your voice.',
|
|
238
|
+
'- Do NOT use emojis, hashtags, or "guru" language unless brand instructions say otherwise.',
|
|
239
|
+
'- Respond with valid JSON only — no markdown fences, no preamble.',
|
|
240
|
+
].filter(Boolean).join('\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function buildClassicUserPrompt(sources) {
|
|
244
|
+
// Note: we intentionally do NOT pass through the source publication name (raw.from)
|
|
245
|
+
// to the AI prompt. Removing it means the AI literally cannot leak it into the body.
|
|
246
|
+
// The "from" field is metadata about WHERE the research came from, not content to reference.
|
|
247
|
+
const summaries = sources
|
|
248
|
+
.map((s, i) => {
|
|
249
|
+
const raw = s.source || {};
|
|
250
|
+
const headline = s.ai?.headline || raw.subject || s.subject || `Topic ${i + 1}`;
|
|
251
|
+
const summary = s.ai?.summary || '';
|
|
252
|
+
const takeaways = (s.ai?.takeaways || []).join('; ');
|
|
253
|
+
const rawContent = !summary && raw.content
|
|
254
|
+
? raw.content.slice(0, 1500)
|
|
255
|
+
: '';
|
|
256
|
+
|
|
257
|
+
return [
|
|
258
|
+
`[Research ${i + 1}]`,
|
|
259
|
+
`Topic: ${headline}`,
|
|
260
|
+
summary ? `Summary: ${summary}` : '',
|
|
261
|
+
takeaways ? `Key takeaways: ${takeaways}` : '',
|
|
262
|
+
rawContent ? `Raw content (excerpt):\n${rawContent}` : '',
|
|
263
|
+
].filter(Boolean).join('\n');
|
|
264
|
+
})
|
|
265
|
+
.join('\n\n');
|
|
266
|
+
|
|
267
|
+
return `Write a newsletter using the following research as background. Do not name or reference these research items — synthesize the ideas into original content.\n\n${summaries}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = {
|
|
271
|
+
generateStructure,
|
|
272
|
+
BASE_SCHEMA,
|
|
273
|
+
defaultBuildPrompt,
|
|
274
|
+
mergeSchemas,
|
|
275
|
+
// Re-exported helpers so templates can reuse the classic prompt patterns
|
|
276
|
+
buildClassicSystemPrompt,
|
|
277
|
+
buildClassicUserPrompt,
|
|
278
|
+
};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG illustrator — AI authors per-section SVG illustrations, rasterized to PNG.
|
|
3
|
+
*
|
|
4
|
+
* Each section in a newsletter gets one illustration. Default provider is
|
|
5
|
+
* Anthropic (Claude generates cleaner geometric SVG than GPT in practice).
|
|
6
|
+
*
|
|
7
|
+
* Output is both the raw SVG string (for debugging) and a rasterized PNG buffer
|
|
8
|
+
* (for embedding). Local file persistence is the caller's responsibility — this
|
|
9
|
+
* module returns buffers only.
|
|
10
|
+
*/
|
|
11
|
+
const { Resvg } = require('@resvg/resvg-js');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_MODELS = {
|
|
14
|
+
openai: 'gpt-5.4-mini',
|
|
15
|
+
anthropic: 'claude-opus',
|
|
16
|
+
'claude-code': 'claude-opus-4-7',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const PNG_WIDTH = 800; // 2x display width of 400px container
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Generate one illustration for a section.
|
|
23
|
+
*
|
|
24
|
+
* @param {object} args
|
|
25
|
+
* @param {string} args.imagePrompt - Visual description from the structure
|
|
26
|
+
* @param {object} args.brand - { name, color: { primary, secondary, ... } }
|
|
27
|
+
* @param {object} args.newsletterConfig - marketing.beehiiv.content
|
|
28
|
+
* @param {object} args.ai - AI instance
|
|
29
|
+
* @param {object} args.assistant - BEM assistant
|
|
30
|
+
* @returns {Promise<{svg: string, png: Buffer, fallback: boolean}>}
|
|
31
|
+
*/
|
|
32
|
+
async function generateSectionImage({ imagePrompt, brand, newsletterConfig, ai, assistant }) {
|
|
33
|
+
const provider = newsletterConfig?.provider?.svg || 'anthropic';
|
|
34
|
+
const model = newsletterConfig?.model?.svg || DEFAULT_MODELS[provider];
|
|
35
|
+
const startTime = Date.now();
|
|
36
|
+
|
|
37
|
+
const palette = resolvePalette(brand, newsletterConfig);
|
|
38
|
+
const systemPrompt = buildSvgSystemPrompt(palette);
|
|
39
|
+
const userPrompt = imagePrompt || 'An abstract geometric illustration representing the topic.';
|
|
40
|
+
|
|
41
|
+
let svg = '';
|
|
42
|
+
let fallback = false;
|
|
43
|
+
let attempts = 0;
|
|
44
|
+
let lastTokens = null;
|
|
45
|
+
|
|
46
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
47
|
+
attempts++;
|
|
48
|
+
try {
|
|
49
|
+
const result = await ai.request({
|
|
50
|
+
provider,
|
|
51
|
+
model,
|
|
52
|
+
messages: [
|
|
53
|
+
{ role: 'system', content: systemPrompt },
|
|
54
|
+
{ role: 'user', content: userPrompt },
|
|
55
|
+
],
|
|
56
|
+
response: 'text',
|
|
57
|
+
maxTokens: 2000,
|
|
58
|
+
temperature: attempt === 0 ? 0.8 : 0.4,
|
|
59
|
+
moderate: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
lastTokens = result.tokens;
|
|
63
|
+
svg = extractSvg(result.content);
|
|
64
|
+
|
|
65
|
+
if (svg) {
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
assistant.log(`SVG generation attempt ${attempt + 1} returned no valid <svg>`);
|
|
70
|
+
} catch (e) {
|
|
71
|
+
assistant.error(`SVG generation attempt ${attempt + 1} failed: ${e.message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let png;
|
|
76
|
+
|
|
77
|
+
if (svg) {
|
|
78
|
+
try {
|
|
79
|
+
png = rasterize(svg);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
assistant.error(`SVG rasterization failed, using fallback: ${e.message}`);
|
|
82
|
+
svg = buildPlaceholderSvg(palette);
|
|
83
|
+
png = rasterize(svg);
|
|
84
|
+
fallback = true;
|
|
85
|
+
}
|
|
86
|
+
} else {
|
|
87
|
+
svg = buildPlaceholderSvg(palette);
|
|
88
|
+
png = rasterize(svg);
|
|
89
|
+
fallback = true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
svg,
|
|
94
|
+
png,
|
|
95
|
+
fallback,
|
|
96
|
+
meta: {
|
|
97
|
+
provider,
|
|
98
|
+
model,
|
|
99
|
+
durationMs: Date.now() - startTime,
|
|
100
|
+
attempts,
|
|
101
|
+
fallback,
|
|
102
|
+
tokens: lastTokens,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve brand palette from config theme + brand defaults.
|
|
109
|
+
* Returns { primary, secondary, accent, bg, fg }
|
|
110
|
+
*/
|
|
111
|
+
function resolvePalette(brand, newsletterConfig) {
|
|
112
|
+
const theme = newsletterConfig?.theme || {};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
primary: theme.primaryColor || brand?.color?.primary || '#5B5BFF',
|
|
116
|
+
secondary: theme.secondaryColor || brand?.color?.secondary || '#1E1E2A',
|
|
117
|
+
accent: theme.accentColor || brand?.color?.accent || '#F6F7FB',
|
|
118
|
+
bg: '#FFFFFF',
|
|
119
|
+
fg: '#1E1E2A',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function buildSvgSystemPrompt(palette) {
|
|
124
|
+
return [
|
|
125
|
+
'You are an SVG illustrator. Produce a single self-contained SVG illustration.',
|
|
126
|
+
'',
|
|
127
|
+
'STRICT REQUIREMENTS:',
|
|
128
|
+
'- viewBox="0 0 800 400"',
|
|
129
|
+
'- No <text>, no <foreignObject>, no <script>, no <image>, no external references',
|
|
130
|
+
'- Use only: <rect>, <circle>, <ellipse>, <path>, <line>, <polyline>, <polygon>, <g>',
|
|
131
|
+
'- Maximum 20 shape elements total',
|
|
132
|
+
'- No filters, no gradients beyond simple <linearGradient>',
|
|
133
|
+
'- Output ONLY the SVG element. No markdown fences, no preamble, no explanation.',
|
|
134
|
+
'',
|
|
135
|
+
'PALETTE (use these colors exclusively):',
|
|
136
|
+
`- Primary: ${palette.primary}`,
|
|
137
|
+
`- Secondary: ${palette.secondary}`,
|
|
138
|
+
`- Accent: ${palette.accent}`,
|
|
139
|
+
`- Background: ${palette.bg}`,
|
|
140
|
+
'',
|
|
141
|
+
'STYLE: Flat, geometric, modern, minimal. Think Stripe, Linear, or Vercel marketing illustrations.',
|
|
142
|
+
'COMPOSITION: Centered subject, balanced negative space, no busy clutter.',
|
|
143
|
+
].join('\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function extractSvg(text) {
|
|
147
|
+
if (!text || typeof text !== 'string') {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Strip markdown fences
|
|
152
|
+
let cleaned = text.trim().replace(/^```(?:svg|xml)?\s*/i, '').replace(/\s*```$/i, '');
|
|
153
|
+
|
|
154
|
+
// Find first <svg ... > ... </svg>
|
|
155
|
+
const match = cleaned.match(/<svg[\s\S]*?<\/svg>/i);
|
|
156
|
+
|
|
157
|
+
if (!match) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return match[0];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function buildPlaceholderSvg(palette) {
|
|
165
|
+
return [
|
|
166
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 400">',
|
|
167
|
+
` <rect width="800" height="400" fill="${palette.accent}"/>`,
|
|
168
|
+
` <circle cx="400" cy="200" r="120" fill="${palette.primary}" opacity="0.85"/>`,
|
|
169
|
+
` <circle cx="320" cy="160" r="60" fill="${palette.secondary}" opacity="0.7"/>`,
|
|
170
|
+
` <rect x="480" y="240" width="120" height="80" fill="${palette.primary}" opacity="0.4"/>`,
|
|
171
|
+
'</svg>',
|
|
172
|
+
].join('\n');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function rasterize(svgString) {
|
|
176
|
+
const resvg = new Resvg(svgString, {
|
|
177
|
+
fitTo: { mode: 'width', value: PNG_WIDTH },
|
|
178
|
+
background: 'rgba(0,0,0,0)',
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return resvg.render().asPng();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { generateSectionImage, rasterize };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classic newsletter content schema — used by `clean` and `editorial`.
|
|
3
|
+
*
|
|
4
|
+
* Both templates consume the same shape:
|
|
5
|
+
* - intro: 1-2 sentence preamble
|
|
6
|
+
* - sections: list of {title, body, cta?, image_prompt}
|
|
7
|
+
*
|
|
8
|
+
* Defined here so adding a new field (e.g. an eyebrow per section) updates
|
|
9
|
+
* every classic-style template at once. Templates with fundamentally
|
|
10
|
+
* different content shapes (Field Report, Postcard, Almanac) declare their
|
|
11
|
+
* own schema instead.
|
|
12
|
+
*/
|
|
13
|
+
const CLASSIC_SCHEMA = {
|
|
14
|
+
required: ['intro', 'sections'],
|
|
15
|
+
properties: {
|
|
16
|
+
intro: { type: 'string' },
|
|
17
|
+
sections: {
|
|
18
|
+
type: 'array',
|
|
19
|
+
minItems: 2,
|
|
20
|
+
maxItems: 6,
|
|
21
|
+
items: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: ['title', 'body', 'image_prompt', 'cta'],
|
|
25
|
+
properties: {
|
|
26
|
+
title: { type: 'string' },
|
|
27
|
+
body: { type: 'string' },
|
|
28
|
+
cta: {
|
|
29
|
+
type: ['object', 'null'],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
required: ['label', 'url'],
|
|
32
|
+
properties: {
|
|
33
|
+
label: { type: 'string' },
|
|
34
|
+
url: { type: 'string' },
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
image_prompt: { type: 'string' },
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a classic structure post-AI-call. Ensures every section has the
|
|
46
|
+
* fields the templates expect, even when the AI omits an optional like cta.
|
|
47
|
+
*/
|
|
48
|
+
function normalizeClassic(structure) {
|
|
49
|
+
if (!Array.isArray(structure.sections)) {
|
|
50
|
+
structure.sections = [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
structure.sections = structure.sections.map((s, i) => ({
|
|
54
|
+
title: s.title || `Section ${i + 1}`,
|
|
55
|
+
body: s.body || '',
|
|
56
|
+
cta: s.cta || null,
|
|
57
|
+
image_prompt: s.image_prompt || '',
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
structure.intro = structure.intro || '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { CLASSIC_SCHEMA, normalizeClassic };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `clean` template — Stripe / Linear marketing aesthetic.
|
|
3
|
+
*
|
|
4
|
+
* White cards on a light accent background. Brand wordmark header → intro →
|
|
5
|
+
* one card per section (image, title, body, optional CTA) → signoff.
|
|
6
|
+
*
|
|
7
|
+
* The shell handles cross-cutting concerns (top/end sponsorships, citations,
|
|
8
|
+
* footer with CAN-SPAM address) automatically. This file only owns the
|
|
9
|
+
* "clean" identity — header, intro, section cards, signoff.
|
|
10
|
+
*/
|
|
11
|
+
const {
|
|
12
|
+
shell,
|
|
13
|
+
resolveTheme,
|
|
14
|
+
brandHeader,
|
|
15
|
+
introBlock,
|
|
16
|
+
sectionCard,
|
|
17
|
+
signoffBlock,
|
|
18
|
+
sponsorshipsAt,
|
|
19
|
+
} = require('./shared.js');
|
|
20
|
+
|
|
21
|
+
const { CLASSIC_SCHEMA, normalizeClassic } = require('./classic-schema.js');
|
|
22
|
+
|
|
23
|
+
const SPACING_OVERRIDES = {
|
|
24
|
+
gutter: '32px',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function build({ structure, imagePaths, theme: themeIn, brandName, brandUrl, brandAddress, now, sponsorships }) {
|
|
28
|
+
const theme = resolveTheme(themeIn, SPACING_OVERRIDES);
|
|
29
|
+
|
|
30
|
+
// Section rendering, with middle sponsorships interleaved at the midpoint.
|
|
31
|
+
// Sections array is optional — a structure with no sections renders just
|
|
32
|
+
// header + intro + signoff + footer (still a valid newsletter).
|
|
33
|
+
const safeSections = Array.isArray(structure.sections) ? structure.sections : [];
|
|
34
|
+
const sectionBlocks = safeSections.map((section, i) =>
|
|
35
|
+
sectionCard({ section: section || {}, imagePath: imagePaths?.[i], theme })
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
const middleSponsorships = sponsorshipsAt({ sponsorships, position: 'middle', theme });
|
|
39
|
+
if (middleSponsorships) {
|
|
40
|
+
const middleIndex = Math.floor(sectionBlocks.length / 2);
|
|
41
|
+
sectionBlocks.splice(middleIndex, 0, middleSponsorships);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Envelope: shared data for every template
|
|
45
|
+
const envelope = {
|
|
46
|
+
structure,
|
|
47
|
+
theme,
|
|
48
|
+
brandName,
|
|
49
|
+
brandUrl,
|
|
50
|
+
brandAddress,
|
|
51
|
+
sponsorships,
|
|
52
|
+
now,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Slots: what 'clean' uniquely contributes
|
|
56
|
+
const slots = {
|
|
57
|
+
header: brandHeader({ brandName, brandUrl, theme }),
|
|
58
|
+
hero: introBlock({ intro: structure.intro, theme }),
|
|
59
|
+
body: sectionBlocks.join('\n'),
|
|
60
|
+
signoff: signoffBlock({ signoff: structure.signoff, theme }),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Footer on transparent so it blends with the page background
|
|
64
|
+
const config = {
|
|
65
|
+
footerStyle: { background: 'transparent' },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return shell(envelope, slots, config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
build,
|
|
73
|
+
meta: {
|
|
74
|
+
name: 'clean',
|
|
75
|
+
description: 'Stripe / Linear marketing aesthetic. Safe, conservative, works everywhere.',
|
|
76
|
+
requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
|
|
77
|
+
optional: ['citations', 'image_prompt', 'cta'],
|
|
78
|
+
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
79
|
+
},
|
|
80
|
+
schema: CLASSIC_SCHEMA,
|
|
81
|
+
normalize: normalizeClassic,
|
|
82
|
+
};
|