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,52 @@
1
+ /**
2
+ * Deduplicate alt-text across DIFFERENT image URLs.
3
+ *
4
+ * Image filenames in the admin/post route are derived from the markdown image's
5
+ * alt-text. Two images sharing alt-text would otherwise produce the same filename
6
+ * and overwrite each other on upload, causing the second image to "disappear"
7
+ * (both `@post/` references in the body resolve to the same file).
8
+ *
9
+ * Strategy: when a non-header image's alt collides with an earlier image's alt
10
+ * AND its URL is different, suffix the alt with ` (N)` (where N is the
11
+ * occurrence count). Same URL keeps its original alt — repeated embeds of the
12
+ * exact same image are not a collision and should resolve to the same file.
13
+ *
14
+ * @param {Array<{src: string, alt: string, header?: boolean}>} images — image
15
+ * entries extracted from the body (plus optional header). Mutated in place:
16
+ * `image.alt` is rewritten when a collision is detected.
17
+ * @param {string} body — markdown body string. Returned with any
18
+ * `![oldAlt](src)` rewritten to `![newAlt](src)` for collisions.
19
+ * @returns {{images: Array, body: string}} mutated images array and rewritten body.
20
+ */
21
+ module.exports = function deduplicateImageAlts(images, body) {
22
+ const seenAltByUrl = new Map();
23
+ const altCountByAlt = new Map();
24
+ let rewrittenBody = body;
25
+
26
+ for (const image of images) {
27
+ if (image.header) {
28
+ continue;
29
+ }
30
+
31
+ const existingForUrl = seenAltByUrl.get(image.src);
32
+ if (existingForUrl) {
33
+ // Same URL appeared earlier — reuse its (possibly already-suffixed) alt.
34
+ // Repeated embeds of the same image should resolve to the same upload.
35
+ image.alt = existingForUrl;
36
+ continue;
37
+ }
38
+
39
+ const count = (altCountByAlt.get(image.alt) || 0) + 1;
40
+ altCountByAlt.set(image.alt, count);
41
+
42
+ if (count > 1) {
43
+ const newAlt = `${image.alt} (${count})`;
44
+ rewrittenBody = rewrittenBody.split(`![${image.alt}](${image.src})`).join(`![${newAlt}](${image.src})`);
45
+ image.alt = newAlt;
46
+ }
47
+
48
+ seenAltByUrl.set(image.src, image.alt);
49
+ }
50
+
51
+ return { images, body: rewrittenBody };
52
+ };
@@ -11,6 +11,8 @@ const path = require('path');
11
11
  const { Octokit } = require('@octokit/rest');
12
12
  const { get, set } = require('lodash');
13
13
 
14
+ const deduplicateImageAlts = require('./deduplicate-image-alts');
15
+
14
16
  const POST_TEMPLATE = jetpack.read(`${__dirname}/templates/post.html`);
15
17
  const IMAGE_PATH_SRC = `src/assets/images/blog/post-{id}/`;
16
18
  const IMAGE_REGEX = /(?:!\[(.*?)\]\((.*?)\))/img;
@@ -63,14 +65,8 @@ module.exports = async ({ assistant, Manager, user, settings, analytics }) => {
63
65
  return assistant.respond('Missing required parameter: body', { code: 400 });
64
66
  }
65
67
 
