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.
- package/CHANGELOG.md +56 -0
- package/CLAUDE.md +43 -1501
- package/TODO-CHARGEBLAST.md +32 -0
- package/TODO-email-auth.md +14 -0
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-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/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,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
|
+
* `` rewritten to `` 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(``).join(``);
|
|
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 =
|
|
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=""
|
package/templates/_.gitignore
CHANGED
|
@@ -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
|
+
}
|