backend-manager 5.0.202 → 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.
- package/CHANGELOG.md +61 -0
- package/CLAUDE.md +43 -1501
- 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-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/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/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/helpers/settings.js +26 -7
- 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/disposable-domains.json +2 -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 +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- 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/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers used by editorial / magazine-style templates.
|
|
3
|
+
*
|
|
4
|
+
* Lives separately from shared.js because these are NOT general-purpose
|
|
5
|
+
* primitives — they encode editorial-specific conventions (pull-quotes,
|
|
6
|
+
* issue numbering, serif eyebrow labels). Templates that want this aesthetic
|
|
7
|
+
* import from here; other templates (clean, future minimal/digest variants)
|
|
8
|
+
* don't touch this file.
|
|
9
|
+
*/
|
|
10
|
+
const { escape } = require('./shared.js');
|
|
11
|
+
|
|
12
|
+
const SERIF_FONT = 'Georgia, \'Times New Roman\', serif';
|
|
13
|
+
|
|
14
|
+
const EYEBROW_STYLE = 'font-size: 11px; letter-spacing: 4px; text-transform: uppercase; font-weight: 700;';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Render an "eyebrow" — a small uppercase tracked label used above headlines
|
|
18
|
+
* and over closing cards. Pass a `color` to tint it; defaults to 'inherit'.
|
|
19
|
+
*/
|
|
20
|
+
function eyebrow({ text, color, marginBottom }) {
|
|
21
|
+
const extra = marginBottom ? ` margin-bottom: ${marginBottom};` : '';
|
|
22
|
+
return `<div style="${EYEBROW_STYLE} color: ${color || 'inherit'};${extra}">${escape(text)}</div>`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Pick a quotable sentence from a body of text. Returns the longest sentence
|
|
27
|
+
* between 60 and 180 characters, biased toward sentences containing hook
|
|
28
|
+
* words like "means", "matters", "practical", "risk", etc.
|
|
29
|
+
*/
|
|
30
|
+
function pullQuoteFrom(body) {
|
|
31
|
+
if (!body) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const sentences = body
|
|
36
|
+
.replace(/\n+/g, ' ')
|
|
37
|
+
.split(/(?<=[.!?])\s+/)
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter((s) => s.length >= 60 && s.length <= 180);
|
|
40
|
+
|
|
41
|
+
if (!sentences.length) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const hooks = /\b(means|matters|will|should|need|important|key|critical|practical|takeaway|risk|trust)\b/i;
|
|
46
|
+
const hooked = sentences.find((s) => hooks.test(s));
|
|
47
|
+
|
|
48
|
+
return hooked || sentences.sort((a, b) => b.length - a.length)[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove a sentence from a body. Used to avoid duplicating the pull-quote
|
|
53
|
+
* in the running body text.
|
|
54
|
+
*/
|
|
55
|
+
function stripSentence(body, sentence) {
|
|
56
|
+
if (!body || !sentence) {
|
|
57
|
+
return body || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const escaped = sentence.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
61
|
+
const pattern = new RegExp(`\\s*${escaped}\\s*`, '');
|
|
62
|
+
|
|
63
|
+
return body.replace(pattern, ' ').trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* "Issue 0863 · May 12, 2026"-style line. The number is monotonically
|
|
68
|
+
* increasing since 2024-01-01, so a daily newsletter gets a stable
|
|
69
|
+
* issue number per day.
|
|
70
|
+
*/
|
|
71
|
+
function issueLine({ now, prefix }) {
|
|
72
|
+
const d = now || new Date();
|
|
73
|
+
return {
|
|
74
|
+
number: computeIssueNumber(d),
|
|
75
|
+
date: formatIssueDate(d),
|
|
76
|
+
line: `${prefix || 'Issue'} ${computeIssueNumber(d)} · ${formatIssueDate(d)}`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatIssueDate(date) {
|
|
81
|
+
return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function computeIssueNumber(date) {
|
|
85
|
+
const epoch = new Date('2024-01-01T00:00:00Z');
|
|
86
|
+
const days = Math.floor((date.getTime() - epoch.getTime()) / (1000 * 60 * 60 * 24));
|
|
87
|
+
|
|
88
|
+
return String(days).padStart(4, '0');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = {
|
|
92
|
+
SERIF_FONT,
|
|
93
|
+
EYEBROW_STYLE,
|
|
94
|
+
eyebrow,
|
|
95
|
+
pullQuoteFrom,
|
|
96
|
+
stripSentence,
|
|
97
|
+
issueLine,
|
|
98
|
+
formatIssueDate,
|
|
99
|
+
computeIssueNumber,
|
|
100
|
+
};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `editorial` template — magazine-style with masthead, drop-cap intro,
|
|
3
|
+
* numbered sections, alternating image layout, pull-quotes, plain signoff.
|
|
4
|
+
*
|
|
5
|
+
* The shell handles cross-cutting concerns (top/end sponsorships, citations,
|
|
6
|
+
* footer with CAN-SPAM address) automatically. This file only owns the
|
|
7
|
+
* editorial identity:
|
|
8
|
+
*
|
|
9
|
+
* - Dark masthead with oversized wordmark + issue line + serif tagline
|
|
10
|
+
* - Cover headline ("In this issue" eyebrow + giant subject)
|
|
11
|
+
* - Drop-cap intro
|
|
12
|
+
* - Numbered section layout with alternating image/numeral columns
|
|
13
|
+
* - Pull-quote per section
|
|
14
|
+
* - Optional inline 'middle' sponsorships between sections
|
|
15
|
+
* - Plain italic-serif sign-off on white
|
|
16
|
+
*/
|
|
17
|
+
const {
|
|
18
|
+
shell,
|
|
19
|
+
resolveTheme,
|
|
20
|
+
escape,
|
|
21
|
+
markdownToHtml,
|
|
22
|
+
inlineMarkdown,
|
|
23
|
+
singleColumnSection,
|
|
24
|
+
twoColumnSection,
|
|
25
|
+
column,
|
|
26
|
+
dividerBlock,
|
|
27
|
+
sponsorshipsAt,
|
|
28
|
+
} = require('./shared.js');
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
SERIF_FONT,
|
|
32
|
+
eyebrow,
|
|
33
|
+
pullQuoteFrom,
|
|
34
|
+
stripSentence,
|
|
35
|
+
issueLine,
|
|
36
|
+
} = require('./editorial-helpers.js');
|
|
37
|
+
|
|
38
|
+
const { CLASSIC_SCHEMA, normalizeClassic } = require('./classic-schema.js');
|
|
39
|
+
|
|
40
|
+
const SPACING_OVERRIDES = {
|
|
41
|
+
gutter: '48px',
|
|
42
|
+
sectionGap: '24px',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function build({ structure, imagePaths, theme: themeIn, brandName, brandUrl, brandAddress, now, sponsorships }) {
|
|
46
|
+
const theme = resolveTheme(themeIn, SPACING_OVERRIDES);
|
|
47
|
+
const gutter = theme.spacing.gutter;
|
|
48
|
+
const WHITE = '#ffffff';
|
|
49
|
+
const issue = issueLine({ now });
|
|
50
|
+
|
|
51
|
+
// Section rendering — sections NEVER render their own trailing divider.
|
|
52
|
+
// The build orchestrator inserts dividers BETWEEN blocks below, so we have
|
|
53
|
+
// exactly one separator between any two adjacent blocks and zero stacking.
|
|
54
|
+
// Sections array is optional — a structure with no sections renders just
|
|
55
|
+
// masthead + cover + intro + signoff + footer.
|
|
56
|
+
const safeSections = Array.isArray(structure.sections) ? structure.sections : [];
|
|
57
|
+
const sectionBlocks = safeSections.map((section, i) =>
|
|
58
|
+
editorialSection({
|
|
59
|
+
section: section || {},
|
|
60
|
+
imagePath: imagePaths?.[i],
|
|
61
|
+
theme,
|
|
62
|
+
index: i,
|
|
63
|
+
total: safeSections.length,
|
|
64
|
+
})
|
|
65
|
+
).filter(Boolean);
|
|
66
|
+
|
|
67
|
+
// Middle sponsorships: insert at midpoint, WITHOUT the sponsorship's own
|
|
68
|
+
// hairlines (the section divider above and below already separates content).
|
|
69
|
+
const middleSponsorships = sponsorshipsAt({
|
|
70
|
+
sponsorships,
|
|
71
|
+
position: 'middle',
|
|
72
|
+
theme,
|
|
73
|
+
padding: `20px ${gutter} 20px ${gutter}`,
|
|
74
|
+
background: WHITE,
|
|
75
|
+
label: 'In partnership with',
|
|
76
|
+
withRules: false,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (middleSponsorships) {
|
|
80
|
+
const middleIndex = Math.floor(sectionBlocks.length / 2);
|
|
81
|
+
sectionBlocks.splice(middleIndex, 0, middleSponsorships);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Join section blocks with a SINGLE shared divider between adjacent blocks.
|
|
85
|
+
// The divider gets symmetric vertical padding (40px above + 40px below) so
|
|
86
|
+
// the rule sits visually centered between the surrounding content. No
|
|
87
|
+
// leading divider, no trailing divider, no stacking with sponsorship rules.
|
|
88
|
+
const divider = dividerBlock({
|
|
89
|
+
background: WHITE,
|
|
90
|
+
padding: `40px ${gutter} 40px ${gutter}`,
|
|
91
|
+
color: theme.spacing.ruleColor,
|
|
92
|
+
});
|
|
93
|
+
const composedBody = sectionBlocks.join(`\n${divider}\n`);
|
|
94
|
+
|
|
95
|
+
// Envelope passed to shell (data — same for every template)
|
|
96
|
+
const envelope = {
|
|
97
|
+
structure,
|
|
98
|
+
theme,
|
|
99
|
+
brandName,
|
|
100
|
+
brandUrl,
|
|
101
|
+
brandAddress,
|
|
102
|
+
sponsorships,
|
|
103
|
+
now,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Slots — what this template uniquely contributes
|
|
107
|
+
const slots = {
|
|
108
|
+
header: masthead({ brandName, brandUrl, theme, issue, tagline: structure.preheader, gutter }),
|
|
109
|
+
hero: [
|
|
110
|
+
coverHeadline({ subject: structure.subject, theme, gutter }),
|
|
111
|
+
dividerBlock({ background: WHITE, padding: `32px ${gutter} 0 ${gutter}`, color: theme.secondaryColor }),
|
|
112
|
+
dropCapIntro({ intro: structure.intro, gutter }),
|
|
113
|
+
].filter(Boolean).join('\n'),
|
|
114
|
+
body: composedBody,
|
|
115
|
+
signoff: editorialSignoff({ signoff: structure.signoff, theme, gutter }),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Per-template shell config
|
|
119
|
+
const config = {
|
|
120
|
+
width: '640px',
|
|
121
|
+
extraAttributes: `<mj-text font-size="17px" line-height="1.7" color="${theme.secondaryColor}" />
|
|
122
|
+
<mj-button background-color="${theme.secondaryColor}" color="#ffffff" border-radius="0" font-weight="600" font-size="12px" letter-spacing="2px" inner-padding="16px 28px" text-transform="uppercase" padding="0" />`,
|
|
123
|
+
extraStyles: editorialStyles(theme),
|
|
124
|
+
sponsorshipStyle: {
|
|
125
|
+
padding: `20px ${gutter} 20px ${gutter}`,
|
|
126
|
+
background: WHITE,
|
|
127
|
+
label: 'In partnership with',
|
|
128
|
+
},
|
|
129
|
+
citationsStyle: {
|
|
130
|
+
padding: `24px ${gutter} 24px ${gutter}`,
|
|
131
|
+
background: WHITE,
|
|
132
|
+
},
|
|
133
|
+
footerStyle: {
|
|
134
|
+
padding: `36px ${gutter} 48px ${gutter}`,
|
|
135
|
+
topRule: `<div class="footer-rule"></div>`,
|
|
136
|
+
extraLine: `${brandName} · Issue ${issue.number}`,
|
|
137
|
+
linkStyle: 'border-bottom: none;',
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return shell(envelope, slots, config);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------- Editorial-specific CSS ----------
|
|
145
|
+
|
|
146
|
+
function editorialStyles(theme) {
|
|
147
|
+
return `
|
|
148
|
+
h1, h2, h3 { color: ${theme.secondaryColor}; margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
|
149
|
+
h2 { font-size: 32px; line-height: 1.15; }
|
|
150
|
+
h3 { font-size: 14px; letter-spacing: 3px; text-transform: uppercase; font-weight: 600; }
|
|
151
|
+
a { color: ${theme.primaryColor}; text-decoration: none; border-bottom: 1px solid ${theme.primaryColor}; }
|
|
152
|
+
p { margin: 0 0 16px; }
|
|
153
|
+
.masthead-issue { font-family: ${SERIF_FONT}; font-style: italic; font-size: 14px; color: rgba(255,255,255,0.7); letter-spacing: 1px; }
|
|
154
|
+
.masthead-brand { font-size: 44px; font-weight: 900; letter-spacing: -0.03em; line-height: 1; color: #ffffff; }
|
|
155
|
+
.masthead-rule { display: block; width: 48px; height: 4px; background: ${theme.primaryColor}; margin: 18px 0 14px; }
|
|
156
|
+
.masthead-tagline { font-family: ${SERIF_FONT}; font-style: italic; font-size: 16px; color: rgba(255,255,255,0.85); line-height: 1.5; }
|
|
157
|
+
.lede { font-family: ${SERIF_FONT}; font-size: 22px; line-height: 1.5; color: ${theme.secondaryColor}; }
|
|
158
|
+
.lede::first-letter {
|
|
159
|
+
font-family: ${SERIF_FONT};
|
|
160
|
+
font-weight: 700;
|
|
161
|
+
font-size: 72px;
|
|
162
|
+
line-height: 0.85;
|
|
163
|
+
float: left;
|
|
164
|
+
padding: 6px 12px 0 0;
|
|
165
|
+
color: ${theme.primaryColor};
|
|
166
|
+
}
|
|
167
|
+
.numeral-fallback { font-family: ${SERIF_FONT}; font-size: 96px; line-height: 0.85; font-weight: 700; color: ${theme.primaryColor}; opacity: 0.18; }
|
|
168
|
+
.pullquote { font-family: ${SERIF_FONT}; font-style: italic; font-size: 22px; line-height: 1.4; color: ${theme.primaryColor}; border-left: 3px solid ${theme.primaryColor}; padding: 4px 0 4px 20px; margin: 8px 0 16px; }
|
|
169
|
+
.section-meta { font-size: 11px; letter-spacing: 3px; text-transform: uppercase; color: #888; font-weight: 600; }
|
|
170
|
+
.signoff { font-family: ${SERIF_FONT}; font-style: italic; font-size: 18px; line-height: 1.5; color: ${theme.secondaryColor}; }
|
|
171
|
+
.footer-rule { display: block; width: 32px; height: 2px; background: ${theme.primaryColor}; margin: 0 auto 16px; }`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------- Editorial blocks ----------
|
|
175
|
+
|
|
176
|
+
function masthead({ brandName, brandUrl, theme, issue, tagline }) {
|
|
177
|
+
return singleColumnSection({
|
|
178
|
+
background: theme.secondaryColor,
|
|
179
|
+
padding: `48px 40px 40px 40px`,
|
|
180
|
+
content: `<mj-text>
|
|
181
|
+
<div class="masthead-issue">${escape(issue.line)}</div>
|
|
182
|
+
<div class="masthead-rule"></div>
|
|
183
|
+
<div class="masthead-brand"><a href="${brandUrl}" style="color: #ffffff; text-decoration: none; border-bottom: none;">${escape(brandName).toUpperCase()}</a></div>
|
|
184
|
+
<div class="masthead-tagline">${escape(tagline || '')}</div>
|
|
185
|
+
</mj-text>`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function coverHeadline({ subject, theme, gutter }) {
|
|
190
|
+
return singleColumnSection({
|
|
191
|
+
background: '#ffffff',
|
|
192
|
+
padding: `48px ${gutter} 8px ${gutter}`,
|
|
193
|
+
content: `<mj-text padding="0">${eyebrow({ text: 'In this issue', color: theme.primaryColor })}</mj-text>
|
|
194
|
+
<mj-spacer height="14px" />
|
|
195
|
+
<mj-text padding="0"><h2 style="font-size: 38px; line-height: 1.1;">${escape(subject || '')}</h2></mj-text>`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function dropCapIntro({ intro, gutter }) {
|
|
200
|
+
if (!intro) {
|
|
201
|
+
return '';
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return singleColumnSection({
|
|
205
|
+
background: '#ffffff',
|
|
206
|
+
padding: `32px ${gutter} 40px ${gutter}`,
|
|
207
|
+
content: `<mj-text padding="0"><div class="lede">${inlineMarkdown(intro)}</div></mj-text>`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function editorialSection({ section, imagePath, theme, index, total }) {
|
|
212
|
+
const gutter = theme.spacing.gutter;
|
|
213
|
+
const WHITE = '#ffffff';
|
|
214
|
+
const safeSection = section || {};
|
|
215
|
+
|
|
216
|
+
// Skip an entirely empty section — no title, no body — so the orchestrator's
|
|
217
|
+
// shared divider doesn't sandwich a hollow numeral block between two real
|
|
218
|
+
// sections.
|
|
219
|
+
if (!safeSection.title && !safeSection.body && !safeSection.cta) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const num = String(index + 1).padStart(2, '0');
|
|
224
|
+
const totalLabel = String(total).padStart(2, '0');
|
|
225
|
+
const pullQuote = pullQuoteFrom(safeSection.body || '');
|
|
226
|
+
const bodyHtml = stripSentence(safeSection.body || '', pullQuote);
|
|
227
|
+
const imageOnLeft = index % 2 === 0;
|
|
228
|
+
|
|
229
|
+
const imageColumn = column({
|
|
230
|
+
width: '60%',
|
|
231
|
+
content: `<mj-image src="${escape(imagePath || '')}" alt="${escape(safeSection.title || '')}" padding="0" border-radius="0" />`,
|
|
232
|
+
});
|
|
233
|
+
const numeralColumn = column({
|
|
234
|
+
width: '40%',
|
|
235
|
+
content: `<mj-text padding="20px"><div class="numeral-fallback" style="text-align: center;">${num}</div></mj-text>`,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const imageRow = imagePath
|
|
239
|
+
? twoColumnSection({
|
|
240
|
+
background: WHITE,
|
|
241
|
+
padding: `0 ${gutter} 0 ${gutter}`,
|
|
242
|
+
left: imageOnLeft ? imageColumn : numeralColumn,
|
|
243
|
+
right: imageOnLeft ? numeralColumn : imageColumn,
|
|
244
|
+
})
|
|
245
|
+
: singleColumnSection({
|
|
246
|
+
background: WHITE,
|
|
247
|
+
padding: `0 ${gutter} 0 ${gutter}`,
|
|
248
|
+
content: `<mj-text><div class="numeral-fallback">${num}</div></mj-text>`,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const titleRow = safeSection.title
|
|
252
|
+
? singleColumnSection({
|
|
253
|
+
background: WHITE,
|
|
254
|
+
padding: `28px ${gutter} 0 ${gutter}`,
|
|
255
|
+
content: `<mj-text padding="0"><div class="section-meta">Story ${num} of ${totalLabel}</div></mj-text>
|
|
256
|
+
<mj-spacer height="10px" />
|
|
257
|
+
<mj-text padding="0"><h2>${escape(safeSection.title)}</h2></mj-text>`,
|
|
258
|
+
})
|
|
259
|
+
: '';
|
|
260
|
+
|
|
261
|
+
const pullQuoteRow = pullQuote
|
|
262
|
+
? singleColumnSection({
|
|
263
|
+
background: WHITE,
|
|
264
|
+
padding: `8px ${gutter} 0 ${gutter}`,
|
|
265
|
+
content: `<mj-text padding="0"><div class="pullquote">${escape(pullQuote)}</div></mj-text>`,
|
|
266
|
+
})
|
|
267
|
+
: '';
|
|
268
|
+
|
|
269
|
+
const bodyRow = bodyHtml
|
|
270
|
+
? singleColumnSection({
|
|
271
|
+
background: WHITE,
|
|
272
|
+
padding: `12px ${gutter} 24px ${gutter}`,
|
|
273
|
+
content: `<mj-text padding="0">${markdownToHtml(bodyHtml)}</mj-text>`,
|
|
274
|
+
})
|
|
275
|
+
: '';
|
|
276
|
+
|
|
277
|
+
const ctaRow = safeSection.cta?.label && safeSection.cta?.url
|
|
278
|
+
? singleColumnSection({
|
|
279
|
+
background: WHITE,
|
|
280
|
+
padding: `8px ${gutter} 0 ${gutter}`,
|
|
281
|
+
content: `<mj-button href="${escape(safeSection.cta.url)}" align="left">${escape(safeSection.cta.label)} →</mj-button>`,
|
|
282
|
+
})
|
|
283
|
+
: '';
|
|
284
|
+
|
|
285
|
+
// Section never renders its own trailing divider — the build orchestrator
|
|
286
|
+
// joins adjacent sections with one shared divider so spacing is symmetric
|
|
287
|
+
// and no rules stack against sponsorship hairlines. Falsy rows are filtered
|
|
288
|
+
// so an empty/partial section doesn't emit hollow whitespace blocks.
|
|
289
|
+
return [imageRow, titleRow, pullQuoteRow, bodyRow, ctaRow].filter(Boolean).join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function editorialSignoff({ signoff, theme, gutter }) {
|
|
293
|
+
if (!signoff) {
|
|
294
|
+
return '';
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const html = escape(signoff).replace(/\n/g, '<br/>');
|
|
298
|
+
|
|
299
|
+
return singleColumnSection({
|
|
300
|
+
background: '#ffffff',
|
|
301
|
+
padding: `40px ${gutter} 8px ${gutter}`,
|
|
302
|
+
content: `<mj-text padding="0"><div class="signoff">${html}</div></mj-text>`,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
module.exports = {
|
|
307
|
+
build,
|
|
308
|
+
meta: {
|
|
309
|
+
name: 'editorial',
|
|
310
|
+
description: 'Magazine-style: masthead, drop-cap intro, numbered sections, pull-quotes, italic signoff.',
|
|
311
|
+
requires: ['subject', 'preheader', 'intro', 'sections', 'signoff'],
|
|
312
|
+
optional: ['citations', 'image_prompt', 'cta'],
|
|
313
|
+
supports: { sponsorships: ['top', 'middle', 'end'], citations: true, images: true },
|
|
314
|
+
},
|
|
315
|
+
schema: CLASSIC_SCHEMA,
|
|
316
|
+
normalize: normalizeClassic,
|
|
317
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for the `field-report` template.
|
|
3
|
+
*
|
|
4
|
+
* Field Report aesthetic = wire-service correspondent + Bloomberg terminal.
|
|
5
|
+
* The helpers below encode the conventions that make that look feel coherent:
|
|
6
|
+
* - mono "kicker" labels in oxblood (a darker, more editorial primary)
|
|
7
|
+
* - "VOL. NNNN" issue numbering (continues the editorial issue counter so
|
|
8
|
+
* the brand reads as a single ongoing publication across templates)
|
|
9
|
+
* - terminal-style data callouts ("// THIS ISSUE //" and per-dispatch
|
|
10
|
+
* data tables) rendered as black bg + mono green-on-black, evoking
|
|
11
|
+
* a trader's terminal
|
|
12
|
+
* - dateline formatting ("MAY 12 · LOS ANGELES")
|
|
13
|
+
*
|
|
14
|
+
* Lives separately from shared.js (which is template-agnostic) and from
|
|
15
|
+
* editorial-helpers.js (which encodes a totally different aesthetic).
|
|
16
|
+
*/
|
|
17
|
+
const { escape } = require('./shared.js');
|
|
18
|
+
const { computeIssueNumber } = require('./editorial-helpers.js');
|
|
19
|
+
|
|
20
|
+
const SERIF_FONT = `'Tiempos Headline', 'Tiempos Text', Georgia, 'Times New Roman', serif`;
|
|
21
|
+
const MONO_FONT = `'JetBrains Mono', 'IBM Plex Mono', Menlo, Consolas, monospace`;
|
|
22
|
+
|
|
23
|
+
// Terminal palette — used by the TLDR block and per-dispatch data callouts.
|
|
24
|
+
// Always rendered against any brand color theme — the terminal block is a
|
|
25
|
+
// fixed visual anchor that says "this is a Field Report" regardless of brand.
|
|
26
|
+
const TERMINAL = {
|
|
27
|
+
bg: '#0d1117', // near-black, slightly cooler than pure black
|
|
28
|
+
fg: '#7fffb0', // muted phosphor green
|
|
29
|
+
label: '#ff6b6b', // alert red for labels
|
|
30
|
+
rule: '#1f2937', // subtle grid rule
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Default oxblood/ink primary — overridden by theme.primaryColor if set.
|
|
34
|
+
const DEFAULT_INK = '#7a1f1f';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a mono "kicker" label. Goes above a headline. Examples:
|
|
38
|
+
* - "DISPATCH"
|
|
39
|
+
* - "FIELD NOTES"
|
|
40
|
+
* - "WATCH"
|
|
41
|
+
* - "BRIEF"
|
|
42
|
+
* Uppercase, heavy letter-spacing, in the ink color.
|
|
43
|
+
*/
|
|
44
|
+
function kicker({ text, color }) {
|
|
45
|
+
return `<div style="font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; text-transform: uppercase; font-weight: 700; color: ${color || DEFAULT_INK};">${escape(text || '')}</div>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Build the issue strap shown at the very top of the newsletter:
|
|
50
|
+
* "VOL. 0863 · MAY 12, 2026 · LOS ANGELES"
|
|
51
|
+
* The Volume number is the same monotonic counter editorial uses, so the
|
|
52
|
+
* brand has one continuous "issue history" regardless of which template
|
|
53
|
+
* shipped the issue.
|
|
54
|
+
*/
|
|
55
|
+
function issueStrap({ now, dateline }) {
|
|
56
|
+
const d = now || new Date();
|
|
57
|
+
const vol = computeIssueNumber(d);
|
|
58
|
+
const datePart = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toUpperCase();
|
|
59
|
+
const placePart = dateline ? ` · ${escape(dateline.toUpperCase())}` : '';
|
|
60
|
+
return `VOL. ${vol} · ${datePart}${placePart}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Per-dispatch dateline. Combines location + a short date.
|
|
65
|
+
* Returns e.g. "REMOTE · MAY 12" or "NEW YORK · MAY 12".
|
|
66
|
+
*/
|
|
67
|
+
function dispatchDateline({ now, location }) {
|
|
68
|
+
const d = now || new Date();
|
|
69
|
+
const datePart = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }).toUpperCase();
|
|
70
|
+
const loc = (location || 'REMOTE').toUpperCase();
|
|
71
|
+
return `${escape(loc)} · ${escape(datePart)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render the per-dispatch "data callout" — a small terminal-style block
|
|
76
|
+
* with up to 4 label/value pairs. Used as the right-column rail next to
|
|
77
|
+
* the dispatch body, OR as a full-width strip below the headline when
|
|
78
|
+
* the dispatch has no body image.
|
|
79
|
+
*
|
|
80
|
+
* dataPoints: [{ label, value }]
|
|
81
|
+
* Returns '' when empty.
|
|
82
|
+
*/
|
|
83
|
+
function dataCallout({ dataPoints, padding, fullWidth }) {
|
|
84
|
+
if (!Array.isArray(dataPoints) || !dataPoints.length) {
|
|
85
|
+
return '';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const rows = dataPoints.slice(0, 4).map((dp) => `
|
|
89
|
+
<div style="display: flex; justify-content: space-between; align-items: baseline; padding: 8px 0; border-bottom: 1px solid ${TERMINAL.rule};">
|
|
90
|
+
<span style="font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: ${TERMINAL.label};">${escape(dp.label || '')}</span>
|
|
91
|
+
<span style="font-family: ${MONO_FONT}; font-size: 16px; font-weight: 700; color: ${TERMINAL.fg};">${escape(dp.value || '')}</span>
|
|
92
|
+
</div>`).join('');
|
|
93
|
+
|
|
94
|
+
const label = fullWidth
|
|
95
|
+
? `<div style="font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; color: ${TERMINAL.label}; margin-bottom: 12px;">// BY THE NUMBERS //</div>`
|
|
96
|
+
: `<div style="font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; color: ${TERMINAL.label}; margin-bottom: 12px;">// DATA //</div>`;
|
|
97
|
+
|
|
98
|
+
return `<div style="background: ${TERMINAL.bg}; padding: ${padding || '20px'}; color: ${TERMINAL.fg};">
|
|
99
|
+
${label}
|
|
100
|
+
${rows}
|
|
101
|
+
</div>`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* TLDR strip rendered immediately under the masthead. Mono green-on-black,
|
|
106
|
+
* blinking-cursor vibe. Always one paragraph max.
|
|
107
|
+
*/
|
|
108
|
+
function tldrStrip({ tldr, gutter }) {
|
|
109
|
+
if (!tldr) {
|
|
110
|
+
return '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return `<div style="background: ${TERMINAL.bg}; padding: 28px ${gutter}; color: ${TERMINAL.fg};">
|
|
114
|
+
<div style="font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 4px; color: ${TERMINAL.label}; margin-bottom: 14px;">// THIS ISSUE //</div>
|
|
115
|
+
<div style="font-family: ${MONO_FONT}; font-size: 14px; line-height: 1.65; color: ${TERMINAL.fg};">${escape(tldr)}<span style="color: ${TERMINAL.label};">_</span></div>
|
|
116
|
+
</div>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* End-of-dispatch terminator. Appears at the bottom of each dispatch,
|
|
121
|
+
* before the divider to the next one. Like the "30" mark in old wire copy.
|
|
122
|
+
*/
|
|
123
|
+
function dispatchTerminator({ inkColor }) {
|
|
124
|
+
return `<div style="text-align: center; font-family: ${MONO_FONT}; font-size: 10px; letter-spacing: 6px; color: ${inkColor || DEFAULT_INK}; padding: 0 0 0 0;">— END DISPATCH —</div>`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = {
|
|
128
|
+
SERIF_FONT,
|
|
129
|
+
MONO_FONT,
|
|
130
|
+
TERMINAL,
|
|
131
|
+
DEFAULT_INK,
|
|
132
|
+
kicker,
|
|
133
|
+
issueStrap,
|
|
134
|
+
dispatchDateline,
|
|
135
|
+
dataCallout,
|
|
136
|
+
tldrStrip,
|
|
137
|
+
dispatchTerminator,
|
|
138
|
+
};
|