66
- // Fix URL
67
- settings.url = settings.url
68
- .replace(/blog\//ig, '')
69
- .replace(/^\/|\/$/g, '')
70
- .replace(/[^a-zA-Z0-9]/g, '-')
71
- .replace(/-+/g, '-')
72
- .replace(/^-+|-+$/g, '')
73
- .toLowerCase();
68
+ // Fix URL — strip blog/ prefix then slugify (slugify handles slashes/special chars)
69
+ settings.url = Manager.Utilities().slugify(settings.url.replace(/blog\//ig, ''));
74
70
 
75
71
  // Fix body
76
72
  settings.body = settings.body
@@ -159,6 +155,11 @@ async function downloadImages(assistant, settings) {
159
155
  header: true,
160
156
  });
161
157
 
158
+ // Deduplicate alt-text across different image URLs (mutates images in place,
159
+ // returns rewritten body). See deduplicate-image-alts.js for full rationale.
160
+ const dedup = deduplicateImageAlts(images, settings.body);
161
+ settings.body = dedup.body;
162
+
162
163
  assistant.log('downloadImages(): images', images);
163
164
 
164
165
  if (!images.length) {
@@ -199,7 +200,7 @@ async function downloadImages(assistant, settings) {
199
200
  // Helper: Download image
200
201
  async function downloadImage(assistant, src, alt) {
201
202
  const fetch = assistant.Manager.require('wonderful-fetch');
202
- const hyphenated = hyphenate(alt);
203
+ const hyphenated = assistant.Manager.Utilities().slugify(alt);
203
204
 
204
205
  assistant.log(`downloadImage(): src=${src}, alt=${alt}, hyphenated=${hyphenated}`);
205
206
 
@@ -325,11 +326,3 @@ function formatClone(payload) {
325
326
  return payload;
326
327
  }
327
328
 
328
- // Helper: Hyphenate string
329
- function hyphenate(s) {
330
- return s
331
- .replace(/[^a-zA-Z0-9]/g, '-')
332
- .replace(/-+/g, '-')
333
- .replace(/^-|-$/g, '')
334
- .toLowerCase();
335
- }
package/templates/_.env CHANGED
@@ -3,6 +3,7 @@
3
3
  BACKEND_MANAGER_KEY=""
4
4
  BACKEND_MANAGER_NAMESPACE=""
5
5
  BACKEND_MANAGER_OPENAI_API_KEY=""
6
+ BACKEND_MANAGER_ANTHROPIC_API_KEY=""
6
7
 
7
8
  # GitHub
8
9
  GITHUB_TOKEN=""
@@ -10,6 +11,9 @@ GITHUB_TOKEN=""
10
11
  # OpenAI
11
12
  OPENAI_API_KEY=""
12
13
 
14
+ # Anthropic
15
+ ANTHROPIC_API_KEY=""
16
+
13
17
  # Payment Processors
14
18
  PAYPAL_CLIENT_SECRET=""
15
19
  STRIPE_SECRET_KEY=""
@@ -48,6 +48,7 @@ node_modules/
48
48
  .runtimeconfig.json
49
49
  service-account*.json
50
50
  bem-reload-trigger.js
51
+ .temp/
51
52
  _legacy
52
53
 
53
54
  # ========== Custom Values ==========
@@ -7,6 +7,18 @@
7
7
  contact: {
8
8
  email: 'support@example.com',
9
9
  },
10
+ // Physical postal address — required by CAN-SPAM in commercial email footers.
11
+ // Rendered in the newsletter footer + transactional email footers.
12
+ // Structured so the renderer can format it consistently across locales
13
+ // and break onto multiple lines if needed. line2/region/postalCode are optional.
14
+ address: {
15
+ line1: '123 Main St',
16
+ line2: 'Suite 100',
17
+ city: 'City',
18
+ region: 'ST',
19
+ postalCode: '12345',
20
+ country: 'United States',
21
+ },
10
22
  images: {
11
23
  wordmark: 'https://example.com/wordmark.png',
12
24
  brandmark: 'https://example.com/wordmark.png',
@@ -17,6 +29,7 @@
17
29
  user: 'username',
18
30
  repo_website: 'https://github.com/username/backend-manager',
19
31
  },
32
+ parent: '', // URL of the parent BEM instance (e.g., 'https://api.itwcreativeworks.com') — used by newsletter generator to pull sources
20
33
  sentry: {
21
34
  dsn: 'https://d965557418748jd749d837asf00552f@o777489.ingest.sentry.io/8789941',
22
35
  },
@@ -129,14 +142,45 @@
129
142
  beehiiv: {
130
143
  enabled: false,
131
144
  // publicationId: 'pub_xxxxx', // Set to skip fuzzy-match API call
145
+ // Content pipeline. Lives under the provider that publishes the result —
146
+ // Beehiiv for newsletters, eventually SendGrid for promo blasts. The
147
+ // shape is the same regardless of provider: sources, tone, template,
148
+ // theme, sponsorships. `beehiiv.enabled: false` above disables it all.
149
+ content: {
150
+ categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
151
+ // AI-customization fields below are all optional with sensible defaults
152
+ instructions: '', // free-form text passed to the AI ("focus on X", "avoid Y", brand voice notes)
153
+ tone: 'professional', // 'professional', 'casual', 'actionable', 'witty', etc. — passed to AI prompt
154
+ template: 'clean', // 'clean' | 'editorial' | 'field-report' — layout template (each owns its own content shape and aesthetic)
155
+ theme: {
156
+ primaryColor: '#5B5BFF', // accent color: buttons, links, brand text
157
+ secondaryColor: '#1E1E2A', // body text color
158
+ accentColor: '#F6F7FB', // page background outside the 600px body
159
+ font: 'Inter, system-ui, sans-serif',
160
+ },
161
+ // AI provider defaults are hard-coded in the library (openai for structure,
162
+ // anthropic for SVG — each chosen for what each model does best). Override
163
+ // per-run only via env: NEWSLETTER_PROVIDER_STRUCTURE / NEWSLETTER_PROVIDER_SVG.
164
+ // Brand-owned sponsorship/promo slots — rendered as "sponsored" blocks inline
165
+ // with the newsletter. Use for promoting your own products, affiliate offers,
166
+ // or paid placements. Each entry can be positioned at 'top', 'middle', or 'end'.
167
+ // Per-campaign overrides via settings.sponsorships on a marketing-campaigns doc.
168
+ sponsorships: [
169
+ // {
170
+ // label: 'Sponsored', // optional eyebrow label (defaults to "Sponsored")
171
+ // headline: 'Grow your audience faster', // bold headline
172
+ // body: 'Short pitch text…', // 1-2 sentence body
173
+ // url: 'https://somiibo.com/promo', // click destination
174
+ // image: 'https://...png', // optional image URL
175
+ // ctaLabel: 'Try Somiibo', // button text (default: "Learn more")
176
+ // position: 'middle', // 'top' | 'middle' | 'end' (default: 'middle')
177
+ // },
178
+ ],
179
+ },
132
180
  },
133
181
  prune: {
134
182
  enabled: true,
135
183
  },
136
- newsletter: {
137
- enabled: false,
138
- categories: [], // e.g., ['social-media', 'marketing'] — content categories to pull from parent server
139
- },
140
184
  },
141
185
  firebaseConfig: {
142
186
  apiKey: '123-456',
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Test: helpers/utilities.slugify()
3
+ * Unit tests for the canonical URL slug builder.
4
+ *
5
+ * Run: npx mgr test helpers/slugify
6
+ *
7
+ * slugify is the SSOT used by:
8
+ * - BEM admin/post (legacy + modern) for URL + image filenames
9
+ * - Sponsorship platform validator.buildFormatted()
10
+ *
11
+ * Contract:
12
+ * - Strip all non-alphanumeric characters → replace with `-`
13
+ * - Collapse runs of `-` into a single `-`
14
+ * - Trim leading/trailing `-`
15
+ * - Lowercase the result
16
+ * - Non-string input → empty string
17
+ */
18
+ const Utilities = require('../../src/manager/helpers/utilities.js');
19
+
20
+ const Manager = { libraries: {} };
21
+ const utilities = new Utilities(Manager);
22
+
23
+ module.exports = {
24
+ description: 'Utilities.slugify()',
25
+ type: 'group',
26
+
27
+ tests: [
28
+ // ─── Basic happy path ───
29
+
30
+ {
31
+ name: 'lowercases-simple-string',
32
+ async run({ assert }) {
33
+ assert.equal(utilities.slugify('Hello World'), 'hello-world');
34
+ },
35
+ },
36
+
37
+ {
38
+ name: 'preserves-numbers',
39
+ async run({ assert }) {
40
+ assert.equal(utilities.slugify('Top 10 Products 2025'), 'top-10-products-2025');
41
+ },
42
+ },
43
+
44
+ {
45
+ name: 'already-a-slug-passes-through',
46
+ async run({ assert }) {
47
+ assert.equal(utilities.slugify('already-a-slug'), 'already-a-slug');
48
+ },
49
+ },
50
+
51
+ {
52
+ name: 'single-word-unchanged',
53
+ async run({ assert }) {
54
+ assert.equal(utilities.slugify('hello'), 'hello');
55
+ },
56
+ },
57
+
58
+ // ─── The bug that drove this: collapse runs of `-` ───
59
+
60
+ {
61
+ name: 'collapses-double-dashes-from-literal-hyphen-space',
62
+ async run({ assert }) {
63
+ // The real-world example: "Copy- Paste" produced "copy--paste" before the fix
64
+ assert.equal(
65
+ utilities.slugify('AI Study Prompts That Work: Copy- Paste Questions for Every Subject'),
66
+ 'ai-study-prompts-that-work-copy-paste-questions-for-every-subject',
67
+ );
68
+ },
69
+ },
70
+
71
+ {
72
+ name: 'collapses-many-consecutive-dashes',
73
+ async run({ assert }) {
74
+ assert.equal(utilities.slugify('foo---bar'), 'foo-bar');
75
+ assert.equal(utilities.slugify('foo-----bar'), 'foo-bar');
76
+ },
77
+ },
78
+
79
+ {
80
+ name: 'collapses-mixed-runs-of-special-chars',
81
+ async run({ assert }) {
82
+ // " / - / " → multiple non-alphanum → all become one `-`
83
+ assert.equal(utilities.slugify('foo / - / bar'), 'foo-bar');
84
+ },
85
+ },
86
+
87
+ // ─── Leading/trailing trim ───
88
+
89
+ {
90
+ name: 'strips-leading-and-trailing-spaces',
91
+ async run({ assert }) {
92
+ assert.equal(utilities.slugify(' hello world '), 'hello-world');
93
+ },
94
+ },
95
+
96
+ {
97
+ name: 'strips-leading-and-trailing-dashes',
98
+ async run({ assert }) {
99
+ assert.equal(utilities.slugify('---hello---'), 'hello');
100
+ },
101
+ },
102
+
103
+ {
104
+ name: 'strips-leading-slash',
105
+ async run({ assert }) {
106
+ assert.equal(utilities.slugify('/foo/bar'), 'foo-bar');
107
+ },
108
+ },
109
+
110
+ {
111
+ name: 'strips-trailing-slash',
112
+ async run({ assert }) {
113
+ assert.equal(utilities.slugify('foo/bar/'), 'foo-bar');
114
+ },
115
+ },
116
+
117
+ {
118
+ name: 'strips-both-slashes',
119
+ async run({ assert }) {
120
+ assert.equal(utilities.slugify('/foo/bar/'), 'foo-bar');
121
+ },
122
+ },
123
+
124
+ // ─── Punctuation ───
125
+
126
+ {
127
+ name: 'colon-becomes-dash',
128
+ async run({ assert }) {
129
+ assert.equal(utilities.slugify('Title: Subtitle'), 'title-subtitle');
130
+ },
131
+ },
132
+
133
+ {
134
+ name: 'comma-becomes-dash',
135
+ async run({ assert }) {
136
+ assert.equal(utilities.slugify('one, two, three'), 'one-two-three');
137
+ },
138
+ },
139
+
140
+ {
141
+ name: 'question-mark-becomes-dash',
142
+ async run({ assert }) {
143
+ assert.equal(utilities.slugify('What is AI?'), 'what-is-ai');
144
+ },
145
+ },
146
+
147
+ {
148
+ name: 'exclamation-becomes-dash',
149
+ async run({ assert }) {
150
+ assert.equal(utilities.slugify('Hello World!'), 'hello-world');
151
+ },
152
+ },
153
+
154
+ {
155
+ name: 'period-becomes-dash',
156
+ async run({ assert }) {
157
+ assert.equal(utilities.slugify('v1.2.3'), 'v1-2-3');
158
+ },
159
+ },
160
+
161
+ {
162
+ name: 'apostrophe-becomes-dash',
163
+ async run({ assert }) {
164
+ assert.equal(utilities.slugify("don't stop"), 'don-t-stop');
165
+ },
166
+ },
167
+
168
+ {
169
+ name: 'quotes-become-dashes',
170
+ async run({ assert }) {
171
+ assert.equal(utilities.slugify('"Hello" said the dog'), 'hello-said-the-dog');
172
+ },
173
+ },
174
+
175
+ {
176
+ name: 'parens-become-dashes',
177
+ async run({ assert }) {
178
+ assert.equal(utilities.slugify('Function (advanced)'), 'function-advanced');
179
+ },
180
+ },
181
+
182
+ {
183
+ name: 'brackets-become-dashes',
184
+ async run({ assert }) {
185
+ assert.equal(utilities.slugify('Array[0] access'), 'array-0-access');
186
+ },
187
+ },
188
+
189
+ {
190
+ name: 'ampersand-becomes-dash',
191
+ async run({ assert }) {
192
+ assert.equal(utilities.slugify('Salt & Pepper'), 'salt-pepper');
193
+ },
194
+ },
195
+
196
+ {
197
+ name: 'hash-becomes-dash',
198
+ async run({ assert }) {
199
+ assert.equal(utilities.slugify('issue #42'), 'issue-42');
200
+ },
201
+ },
202
+
203
+ {
204
+ name: 'at-sign-becomes-dash',
205
+ async run({ assert }) {
206
+ assert.equal(utilities.slugify('user@example.com'), 'user-example-com');
207
+ },
208
+ },
209
+
210
+ // ─── Unicode / non-ASCII ───
211
+
212
+ {
213
+ name: 'em-dash-becomes-dash',
214
+ async run({ assert }) {
215
+ assert.equal(utilities.slugify('Title — Subtitle'), 'title-subtitle');
216
+ },
217
+ },
218
+
219
+ {
220
+ name: 'curly-quotes-become-dashes',
221
+ async run({ assert }) {
222
+ assert.equal(utilities.slugify('“Hello”'), 'hello');
223
+ },
224
+ },
225
+
226
+ {
227
+ name: 'emoji-becomes-dash',
228
+ async run({ assert }) {
229
+ assert.equal(utilities.slugify('Rocket 🚀 launch'), 'rocket-launch');
230
+ },
231
+ },
232
+
233
+ {
234
+ name: 'accented-chars-become-dashes',
235
+ async run({ assert }) {
236
+ // Slugify is ASCII-only by design — accents get stripped to dashes
237
+ // (Future improvement could be to transliterate, but current behavior is documented here)
238
+ assert.equal(utilities.slugify('café'), 'caf');
239
+ },
240
+ },
241
+
242
+ // ─── Whitespace variants ───
243
+
244
+ {
245
+ name: 'tab-becomes-dash',
246
+ async run({ assert }) {
247
+ assert.equal(utilities.slugify('foo\tbar'), 'foo-bar');
248
+ },
249
+ },
250
+
251
+ {
252
+ name: 'newline-becomes-dash',
253
+ async run({ assert }) {
254
+ assert.equal(utilities.slugify('foo\nbar'), 'foo-bar');
255
+ },
256
+ },
257
+
258
+ {
259
+ name: 'multiple-spaces-collapse',
260
+ async run({ assert }) {
261
+ assert.equal(utilities.slugify('foo bar'), 'foo-bar');
262
+ },
263
+ },
264
+
265
+ {
266
+ name: 'mixed-whitespace-collapses',
267
+ async run({ assert }) {
268
+ assert.equal(utilities.slugify('foo \t \n bar'), 'foo-bar');
269
+ },
270
+ },
271
+
272
+ // ─── Edge cases / non-string input ───
273
+
274
+ {
275
+ name: 'empty-string-returns-empty',
276
+ async run({ assert }) {
277
+ assert.equal(utilities.slugify(''), '');
278
+ },
279
+ },
280
+
281
+ {
282
+ name: 'all-punctuation-returns-empty',
283
+ async run({ assert }) {
284
+ assert.equal(utilities.slugify('!@#$%^&*()'), '');
285
+ },
286
+ },
287
+
288
+ {
289
+ name: 'only-dashes-returns-empty',
290
+ async run({ assert }) {
291
+ assert.equal(utilities.slugify('-----'), '');
292
+ },
293
+ },
294
+
295
+ {
296
+ name: 'only-whitespace-returns-empty',
297
+ async run({ assert }) {
298
+ assert.equal(utilities.slugify(' '), '');
299
+ },
300
+ },
301
+
302
+ {
303
+ name: 'null-returns-empty-string',
304
+ async run({ assert }) {
305
+ assert.equal(utilities.slugify(null), '');
306
+ },
307
+ },
308
+
309
+ {
310
+ name: 'undefined-returns-empty-string',
311
+ async run({ assert }) {
312
+ assert.equal(utilities.slugify(undefined), '');
313
+ },
314
+ },
315
+
316
+ {
317
+ name: 'number-returns-empty-string',
318
+ async run({ assert }) {
319
+ // Non-string input → empty string (caller is responsible for stringifying)
320
+ assert.equal(utilities.slugify(42), '');
321
+ },
322
+ },
323
+
324
+ {
325
+ name: 'object-returns-empty-string',
326
+ async run({ assert }) {
327
+ assert.equal(utilities.slugify({ foo: 'bar' }), '');
328
+ },
329
+ },
330
+
331
+ {
332
+ name: 'array-returns-empty-string',
333
+ async run({ assert }) {
334
+ assert.equal(utilities.slugify(['hello']), '');
335
+ },
336
+ },
337
+
338
+ // ─── Real-world examples ───
339
+
340
+ {
341
+ name: 'real-blog-post-title',
342
+ async run({ assert }) {
343
+ assert.equal(
344
+ utilities.slugify('10 Best Productivity Apps for Students in 2025'),
345
+ '10-best-productivity-apps-for-students-in-2025',
346
+ );
347
+ },
348
+ },
349
+
350
+ {
351
+ name: 'real-blog-post-with-colon-and-slash',
352
+ async run({ assert }) {
353
+ assert.equal(
354
+ utilities.slugify('Beginner\'s Guide: Python vs JavaScript'),
355
+ 'beginner-s-guide-python-vs-javascript',
356
+ );
357
+ },
358
+ },
359
+
360
+ {
361
+ name: 'real-image-alt-text',
362
+ async run({ assert }) {
363
+ // The downloadImage() caller uses slugify(alt) to build a tmp filename
364
+ assert.equal(
365
+ utilities.slugify('Diagram showing the user flow (v2)'),
366
+ 'diagram-showing-the-user-flow-v2',
367
+ );
368
+ },
369
+ },
370
+
371
+ {
372
+ name: 'real-url-with-blog-prefix-removed-first',
373
+ async run({ assert }) {
374
+ // Mimics how callers strip "blog/" before slugifying
375
+ const url = 'blog/some-existing-post';
376
+ assert.equal(
377
+ utilities.slugify(url.replace(/blog\//ig, '')),
378
+ 'some-existing-post',
379
+ );
380
+ },
381
+ },
382
+
383
+ // ─── Idempotency ───
384
+
385
+ {
386
+ name: 'idempotent-double-slugify',
387
+ async run({ assert }) {
388
+ const once = utilities.slugify('Hello World: Foo & Bar!');
389
+ const twice = utilities.slugify(once);
390
+ assert.equal(twice, once, 'Applying slugify twice should equal applying it once');
391
+ },
392
+ },
393
+ ],
394
+ };
@@ -0,0 +1,31 @@
1
+ {
2
+ "_comment": "Predefined fixture for the `clean` template. Loaded via NEWSLETTER_FIXTURE=clean. Edit freely — no AI involved. Classic content shape: intro + sections[{title, body, cta, image_prompt}].",
3
+ "subject": "Identity is the new growth lever",
4
+ "preheader": "Three platform shifts to lock in this week.",
5
+ "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later.",
6
+ "sections": [
7
+ {
8
+ "title": "LinkedIn ships verified-profile gates for business accounts",
9
+ "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. Accounts with documented attribution histories sail through verification in under twelve hours. The practical implication is to lock down your attribution log this week, not next.",
10
+ "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
+ "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
+ },
13
+ {
14
+ "title": "Documentation is the new operator moat",
15
+ "body": "Operator playbooks are now the difference between an account that recovers from a flag and one that gets permanently restricted. Teams running documented processes recover in days. The ones without burn weeks negotiating with support queues.",
16
+ "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
+ "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
+ },
19
+ {
20
+ "title": "YouTube tests creator-attribution metadata on uploads",
21
+ "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows will adapt in an afternoon.",
22
+ "cta": null,
23
+ "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
+ }
25
+ ],
26
+ "signoff": "Best,\nThe Somiibo Team",
27
+ "citations": [
28
+ { "note": "LinkedIn flagged 12,000 impersonation attempts in the verified-profile beta.", "source": "Reported by LinkedIn product team, May 2026" },
29
+ { "note": "Operator playbook adoption up 38% week-over-week in Q2.", "source": "Per platform analytics, May 2026" }
30
+ ]
31
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "_comment": "Predefined fixture for the `editorial` template. Loaded via NEWSLETTER_FIXTURE=editorial. Same classic content shape as clean.json — both templates render the same data.",
3
+ "subject": "Identity is the new growth lever",
4
+ "preheader": "Three platform shifts to lock in this week.",
5
+ "intro": "Identity matters because trust is the new growth lever. Teams that handle this well will spend less time cleaning up later, and the patterns emerging this week tell you exactly where to focus.",
6
+ "sections": [
7
+ {
8
+ "title": "LinkedIn ships verified-profile gates for business accounts",
9
+ "body": "LinkedIn now requires verified identity attestations on any business profile claiming more than 500 followers. The rollout is fast and uneven — some accounts hit the gate inside two days of profile activity, others sit unchallenged for weeks. The pattern that matters is this: accounts with documented attribution histories sail through verification in under twelve hours. Accounts without get queued. The practical implication is to lock down your attribution log this week, not next.",
10
+ "cta": { "label": "Read the brief", "url": "https://somiibo.com/blog/linkedin-verification" },
11
+ "image_prompt": "Abstract geometric illustration of a verification checkmark inside a layered profile card."
12
+ },
13
+ {
14
+ "title": "Documentation is the new operator moat",
15
+ "body": "Operator playbooks — once dismissed as overhead — are now the difference between an account that recovers from a flag and one that gets permanently restricted. The teams already running on documented processes recover in days. The ones without burn weeks negotiating with support queues. Treat your playbook as a compliance artifact, not a knowledge-management nice-to-have.",
16
+ "cta": { "label": "See the playbook", "url": "https://somiibo.com/blog/operator-playbook" },
17
+ "image_prompt": "Stack of geometric document layers casting clean shadows on a flat surface."
18
+ },
19
+ {
20
+ "title": "YouTube tests creator-attribution metadata on uploads",
21
+ "body": "A subset of channels saw a new upload checkbox this week asking whether content was assisted by automation. The checkbox is optional today. Reading the room: it will not stay optional. Creators with clear internal workflows already labeled will adapt in an afternoon. Everyone else will spend a quarter retrofitting.",
22
+ "cta": null,
23
+ "image_prompt": "Minimalist play button morphing into a label tag, flat geometric style."
24
+ }
25
+ ],
26
+ "signoff": "Best,\nThe Somiibo Team",
27
+ "citations": [
28
+ { "note": "LinkedIn flagged 12,000 impersonation attempts in the verified-profile beta.", "source": "Reported by LinkedIn product team, May 2026" },
29
+ { "note": "Operator playbook adoption up 38% week-over-week in Q2.", "source": "Per platform analytics, May 2026" }
30
+ ]
31
+ }