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,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)} &nbsp;→</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
+ };