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.
Files changed (46) hide show
  1. package/dist/commands/dev.d.ts.map +1 -1
  2. package/dist/commands/dev.js +20 -0
  3. package/dist/commands/dev.js.map +1 -1
  4. package/package.json +1 -1
  5. package/vendored/app/[[...slug]]/page.tsx +8 -1
  6. package/vendored/app/api/subscribe/[project]/route.ts +154 -0
  7. package/vendored/components/mdx/EmailSubscribe.tsx +167 -0
  8. package/vendored/components/mdx/MDXComponents.tsx +3 -0
  9. package/vendored/components/mdx/NativeSubscribeForm.tsx +172 -0
  10. package/vendored/components/navigation/Header.tsx +4 -3
  11. package/vendored/components/search/SearchModal.tsx +10 -4
  12. package/vendored/lib/docs-types.ts +45 -2
  13. package/vendored/lib/email-subscribe-autoplacement.ts +46 -0
  14. package/vendored/lib/email-subscribe-providers.ts +120 -0
  15. package/vendored/lib/middleware-helpers.ts +21 -0
  16. package/vendored/lib/navigation-resolver.ts +5 -5
  17. package/vendored/lib/newsletter/adapters/beehiiv.ts +23 -0
  18. package/vendored/lib/newsletter/adapters/brevo.ts +31 -0
  19. package/vendored/lib/newsletter/adapters/kit.ts +35 -0
  20. package/vendored/lib/newsletter/adapters/loops.ts +22 -0
  21. package/vendored/lib/newsletter/adapters/mailchimp.ts +52 -0
  22. package/vendored/lib/newsletter/adapters/resend.ts +23 -0
  23. package/vendored/lib/newsletter/adapters/sendgrid.ts +23 -0
  24. package/vendored/lib/newsletter/descriptors.ts +84 -0
  25. package/vendored/lib/newsletter/http.ts +19 -0
  26. package/vendored/lib/newsletter/registry.ts +27 -0
  27. package/vendored/lib/newsletter/types.ts +52 -0
  28. package/vendored/lib/newsletter-display.ts +43 -0
  29. package/vendored/lib/render-doc-page.tsx +46 -2
  30. package/vendored/lib/seo.ts +13 -2
  31. package/vendored/lib/validate-content-images.ts +150 -0
  32. package/vendored/schema/docs-schema.json +29 -1
  33. package/vendored/shared/status-reporter.ts +1 -1
  34. package/vendored/workspace-package-lock.json +3 -87
  35. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
  36. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
  37. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
  38. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
  39. package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
  40. package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
  41. package/dist/__tests__/unit/language-filter.test.js +0 -166
  42. package/dist/__tests__/unit/language-filter.test.js.map +0 -1
  43. package/dist/lib/language-filter.d.ts +0 -31
  44. package/dist/lib/language-filter.d.ts.map +0 -1
  45. package/dist/lib/language-filter.js +0 -14
  46. 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
- const slug = needsSlugRewrite(normalizedSlug)
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} />}
@@ -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
- const hreflangLanguages = buildHreflangAlternates(baseUrl, pagePath, languages || []);
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,