jamdesk 1.1.142 → 1.1.144
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/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +20 -0
- package/dist/commands/dev.js.map +1 -1
- package/package.json +1 -1
- package/vendored/app/[[...slug]]/page.tsx +8 -1
- package/vendored/app/api/subscribe/[project]/route.ts +154 -0
- package/vendored/components/mdx/EmailSubscribe.tsx +167 -0
- package/vendored/components/mdx/MDXComponents.tsx +3 -0
- package/vendored/components/mdx/NativeSubscribeForm.tsx +172 -0
- package/vendored/components/navigation/Header.tsx +4 -3
- package/vendored/components/search/SearchModal.tsx +10 -4
- package/vendored/lib/docs-types.ts +45 -2
- package/vendored/lib/email-subscribe-autoplacement.ts +46 -0
- package/vendored/lib/email-subscribe-providers.ts +120 -0
- package/vendored/lib/middleware-helpers.ts +21 -0
- package/vendored/lib/navigation-resolver.ts +5 -5
- package/vendored/lib/newsletter/adapters/beehiiv.ts +23 -0
- package/vendored/lib/newsletter/adapters/brevo.ts +31 -0
- package/vendored/lib/newsletter/adapters/kit.ts +35 -0
- package/vendored/lib/newsletter/adapters/loops.ts +22 -0
- package/vendored/lib/newsletter/adapters/mailchimp.ts +52 -0
- package/vendored/lib/newsletter/adapters/resend.ts +23 -0
- package/vendored/lib/newsletter/adapters/sendgrid.ts +23 -0
- package/vendored/lib/newsletter/descriptors.ts +84 -0
- package/vendored/lib/newsletter/http.ts +19 -0
- package/vendored/lib/newsletter/registry.ts +27 -0
- package/vendored/lib/newsletter/types.ts +52 -0
- package/vendored/lib/newsletter-display.ts +43 -0
- package/vendored/lib/render-doc-page.tsx +46 -2
- package/vendored/lib/seo.ts +13 -2
- package/vendored/lib/validate-content-images.ts +150 -0
- package/vendored/schema/docs-schema.json +29 -1
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/workspace-package-lock.json +3 -87
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
- package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
- package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
- package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
- package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
- package/dist/__tests__/unit/language-filter.test.js +0 -166
- package/dist/__tests__/unit/language-filter.test.js.map +0 -1
- package/dist/lib/language-filter.d.ts +0 -31
- package/dist/lib/language-filter.d.ts.map +0 -1
- package/dist/lib/language-filter.js +0 -14
- package/dist/lib/language-filter.js.map +0 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { NewsletterAdapter, NewsletterCreds } from '../types';
|
|
2
|
+
import { getDescriptor } from '../descriptors';
|
|
3
|
+
import { fetchJson } from '../http';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://api.kit.com/v4';
|
|
6
|
+
const headers = (secret: string) => ({ 'X-Kit-Api-Key': secret, 'content-type': 'application/json' });
|
|
7
|
+
|
|
8
|
+
export const kitAdapter: NewsletterAdapter = {
|
|
9
|
+
descriptor: getDescriptor('kit')!,
|
|
10
|
+
async validate(creds: NewsletterCreds) {
|
|
11
|
+
if (!creds.formId) return { ok: false, reason: 'formId required' };
|
|
12
|
+
// Probe the FORM resource, NOT just /account: a key that can read the account
|
|
13
|
+
// may have a wrong/inaccessible formId, which /account wouldn't catch — then
|
|
14
|
+
// every real signup 404s.
|
|
15
|
+
const { status } = await fetchJson(`${BASE}/forms/${creds.formId}/subscribers?per_page=1`,
|
|
16
|
+
{ method: 'GET', headers: headers(creds.secret) });
|
|
17
|
+
return status === 200 ? { ok: true } : { ok: false, reason: `form ${status}` };
|
|
18
|
+
},
|
|
19
|
+
async addContact(email: string, creds: NewsletterCreds) {
|
|
20
|
+
if (!creds.formId) return { ok: false, code: 'misconfigured' };
|
|
21
|
+
// Kit v4 split subscriber-creation from form-attachment: POST /forms/{id}/subscribers
|
|
22
|
+
// ONLY attaches a subscriber that ALREADY exists ("The subscriber being added to the
|
|
23
|
+
// form must already exist") — for a brand-new email it returns 2xx without adding
|
|
24
|
+
// anyone. So upsert the subscriber first (idempotent: 201 new / 200 existing), then
|
|
25
|
+
// attach them to the form.
|
|
26
|
+
const created = await fetchJson(`${BASE}/subscribers`, {
|
|
27
|
+
method: 'POST', headers: headers(creds.secret), body: JSON.stringify({ email_address: email }),
|
|
28
|
+
});
|
|
29
|
+
if (created.status < 200 || created.status >= 300) return { ok: false, status: created.status };
|
|
30
|
+
const { status } = await fetchJson(`${BASE}/forms/${creds.formId}/subscribers`, {
|
|
31
|
+
method: 'POST', headers: headers(creds.secret), body: JSON.stringify({ email_address: email }),
|
|
32
|
+
});
|
|
33
|
+
return status >= 200 && status < 300 ? { ok: true } : { ok: false, status };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { NewsletterAdapter, NewsletterCreds } from '../types';
|
|
2
|
+
import { getDescriptor } from '../descriptors';
|
|
3
|
+
import { fetchJson } from '../http';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://app.loops.so/api/v1';
|
|
6
|
+
const headers = (secret: string) => ({ Authorization: `Bearer ${secret}`, 'content-type': 'application/json' });
|
|
7
|
+
|
|
8
|
+
export const loopsAdapter: NewsletterAdapter = {
|
|
9
|
+
descriptor: getDescriptor('loops')!,
|
|
10
|
+
async validate(creds: NewsletterCreds) {
|
|
11
|
+
const { status } = await fetchJson(`${BASE}/api-key`, { method: 'GET', headers: headers(creds.secret) });
|
|
12
|
+
return status === 200 ? { ok: true } : { ok: false, reason: `api-key ${status}` };
|
|
13
|
+
},
|
|
14
|
+
async addContact(email: string, creds: NewsletterCreds) {
|
|
15
|
+
const { status } = await fetchJson(`${BASE}/contacts/create`, {
|
|
16
|
+
method: 'POST', headers: headers(creds.secret), body: JSON.stringify({ email }),
|
|
17
|
+
});
|
|
18
|
+
// 409 = contact already exists → a successful (idempotent) signup.
|
|
19
|
+
if (status === 409) return { ok: true };
|
|
20
|
+
return status >= 200 && status < 300 ? { ok: true } : { ok: false, status };
|
|
21
|
+
},
|
|
22
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Node-runtime ONLY: uses node:crypto (MD5) + Buffer. Safe here — the subscribe
|
|
2
|
+
// route is `export const runtime = 'nodejs'` and the vendored functions copy runs
|
|
3
|
+
// on Node 24. Do NOT import the registry from an edge route.
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import type { NewsletterAdapter, NewsletterCreds } from '../types';
|
|
6
|
+
import { getDescriptor } from '../descriptors';
|
|
7
|
+
import { fetchJson } from '../http';
|
|
8
|
+
|
|
9
|
+
// SECURITY: the datacenter suffix is interpolated straight into the request host
|
|
10
|
+
// (`https://${dc}.api.mailchimp.com`). The `/^[a-z]+\d+$/` test is the ONLY guard
|
|
11
|
+
// stopping a malicious or typo'd key suffix from redirecting the authenticated
|
|
12
|
+
// call to an attacker-controlled host (SSRF + key exfiltration). Do NOT loosen it.
|
|
13
|
+
function dc(secret: string): string | null {
|
|
14
|
+
const i = secret.lastIndexOf('-');
|
|
15
|
+
const suffix = i >= 0 ? secret.slice(i + 1) : '';
|
|
16
|
+
return /^[a-z]+\d+$/.test(suffix) ? suffix : null;
|
|
17
|
+
}
|
|
18
|
+
function authHeader(secret: string): string {
|
|
19
|
+
return 'Basic ' + Buffer.from(`any:${secret}`).toString('base64');
|
|
20
|
+
}
|
|
21
|
+
function memberHash(email: string): string {
|
|
22
|
+
return createHash('md5').update(email.trim().toLowerCase()).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const mailchimpAdapter: NewsletterAdapter = {
|
|
26
|
+
descriptor: getDescriptor('mailchimp')!,
|
|
27
|
+
async validate(creds: NewsletterCreds) {
|
|
28
|
+
const d = dc(creds.secret);
|
|
29
|
+
if (!d) return { ok: false, reason: 'key has no -datacenter suffix' };
|
|
30
|
+
if (!creds.listId) return { ok: false, reason: 'listId required' };
|
|
31
|
+
const { status } = await fetchJson(
|
|
32
|
+
`https://${d}.api.mailchimp.com/3.0/lists/${creds.listId}`,
|
|
33
|
+
{ method: 'GET', headers: { Authorization: authHeader(creds.secret) } },
|
|
34
|
+
);
|
|
35
|
+
return status === 200 ? { ok: true } : { ok: false, reason: `list check ${status}` };
|
|
36
|
+
},
|
|
37
|
+
async addContact(email: string, creds: NewsletterCreds) {
|
|
38
|
+
const d = dc(creds.secret);
|
|
39
|
+
if (!d || !creds.listId) return { ok: false, code: 'misconfigured' };
|
|
40
|
+
const hash = memberHash(email);
|
|
41
|
+
const { status } = await fetchJson(
|
|
42
|
+
`https://${d}.api.mailchimp.com/3.0/lists/${creds.listId}/members/${hash}`,
|
|
43
|
+
{
|
|
44
|
+
method: 'PUT',
|
|
45
|
+
headers: { Authorization: authHeader(creds.secret), 'content-type': 'application/json' },
|
|
46
|
+
// status_if_new (not status) → never resurrects an unsubscribed member.
|
|
47
|
+
body: JSON.stringify({ email_address: email, status_if_new: 'subscribed' }),
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
return status >= 200 && status < 300 ? { ok: true } : { ok: false, status };
|
|
51
|
+
},
|
|
52
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Resend } from 'resend';
|
|
2
|
+
import type { NewsletterAdapter, NewsletterCreds } from '../types';
|
|
3
|
+
import { getDescriptor } from '../descriptors';
|
|
4
|
+
|
|
5
|
+
// Resend keeps the SDK (already a dep of build-service AND functions). validate
|
|
6
|
+
// calls segments.get: Resend keys are binary (Full vs Sending) and a
|
|
7
|
+
// Sending-access key cannot call segments.* — success proves Full access AND a
|
|
8
|
+
// valid audience. addContact upserts WITHOUT unsubscribed:false (no resurrection).
|
|
9
|
+
export const resendAdapter: NewsletterAdapter = {
|
|
10
|
+
descriptor: getDescriptor('resend')!,
|
|
11
|
+
async validate(creds: NewsletterCreds) {
|
|
12
|
+
if (!creds.audienceId) return { ok: false, reason: 'audienceId required' };
|
|
13
|
+
const { error } = await new Resend(creds.secret).segments.get(creds.audienceId);
|
|
14
|
+
// Surface Resend's error.name (e.g. "restricted_api_key" = a Sending-only
|
|
15
|
+
// key, "not_found" = wrong audience) so a failed connect is debuggable from
|
|
16
|
+
// logs. error.name is a stable, key-safe category string.
|
|
17
|
+
return error ? { ok: false, reason: error.name || 'invalid_key_or_audience' } : { ok: true };
|
|
18
|
+
},
|
|
19
|
+
async addContact(email: string, creds: NewsletterCreds) {
|
|
20
|
+
const { error } = await new Resend(creds.secret).contacts.create({ email, audienceId: creds.audienceId! });
|
|
21
|
+
return error ? { ok: false, code: error.name } : { ok: true };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { NewsletterAdapter, NewsletterCreds } from '../types';
|
|
2
|
+
import { getDescriptor } from '../descriptors';
|
|
3
|
+
import { fetchJson } from '../http';
|
|
4
|
+
|
|
5
|
+
const BASE = 'https://api.sendgrid.com/v3';
|
|
6
|
+
const headers = (secret: string) => ({ Authorization: `Bearer ${secret}`, 'content-type': 'application/json' });
|
|
7
|
+
|
|
8
|
+
export const sendgridAdapter: NewsletterAdapter = {
|
|
9
|
+
descriptor: getDescriptor('sendgrid')!,
|
|
10
|
+
async validate(creds: NewsletterCreds) {
|
|
11
|
+
if (!creds.listId) return { ok: false, reason: 'listId required' };
|
|
12
|
+
const { status } = await fetchJson(`${BASE}/marketing/lists/${creds.listId}`, { method: 'GET', headers: headers(creds.secret) });
|
|
13
|
+
return status === 200 ? { ok: true } : { ok: false, reason: `list ${status}` };
|
|
14
|
+
},
|
|
15
|
+
async addContact(email: string, creds: NewsletterCreds) {
|
|
16
|
+
if (!creds.listId) return { ok: false, code: 'misconfigured' };
|
|
17
|
+
const { status } = await fetchJson(`${BASE}/marketing/contacts`, {
|
|
18
|
+
method: 'PUT', headers: headers(creds.secret),
|
|
19
|
+
body: JSON.stringify({ list_ids: [creds.listId], contacts: [{ email }] }),
|
|
20
|
+
});
|
|
21
|
+
return status >= 200 && status < 300 ? { ok: true } : { ok: false, status };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { ProviderDescriptor } from './types';
|
|
2
|
+
|
|
3
|
+
const SECRET = (label: string, placeholder: string, help?: string) =>
|
|
4
|
+
({ key: 'secret' as const, label, placeholder, type: 'password' as const, help });
|
|
5
|
+
|
|
6
|
+
export const NEWSLETTER_DESCRIPTORS: ProviderDescriptor[] = [
|
|
7
|
+
{
|
|
8
|
+
id: 'resend', label: 'Resend',
|
|
9
|
+
fields: [
|
|
10
|
+
SECRET('Resend API key', 're_live_...', 'Full access key (Sending-access keys cannot manage contacts).'),
|
|
11
|
+
{
|
|
12
|
+
key: 'audienceId', label: 'Audience ID', placeholder: '78261eea-...', type: 'text',
|
|
13
|
+
help: 'In Resend → Audiences, click the ⋯ menu on your audience → Copy ID.',
|
|
14
|
+
helpUrl: 'https://resend.com/audiences', helpLabel: 'Resend audiences',
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
docsUrl: 'https://resend.com/api-keys', docsLabel: 'resend.com/api-keys',
|
|
18
|
+
redact: /re_[A-Za-z0-9_]{6,}/g,
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'mailchimp', label: 'Mailchimp',
|
|
22
|
+
fields: [
|
|
23
|
+
SECRET('Mailchimp API key', '...-us21', 'The key ends in your datacenter, e.g. -us21.'),
|
|
24
|
+
{ key: 'listId', label: 'Audience (List) ID', placeholder: 'a1b2c3d4e5', type: 'text' },
|
|
25
|
+
],
|
|
26
|
+
docsUrl: 'https://mailchimp.com/help/about-api-keys/', docsLabel: 'Mailchimp API keys',
|
|
27
|
+
redact: /[0-9a-f]{32}-us\d{1,2}/g,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'kit', label: 'Kit (ConvertKit)',
|
|
31
|
+
fields: [
|
|
32
|
+
SECRET('Kit API key', 'kit_...', 'A v4 API key from your Kit account settings.'),
|
|
33
|
+
{ key: 'formId', label: 'Form ID', placeholder: '1234567', type: 'text' },
|
|
34
|
+
],
|
|
35
|
+
docsUrl: 'https://app.kit.com/account_settings/developer_settings', docsLabel: 'Kit developer settings',
|
|
36
|
+
redact: /kit_[A-Za-z0-9]{6,}/g,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'loops', label: 'Loops',
|
|
40
|
+
fields: [SECRET('Loops API key', '...', 'An API key from Loops → Settings → API.')],
|
|
41
|
+
docsUrl: 'https://app.loops.so/settings?page=api', docsLabel: 'Loops API settings',
|
|
42
|
+
redact: /[0-9a-f]{32,}/g,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'beehiiv', label: 'beehiiv',
|
|
46
|
+
fields: [
|
|
47
|
+
SECRET('beehiiv API key', '...', 'An API key from Settings → API.'),
|
|
48
|
+
{ key: 'publicationId', label: 'Publication ID', placeholder: 'pub_...', type: 'text' },
|
|
49
|
+
],
|
|
50
|
+
docsUrl: 'https://developers.beehiiv.com/', docsLabel: 'beehiiv API docs',
|
|
51
|
+
redact: /pub_[A-Za-z0-9-]{6,}/g,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'brevo', label: 'Brevo',
|
|
55
|
+
fields: [
|
|
56
|
+
SECRET('Brevo API key', 'xkeysib-...', 'A v3 API key from SMTP & API → API Keys.'),
|
|
57
|
+
{ key: 'listId', label: 'Contact List ID', placeholder: '2', type: 'text' },
|
|
58
|
+
],
|
|
59
|
+
docsUrl: 'https://app.brevo.com/settings/keys/api', docsLabel: 'Brevo API keys',
|
|
60
|
+
redact: /xkeysib-[A-Za-z0-9-]{6,}/g,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'sendgrid', label: 'SendGrid',
|
|
64
|
+
fields: [
|
|
65
|
+
SECRET('SendGrid API key', 'SG....', 'A key with Marketing → Contacts write access.'),
|
|
66
|
+
{ key: 'listId', label: 'Marketing List ID', placeholder: 'uuid', type: 'text' },
|
|
67
|
+
],
|
|
68
|
+
docsUrl: 'https://app.sendgrid.com/settings/api_keys', docsLabel: 'SendGrid API keys',
|
|
69
|
+
redact: /SG\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}/g,
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function getDescriptor(id: string): ProviderDescriptor | undefined {
|
|
74
|
+
const key = id.trim().toLowerCase();
|
|
75
|
+
return NEWSLETTER_DESCRIPTORS.find((d) => d.id === key);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Native-capture provider ids, derived from the descriptors. Lives HERE (pure
|
|
79
|
+
* data) rather than in registry.ts so `email-subscribe-providers.ts` — which is
|
|
80
|
+
* vendored into the dashboard editor — can gate on it WITHOUT importing the
|
|
81
|
+
* adapters (which pull in `resend`/`fetch`/`node:crypto` and have no place in the
|
|
82
|
+
* editor bundle). registry.ts re-exports this; the registry test cross-checks it
|
|
83
|
+
* equals the adapter keys so a descriptor/adapter mismatch fails loudly. */
|
|
84
|
+
export const NATIVE_PROVIDER_IDS = new Set(NEWSLETTER_DESCRIPTORS.map((d) => d.id));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Minimal JSON fetch with a hard timeout. Returns the status + parsed body
|
|
2
|
+
* (null if the body isn't JSON). Never throws on non-2xx — callers branch on
|
|
3
|
+
* `status`. Throws only on network failure / abort, which callers catch. */
|
|
4
|
+
export async function fetchJson(
|
|
5
|
+
url: string,
|
|
6
|
+
init: RequestInit,
|
|
7
|
+
timeoutMs = 8000,
|
|
8
|
+
): Promise<{ status: number; json: unknown }> {
|
|
9
|
+
const ctrl = new AbortController();
|
|
10
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
11
|
+
try {
|
|
12
|
+
const res = await fetch(url, { ...init, signal: ctrl.signal });
|
|
13
|
+
let json: unknown = null;
|
|
14
|
+
try { json = await res.json(); } catch { /* empty / non-JSON body */ }
|
|
15
|
+
return { status: res.status, json };
|
|
16
|
+
} finally {
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { NewsletterAdapter } from './types';
|
|
2
|
+
import { resendAdapter } from './adapters/resend';
|
|
3
|
+
import { mailchimpAdapter } from './adapters/mailchimp';
|
|
4
|
+
import { kitAdapter } from './adapters/kit';
|
|
5
|
+
import { loopsAdapter } from './adapters/loops';
|
|
6
|
+
import { beehiivAdapter } from './adapters/beehiiv';
|
|
7
|
+
import { brevoAdapter } from './adapters/brevo';
|
|
8
|
+
import { sendgridAdapter } from './adapters/sendgrid';
|
|
9
|
+
|
|
10
|
+
export const NEWSLETTER_ADAPTERS: Record<string, NewsletterAdapter> = {
|
|
11
|
+
resend: resendAdapter,
|
|
12
|
+
mailchimp: mailchimpAdapter,
|
|
13
|
+
kit: kitAdapter,
|
|
14
|
+
loops: loopsAdapter,
|
|
15
|
+
beehiiv: beehiivAdapter,
|
|
16
|
+
brevo: brevoAdapter,
|
|
17
|
+
sendgrid: sendgridAdapter,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Single source of truth is descriptors.ts (pure data, vendored to the editor).
|
|
21
|
+
// Re-export for endpoint/registry callers; the test below cross-checks it equals
|
|
22
|
+
// the adapter keys, catching a descriptor-without-adapter (or vice-versa) drift.
|
|
23
|
+
export { NATIVE_PROVIDER_IDS } from './descriptors';
|
|
24
|
+
|
|
25
|
+
export function getAdapter(provider: string): NewsletterAdapter | undefined {
|
|
26
|
+
return NEWSLETTER_ADAPTERS[provider.trim().toLowerCase()];
|
|
27
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Generic, multi-provider credentials. `secret` is always the API key; the
|
|
2
|
+
* provider-specific id fields below are present only for the providers that
|
|
3
|
+
* need them. Stored (minus nothing) in Redis; the secret never leaves the
|
|
4
|
+
* backend. */
|
|
5
|
+
export interface NewsletterCreds {
|
|
6
|
+
provider: string;
|
|
7
|
+
secret: string;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
audienceId?: string; // resend
|
|
10
|
+
listId?: string; // mailchimp, brevo, sendgrid
|
|
11
|
+
formId?: string; // kit
|
|
12
|
+
publicationId?: string; // beehiiv
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** One input the dashboard card renders for a provider. `key` is the
|
|
16
|
+
* NewsletterCreds field it populates. */
|
|
17
|
+
export interface CredField {
|
|
18
|
+
key: 'secret' | 'audienceId' | 'listId' | 'formId' | 'publicationId';
|
|
19
|
+
label: string;
|
|
20
|
+
placeholder: string;
|
|
21
|
+
type: 'password' | 'text';
|
|
22
|
+
/** Short hint shown under the field. */
|
|
23
|
+
help?: string;
|
|
24
|
+
/** Optional "where to find this" link shown under the field (e.g. where a
|
|
25
|
+
* provider exposes the id the field needs). */
|
|
26
|
+
helpUrl?: string;
|
|
27
|
+
helpLabel?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Pure-data description of a provider for the dashboard UI + input validation. */
|
|
31
|
+
export interface ProviderDescriptor {
|
|
32
|
+
id: string;
|
|
33
|
+
label: string;
|
|
34
|
+
/** Fields in display order; always includes the `secret` field first. */
|
|
35
|
+
fields: CredField[];
|
|
36
|
+
/** Where the owner creates the key. */
|
|
37
|
+
docsUrl: string;
|
|
38
|
+
docsLabel: string;
|
|
39
|
+
/** Matches this provider's key format, for redacting it out of error text. */
|
|
40
|
+
redact: RegExp;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ValidateResult { ok: boolean; reason?: string; }
|
|
44
|
+
export interface AddContactResult { ok: boolean; status?: number; code?: string; }
|
|
45
|
+
|
|
46
|
+
export interface NewsletterAdapter {
|
|
47
|
+
descriptor: ProviderDescriptor;
|
|
48
|
+
/** Cheap live call proving the key (and id field, if any) work. */
|
|
49
|
+
validate(creds: NewsletterCreds): Promise<ValidateResult>;
|
|
50
|
+
/** Add/upsert the subscriber. Never throws on provider errors — returns ok:false. */
|
|
51
|
+
addContact(email: string, creds: NewsletterCreds): Promise<AddContactResult>;
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// builder/build-service/lib/newsletter-display.ts
|
|
2
|
+
//
|
|
3
|
+
// Prod-only (ISR) reader for the dashboard-set display copy on the newsletter
|
|
4
|
+
// signup. NOT vendored — the CLI/editor have no prod Redis, and a dashboard
|
|
5
|
+
// subtitle is a published-site concept. The render path stays Redis-free; this
|
|
6
|
+
// is injected into renderDocPage as a lazy loader and only ever called when a
|
|
7
|
+
// changelog page actually auto-places the signup.
|
|
8
|
+
import { redis } from './redis';
|
|
9
|
+
|
|
10
|
+
export interface NewsletterDisplay {
|
|
11
|
+
title?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read the dashboard-set title/subtitle from the `newsletter:<slug>` record the
|
|
17
|
+
* Email Signups card writes (same record the capture endpoint reads for creds).
|
|
18
|
+
* NEVER returns the secret. Fails OPEN (returns null) on any error so a Redis
|
|
19
|
+
* blip can never break a changelog render — the form just falls back to the
|
|
20
|
+
* docs.json copy.
|
|
21
|
+
*/
|
|
22
|
+
export async function readNewsletterDisplay(slug: string): Promise<NewsletterDisplay | null> {
|
|
23
|
+
if (!redis) return null;
|
|
24
|
+
let raw: unknown;
|
|
25
|
+
try {
|
|
26
|
+
raw = await redis.get(`newsletter:${slug}`);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (!raw) return null;
|
|
31
|
+
let obj: unknown = raw;
|
|
32
|
+
// Upstash may hand back a JSON string or an already-parsed object (mirrors the
|
|
33
|
+
// capture route's parseCreds tolerance).
|
|
34
|
+
if (typeof raw === 'string') {
|
|
35
|
+
try { obj = JSON.parse(raw); } catch { return null; }
|
|
36
|
+
}
|
|
37
|
+
if (!obj || typeof obj !== 'object') return null;
|
|
38
|
+
const c = obj as Record<string, unknown>;
|
|
39
|
+
const display: NewsletterDisplay = {};
|
|
40
|
+
if (typeof c.title === 'string' && c.title.trim()) display.title = c.title;
|
|
41
|
+
if (typeof c.description === 'string' && c.description.trim()) display.description = c.description;
|
|
42
|
+
return display.title || display.description ? display : null;
|
|
43
|
+
}
|
|
@@ -89,6 +89,8 @@ import { AIActionsMenu } from '@/components/AIActionsMenu';
|
|
|
89
89
|
import { getContextualOptions } from '@/lib/contextual-defaults';
|
|
90
90
|
import { withApiSpecDownload } from '@/lib/api-spec-menu-gate';
|
|
91
91
|
import { SnippetComponents } from '@/components/snippets/ProjectSnippets';
|
|
92
|
+
import { EmailSubscribe } from '@/components/mdx/EmailSubscribe';
|
|
93
|
+
import { resolveAutoNewsletter, mdxHasEmailSubscribe } from '@/lib/email-subscribe-autoplacement';
|
|
92
94
|
|
|
93
95
|
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD' | 'OPTIONS' | 'TRACE';
|
|
94
96
|
|
|
@@ -130,6 +132,11 @@ export interface RenderInput {
|
|
|
130
132
|
projectSlug: string | null;
|
|
131
133
|
hostAtDocs: boolean;
|
|
132
134
|
requestHeaders: Headers | null;
|
|
135
|
+
/** Lazy loader for the dashboard-set signup title/subtitle (prod ISR only —
|
|
136
|
+
* reads Redis, so it's injected by the route, NOT imported here, to keep this
|
|
137
|
+
* vendored module Redis-free). Called at most once, and only when a changelog
|
|
138
|
+
* page actually auto-places the signup. */
|
|
139
|
+
loadNewsletterDisplay?: () => Promise<{ title?: string; description?: string } | null>;
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
interface FrontmatterData {
|
|
@@ -217,7 +224,11 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
|
217
224
|
const configP = loader.getConfig();
|
|
218
225
|
|
|
219
226
|
const normalizedSlug = normalizeSlugForContent(slugInput || [], hostAtDocs);
|
|
220
|
-
|
|
227
|
+
// Bare `/` or `/<lang>` roots render the first nav page but keep the alias URL;
|
|
228
|
+
// suppress hreflang on them (passed to buildSeoMetadata — see its isRootAlias
|
|
229
|
+
// doc for why).
|
|
230
|
+
const isRootAlias = needsSlugRewrite(normalizedSlug);
|
|
231
|
+
const slug = isRootAlias
|
|
221
232
|
? resolveSlug(normalizedSlug, await configP)
|
|
222
233
|
: normalizedSlug;
|
|
223
234
|
const pagePath = slug.join('/');
|
|
@@ -241,7 +252,7 @@ export async function buildDocMetadata(input: RenderInput): Promise<Metadata> {
|
|
|
241
252
|
const baseUrl = resolveBaseUrl(requestHeaders, projectSlug, hostAtDocs);
|
|
242
253
|
const languages = config.navigation?.languages;
|
|
243
254
|
|
|
244
|
-
const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages);
|
|
255
|
+
const seoMetadata = buildSeoMetadata(config, data, pagePath, baseUrl, languages, isRootAlias);
|
|
245
256
|
|
|
246
257
|
const titleValue = data.title
|
|
247
258
|
? (data.title === config.name ? { absolute: data.title } : data.title)
|
|
@@ -601,6 +612,38 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
601
612
|
</a>
|
|
602
613
|
) : null;
|
|
603
614
|
|
|
615
|
+
// Auto-mount the configured signup at the top of changelog (rss:true) pages
|
|
616
|
+
// when integrations.newsletter.placement === 'changelog'. Per-page frontmatter
|
|
617
|
+
// `newsletter: false` opts a single page out — verified safe: FrontmatterData
|
|
618
|
+
// declares `[key: string]: unknown`, so the custom `newsletter` key survives
|
|
619
|
+
// parsing into `data`. Manual <EmailSubscribe> in MDX is unaffected. NOTE: this
|
|
620
|
+
// mounts on EVERY rss:true page (multi-page rss is documented) — the per-page
|
|
621
|
+
// opt-out is the control for that.
|
|
622
|
+
let autoNewsletterProps = resolveAutoNewsletter(
|
|
623
|
+
data.rss,
|
|
624
|
+
config,
|
|
625
|
+
(data as { newsletter?: boolean }).newsletter,
|
|
626
|
+
);
|
|
627
|
+
// Dashboard-set title/subtitle fills any gap docs.json left — docs.json wins
|
|
628
|
+
// when both set a field. Gated to the only case it matters (auto-placement
|
|
629
|
+
// produced props), so the Redis read never touches non-changelog renders.
|
|
630
|
+
if (autoNewsletterProps && input.loadNewsletterDisplay) {
|
|
631
|
+
const dash = await input.loadNewsletterDisplay();
|
|
632
|
+
if (dash) {
|
|
633
|
+
autoNewsletterProps = {
|
|
634
|
+
...autoNewsletterProps,
|
|
635
|
+
title: autoNewsletterProps.title ?? dash.title,
|
|
636
|
+
description: autoNewsletterProps.description ?? dash.description,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
// An explicit <EmailSubscribe> in the page's MDX wins over auto-placement —
|
|
641
|
+
// otherwise a changelog author who hand-places the form would get two.
|
|
642
|
+
const autoEmailSubscribe =
|
|
643
|
+
autoNewsletterProps && !mdxHasEmailSubscribe(rawContent)
|
|
644
|
+
? <EmailSubscribe {...autoNewsletterProps} />
|
|
645
|
+
: null;
|
|
646
|
+
|
|
604
647
|
const contextualOptions = getContextualOptions(config);
|
|
605
648
|
const hasAiActions = contextualOptions.length > 0;
|
|
606
649
|
const apiMenuOptions = withApiSpecDownload(contextualOptions, {
|
|
@@ -774,6 +817,7 @@ export async function renderDocPage(input: RenderInput): Promise<ReactElement> {
|
|
|
774
817
|
)}
|
|
775
818
|
|
|
776
819
|
<div className={proseClasses}>
|
|
820
|
+
{autoEmailSubscribe}
|
|
777
821
|
{hasView ? <ViewWrapper>{mdxContent}</ViewWrapper> : mdxContent}
|
|
778
822
|
|
|
779
823
|
{!embed && <PageNavigation currentSlug={slug.join('/')} config={config} isWideMode={isWideMode || hasPanel} />}
|
package/vendored/lib/seo.ts
CHANGED
|
@@ -606,6 +606,13 @@ function derivePageLocale(pagePath: string, languages?: LanguageConfig[]): strin
|
|
|
606
606
|
* @param pagePath - The page path (e.g., "getting-started/installation")
|
|
607
607
|
* @param baseUrl - The base URL (e.g., "https://docs.example.com")
|
|
608
608
|
* @param languages - Optional array of language configurations for hreflang tags
|
|
609
|
+
* @param isRootAlias - True when the request is a bare `/` or `/<lang>` root that
|
|
610
|
+
* renders the first nav page's content but keeps the alias URL. Such pages emit
|
|
611
|
+
* `canonical` → the first page's URL (to consolidate the duplicate), so they must
|
|
612
|
+
* NOT carry hreflang: hreflang on a canonicalized-away page is the Semrush
|
|
613
|
+
* "Conflicting hreflang and rel=canonical" defect, and it can never self-reference
|
|
614
|
+
* the alias URL ("No self-referencing hreflang"). The canonical target page emits
|
|
615
|
+
* the full hreflang cluster, so language discovery is preserved.
|
|
609
616
|
* @returns Partial<Metadata> to spread into generateMetadata return
|
|
610
617
|
*/
|
|
611
618
|
export function buildSeoMetadata(
|
|
@@ -614,6 +621,7 @@ export function buildSeoMetadata(
|
|
|
614
621
|
pagePath: string,
|
|
615
622
|
baseUrl: string,
|
|
616
623
|
languages?: LanguageConfig[],
|
|
624
|
+
isRootAlias = false,
|
|
617
625
|
): Partial<Metadata> {
|
|
618
626
|
const globalMeta = config.seo?.metatags || {};
|
|
619
627
|
const pageMeta = normalizePageMetatags(frontmatter as Record<string, unknown>);
|
|
@@ -684,8 +692,11 @@ export function buildSeoMetadata(
|
|
|
684
692
|
canonical = pageUrl;
|
|
685
693
|
}
|
|
686
694
|
|
|
687
|
-
// 7b. Hreflang alternates for multi-language support
|
|
688
|
-
|
|
695
|
+
// 7b. Hreflang alternates for multi-language support. Suppressed on root-alias
|
|
696
|
+
// pages — see the isRootAlias param doc above for why.
|
|
697
|
+
const hreflangLanguages = isRootAlias
|
|
698
|
+
? undefined
|
|
699
|
+
: buildHreflangAlternates(baseUrl, pagePath, languages || []);
|
|
689
700
|
|
|
690
701
|
metadata.alternates = {
|
|
691
702
|
canonical,
|