backend-manager 5.0.203 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/CLAUDE.md +43 -1501
  3. package/TODO-CHARGEBLAST.md +32 -0
  4. package/TODO-email-auth.md +14 -0
  5. package/docs/admin-post-route.md +24 -0
  6. package/docs/ai-library.md +23 -0
  7. package/docs/architecture.md +31 -0
  8. package/docs/auth-hooks.md +74 -0
  9. package/docs/cli-firestore-auth.md +59 -0
  10. package/docs/cli-logs.md +67 -0
  11. package/docs/code-patterns.md +67 -0
  12. package/docs/common-operations.md +64 -0
  13. package/docs/directory-structure.md +119 -0
  14. package/docs/environment-detection.md +7 -0
  15. package/docs/file-naming.md +11 -0
  16. package/docs/marketing-campaigns.md +244 -0
  17. package/docs/marketing-fields.md +25 -0
  18. package/docs/mcp.md +95 -0
  19. package/docs/payment-system.md +325 -0
  20. package/docs/response-headers.md +7 -0
  21. package/docs/routes.md +126 -0
  22. package/docs/sanitization.md +61 -0
  23. package/docs/schemas.md +39 -0
  24. package/docs/stripe-webhook-forwarding.md +18 -0
  25. package/docs/testing.md +129 -0
  26. package/docs/usage-rate-limiting.md +67 -0
  27. package/package.json +8 -4
  28. package/src/defaults/CHANGELOG.md +15 -0
  29. package/src/defaults/CLAUDE.md +8 -4
  30. package/src/defaults/docs/README.md +17 -0
  31. package/src/defaults/test/README.md +33 -0
  32. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
  33. package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
  34. package/src/manager/helpers/utilities.js +21 -0
  35. package/src/manager/index.js +1 -1
  36. package/src/manager/libraries/ai/index.js +162 -0
  37. package/src/manager/libraries/ai/providers/anthropic.js +193 -0
  38. package/src/manager/libraries/ai/providers/claude-code.js +206 -0
  39. package/src/manager/libraries/ai/providers/openai.js +934 -0
  40. package/src/manager/libraries/disposable-domains.json +2 -0
  41. package/src/manager/libraries/email/generators/lib/filter.js +179 -0
  42. package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
  43. package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
  44. package/src/manager/libraries/email/generators/lib/structure.js +278 -0
  45. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
  46. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
  47. package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
  48. package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
  49. package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
  50. package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
  51. package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
  52. package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
  53. package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
  54. package/src/manager/libraries/email/generators/newsletter.js +377 -95
  55. package/src/manager/libraries/email/marketing/index.js +5 -2
  56. package/src/manager/libraries/email/providers/beehiiv.js +7 -3
  57. package/src/manager/libraries/openai.js +13 -932
  58. package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
  59. package/src/manager/routes/admin/post/post.js +10 -17
  60. package/templates/_.env +4 -0
  61. package/templates/_.gitignore +1 -0
  62. package/templates/backend-manager-config.json +48 -4
  63. package/test/helpers/slugify.js +394 -0
  64. package/test/marketing/fixtures/clean.json +31 -0
  65. package/test/marketing/fixtures/editorial.json +31 -0
  66. package/test/marketing/fixtures/field-report.json +54 -0
  67. package/test/marketing/newsletter-generate.js +731 -0
  68. package/test/marketing/newsletter-templates.js +512 -0
  69. package/test/routes/admin/deduplicate-image-alts.js +190 -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
+ };