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,172 @@
1
+ // builder/build-service/components/mdx/NativeSubscribeForm.tsx
2
+ 'use client';
3
+
4
+ import { useEffect, useState, type CSSProperties, type FormEvent } from 'react';
5
+
6
+ export interface NativeSubscribeFormProps {
7
+ provider: string; // 'resend'
8
+ title?: string;
9
+ description?: string;
10
+ /** Start collapsed: a compact button that expands to the form on click. */
11
+ collapsed?: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ // Solid primary button (the collapsed trigger and the form's submit share it).
16
+ // Kept compact — the iOS 16px-min rule is for inputs, not buttons.
17
+ const PRIMARY_BUTTON: CSSProperties = {
18
+ fontSize: '0.875rem', padding: '0.4rem 0.85rem', border: 0,
19
+ borderRadius: '0.375rem', cursor: 'pointer',
20
+ background: 'var(--color-primary, #2563eb)', color: '#fff',
21
+ };
22
+
23
+ // Inline text button for the "use a different email" escape hatch.
24
+ const LINK_BUTTON: CSSProperties = {
25
+ background: 'none', border: 0, padding: 0, color: 'var(--color-primary, #2563eb)',
26
+ cursor: 'pointer', textDecoration: 'underline', font: 'inherit',
27
+ };
28
+
29
+ /** Site-wide "this visitor already subscribed" flag. Per-origin (localStorage is
30
+ * already scoped to the published site), so one signup is remembered across
31
+ * every changelog page rather than re-nagging the same reader. */
32
+ const SUBSCRIBED_KEY = 'jd_subscribed';
33
+
34
+ function readSubscribed(): boolean {
35
+ try {
36
+ return typeof window !== 'undefined' &&
37
+ window.localStorage.getItem(SUBSCRIBED_KEY) === '1';
38
+ } catch {
39
+ // Private-mode / storage-disabled: fail open (show the form).
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function rememberSubscribed(): void {
45
+ try {
46
+ window.localStorage.setItem(SUBSCRIBED_KEY, '1');
47
+ } catch {
48
+ // Best-effort — a storage failure just means we re-show next visit.
49
+ }
50
+ }
51
+
52
+ /** Same-origin endpoint. Hosted-at-/docs sites serve the signup under /docs,
53
+ * mirroring useChat's `/docs/_chat` swap (builder/cli/vendored/hooks/useChat.ts). */
54
+ function subscribeEndpoint(): string {
55
+ if (typeof window !== 'undefined' && window.location.pathname.startsWith('/docs')) {
56
+ return '/docs/_jd/subscribe';
57
+ }
58
+ return '/_jd/subscribe';
59
+ }
60
+
61
+ /**
62
+ * On-brand, first-party signup form for native-capture providers (Resend). Posts
63
+ * the email to Jamdesk's same-origin /_jd/subscribe; the customer's API key stays
64
+ * server-side. No third-party script — safe to render anywhere the live component
65
+ * renders (published ISR + CLI dev; the editor uses the placeholder).
66
+ *
67
+ * Two display refinements, native-only (we control this DOM, unlike vendor
68
+ * embeds):
69
+ * - `collapsed`: render a compact button that expands to the form on click.
70
+ * - Already-subscribed memory: once a visitor subscribes, every later mount
71
+ * shows a one-line "you're subscribed" with a "use a different email" escape
72
+ * hatch instead of the full form. The flag is read AFTER mount (useEffect)
73
+ * so SSR/first-paint stays deterministic and hydration never mismatches.
74
+ */
75
+ export function NativeSubscribeForm({ provider, title, description, collapsed, className }: NativeSubscribeFormProps) {
76
+ const [email, setEmail] = useState('');
77
+ const [status, setStatus] = useState<'idle' | 'submitting' | 'done' | 'error'>('idle');
78
+ // Set after mount only — keeps the server/first-client render deterministic.
79
+ const [remembered, setRemembered] = useState(false);
80
+ // The visitor explicitly opened the form (collapsed trigger or "different email").
81
+ const [forceOpen, setForceOpen] = useState(false);
82
+
83
+ useEffect(() => {
84
+ if (readSubscribed()) setRemembered(true);
85
+ }, []);
86
+
87
+ async function onSubmit(e: FormEvent<HTMLFormElement>) {
88
+ e.preventDefault();
89
+ if (status === 'submitting') return;
90
+ const jd_hp = (e.currentTarget.elements.namedItem('jd_hp') as HTMLInputElement | null)?.value ?? '';
91
+ setStatus('submitting');
92
+ try {
93
+ const res = await fetch(subscribeEndpoint(), {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json' },
96
+ body: JSON.stringify({ email, jd_hp, provider }),
97
+ });
98
+ if (res.ok) {
99
+ rememberSubscribed();
100
+ setRemembered(true);
101
+ setStatus('done');
102
+ } else {
103
+ setStatus('error');
104
+ }
105
+ } catch {
106
+ setStatus('error');
107
+ }
108
+ }
109
+
110
+ const wrapperClass = `jd-emailsubscribe not-prose my-6 ${className ?? ''}`.trim();
111
+
112
+ if (status === 'done') {
113
+ return (
114
+ <div className={wrapperClass} role="status">
115
+ {title && <p className="mb-1 font-semibold text-theme-text-primary">{title}</p>}
116
+ <p className="text-sm text-theme-text-secondary">Thanks — you&rsquo;re subscribed.</p>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ // Already-subscribed wins over the collapsed trigger: a returning subscriber
122
+ // sees the status line, not a generic "Subscribe" button.
123
+ if (remembered && !forceOpen) {
124
+ return (
125
+ <div className={wrapperClass}>
126
+ <p className="text-sm text-theme-text-secondary">
127
+ You&rsquo;re subscribed to the newsletter.{' '}
128
+ <button type="button" onClick={() => { setEmail(''); setStatus('idle'); setForceOpen(true); }}
129
+ style={LINK_BUTTON}>
130
+ Use a different email?
131
+ </button>
132
+ </p>
133
+ </div>
134
+ );
135
+ }
136
+
137
+ // Collapsed: a compact button standing in for the whole form until clicked.
138
+ if (collapsed && !forceOpen) {
139
+ return (
140
+ <div className={wrapperClass}>
141
+ <button type="button" onClick={() => setForceOpen(true)} style={PRIMARY_BUTTON}>
142
+ {title || 'Subscribe to updates'}
143
+ </button>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ return (
149
+ <div className={wrapperClass}>
150
+ {title && <p className="mb-1 font-semibold text-theme-text-primary">{title}</p>}
151
+ {description && <p className="mb-3 text-sm text-theme-text-secondary">{description}</p>}
152
+ <form onSubmit={onSubmit} style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center', maxWidth: '28rem' }}>
153
+ {/* Honeypot: a deliberately odd name (NOT website/url/email, which password
154
+ managers autofill — a filled honeypot silently drops a real user). Bots
155
+ fill it; humans can't see it. Off-screen + aria-hidden + tabIndex -1. */}
156
+ <input type="text" name="jd_hp" tabIndex={-1} autoComplete="off" aria-hidden="true"
157
+ defaultValue="" style={{ position: 'absolute', left: '-5000px' }} />
158
+ {/* font-size:16px avoids iOS auto-zoom (monorepo UI rule). */}
159
+ <input type="email" name="email" required value={email}
160
+ onChange={(e) => { setEmail(e.target.value); if (status === 'error') setStatus('idle'); }}
161
+ disabled={status === 'submitting'} placeholder="you@example.com" aria-label="Email address"
162
+ style={{ flex: 1, minWidth: 0, fontSize: '16px', padding: '0.4rem 0.75rem', border: '1px solid var(--color-border, #d4d4d8)', borderRadius: '0.375rem', background: 'var(--color-bg-primary, #fff)', color: 'var(--color-text-primary, #18181b)' }} />
163
+ <button type="submit" disabled={status === 'submitting'} style={PRIMARY_BUTTON}>
164
+ {status === 'submitting' ? 'Subscribing…' : 'Subscribe'}
165
+ </button>
166
+ </form>
167
+ {status === 'error' && (
168
+ <p className="mt-2 text-sm text-amber-700 dark:text-amber-400" role="alert">Something went wrong. Please try again.</p>
169
+ )}
170
+ </div>
171
+ );
172
+ }
@@ -6,7 +6,7 @@ import Link from 'next/link';
6
6
  import { usePathname } from 'next/navigation';
7
7
  // Icons use Font Awesome CSS classes for lightweight rendering
8
8
  import type { DocsConfig, TabsPosition } from '@/lib/docs-types';
9
- import { normalizeLogo, getIconName } from '@/lib/docs-types';
9
+ import { normalizeLogo, getIconString } from '@/lib/docs-types';
10
10
  import type { LayoutVariant } from '@/themes';
11
11
  import { getTheme } from '@/themes';
12
12
  import { ThemeToggle } from '@/components/theme/ThemeToggle';
@@ -136,7 +136,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
136
136
  if (!hasTabs) return [];
137
137
 
138
138
  return navigationTabs.map((tab) => {
139
- const iconName = getIconName(tab.icon);
139
+ const iconName = getIconString(tab.icon);
140
140
 
141
141
  // For external tabs, use the href directly
142
142
  if (tab.href && !tab.groups && !tab.pages) {
@@ -489,7 +489,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
489
489
  rel={isExternal ? 'noopener noreferrer' : undefined}
490
490
  className="flex items-center gap-1.5 px-2.5 py-1.5 text-sm text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
491
491
  >
492
- {link.icon && <i className={`${getIconClass(getIconName(link.icon))} text-[14px]`} aria-hidden="true" />}
492
+ {link.icon && <i className={`${getIconClass(getIconString(link.icon))} text-[14px]`} aria-hidden="true" />}
493
493
  <span>{resolveLabel(link.label, link.labels)}</span>
494
494
  {isExternal && <i className="fa-solid fa-arrow-up-right-from-square text-[10px] opacity-50" aria-hidden="true" />}
495
495
  </a>
@@ -564,6 +564,7 @@ export function Header({ config, layout = 'header-logo', tabsPosition: tabsPosit
564
564
  isOpen={isSearchOpen}
565
565
  onClose={() => setIsSearchOpen(false)}
566
566
  popularPages={config.search?.popularPages}
567
+ placeholder={config.search?.prompt}
567
568
  />
568
569
  )}
569
570
  {searchDevNotice}
@@ -12,11 +12,13 @@ import { useProjectSlug } from '@/lib/project-slug-context';
12
12
  import type { SearchResult, SearchGroup } from '@/lib/search-client';
13
13
  import { useChatPanelOptional } from '@/hooks/useChatPanel';
14
14
  import { modifierKeyLabel } from '@/lib/platform-keys';
15
+ import { getIconString, type IconConfig } from '@/lib/docs-types';
16
+ import { getIconClass } from '@/lib/icon-utils';
15
17
 
16
18
  interface PopularPage {
17
19
  title: string;
18
20
  slug: string;
19
- icon?: string;
21
+ icon?: IconConfig;
20
22
  }
21
23
 
22
24
  interface SearchModalProps {
@@ -24,10 +26,14 @@ interface SearchModalProps {
24
26
  onClose: () => void;
25
27
  popularPages?: PopularPage[];
26
28
  onNavigate?: (url: string) => void;
29
+ /** Custom placeholder for the search input (docs.json `search.prompt`) */
30
+ placeholder?: string;
27
31
  }
28
32
 
29
33
  const DEBOUNCE_MS = 150;
30
34
 
35
+ const DEFAULT_SEARCH_PLACEHOLDER = 'Search documentation…';
36
+
31
37
  // Default popular pages fallback
32
38
  const DEFAULT_POPULAR_PAGES: PopularPage[] = [
33
39
  { title: 'Quick Start', slug: 'quickstart', icon: 'rocket' },
@@ -135,7 +141,7 @@ function countVisibleResults(rows: SearchRow[]): number {
135
141
  return rows.reduce((n, r) => (r.kind === 'expander' ? n : n + 1), 0);
136
142
  }
137
143
 
138
- export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
144
+ export function SearchModal({ isOpen, onClose, popularPages, onNavigate, placeholder }: SearchModalProps) {
139
145
  const linkPrefix = useLinkPrefix();
140
146
  const projectSlug = useProjectSlug();
141
147
  const chatPanel = useChatPanelOptional();
@@ -522,7 +528,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
522
528
  <input
523
529
  ref={inputRef}
524
530
  type="search"
525
- placeholder="Search documentation…"
531
+ placeholder={placeholder || DEFAULT_SEARCH_PLACEHOLDER}
526
532
  value={query}
527
533
  onChange={(e) => setQuery(e.target.value)}
528
534
  className="flex-1 bg-transparent text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none text-base [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
@@ -703,7 +709,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
703
709
  : 'hover:bg-[var(--color-bg-secondary)]'
704
710
  }`}
705
711
  >
706
- <i className={`fa-light fa-${page.icon || 'file-lines'} text-sm text-[var(--color-text-muted)]`} aria-hidden="true" />
712
+ <i className={`${getIconClass(getIconString(page.icon))} text-sm text-[var(--color-text-muted)]`} aria-hidden="true" />
707
713
  <span className="text-sm text-[var(--color-text-primary)] truncate">{page.title}</span>
708
714
  </button>
709
715
  ))}
@@ -614,6 +614,35 @@ export interface StylingConfig {
614
614
  // INTEGRATIONS
615
615
  // =============================================================================
616
616
 
617
+ /**
618
+ * Customer-facing newsletter / changelog-notification signup (rendered by
619
+ * <EmailSubscribe>). Fields mirror EmailSubscribeProps (see
620
+ * lib/email-subscribe-providers.ts); `placement` opts into auto-rendering on
621
+ * changelog (`rss: true`) pages.
622
+ *
623
+ * NOTE: distinct from the dashboard's internal `addUserToResendNewsletter`
624
+ * (Jamdesk's own signup list) — this is per docs-project config.
625
+ */
626
+ export interface NewsletterConfig {
627
+ /** Shorthand provider: mailchimp | buttondown | substack | beehiiv (v1). */
628
+ provider?: string;
629
+ /** Mailchimp: full form POST action URL. */
630
+ action?: string;
631
+ /** Buttondown / Substack: account username. */
632
+ username?: string;
633
+ /** Beehiiv: full iframe embed src URL. */
634
+ src?: string;
635
+ /** Raw embed snippet (any vendor) — escape hatch. */
636
+ snippet?: string;
637
+ /** iframe height in px for iframe providers (substack/beehiiv). */
638
+ height?: number;
639
+ /** Optional heading shown above the embed. */
640
+ title?: string;
641
+ description?: string;
642
+ /** 'changelog' auto-mounts on `rss: true` pages; 'none' (default) = manual. */
643
+ placement?: 'none' | 'changelog';
644
+ }
645
+
617
646
  /**
618
647
  * Analytics and integration configurations (stubs - TODO: implement)
619
648
  */
@@ -629,6 +658,7 @@ export interface IntegrationsConfig {
629
658
  hightouch?: { writeKey: string; apiHost?: string };
630
659
  hotjar?: { hjid: string; hjsv: string };
631
660
  crisp?: { websiteId: string };
661
+ newsletter?: NewsletterConfig;
632
662
  intercom?: { appId: string };
633
663
  koala?: { publicApiKey: string };
634
664
  logrocket?: { appId: string };
@@ -655,7 +685,7 @@ export interface SearchConfig {
655
685
  popularPages?: Array<{
656
686
  title: string;
657
687
  slug: string;
658
- icon?: string;
688
+ icon?: IconConfig;
659
689
  }>;
660
690
  }
661
691
 
@@ -930,7 +960,7 @@ export function normalizeNavPage(page: NavigationPage): {
930
960
  .pop()
931
961
  ?.replace(/-/g, ' ')
932
962
  .replace(/\b\w/g, (l) => l.toUpperCase()) || page.page,
933
- icon: getIconName(page.icon),
963
+ icon: getIconString(page.icon),
934
964
  tag: page.tag,
935
965
  };
936
966
  }
@@ -994,6 +1024,19 @@ export function getIconName(icon: IconConfig | undefined): string | undefined {
994
1024
  return icon.name;
995
1025
  }
996
1026
 
1027
+ /**
1028
+ * Flatten an icon config to the string getIconClass() understands, preserving
1029
+ * the Font Awesome style as a "style/name" prefix (every IconStyle value is a
1030
+ * getIconClass style prefix). Use this — not getIconName — wherever an icon is
1031
+ * rendered, so a configured `style` survives the flatten. `library` is
1032
+ * intentionally dropped: all icons render via Font Awesome.
1033
+ */
1034
+ export function getIconString(icon: IconConfig | undefined): string | undefined {
1035
+ if (!icon) return undefined;
1036
+ if (typeof icon === 'string') return icon;
1037
+ return icon.style ? `${icon.style}/${icon.name}` : icon.name;
1038
+ }
1039
+
997
1040
  /**
998
1041
  * Get icon library from config
999
1042
  */
@@ -0,0 +1,46 @@
1
+ // builder/build-service/lib/email-subscribe-autoplacement.ts
2
+ import type { EmailSubscribeProps } from './email-subscribe-providers';
3
+
4
+ /** Structural shape of the slice of config this resolver reads. Declared locally
5
+ * so Task 2 type-checks on its own, WITHOUT waiting for Task 5 to add
6
+ * `IntegrationsConfig.newsletter` — the real `DocsConfig` is structurally
7
+ * assignable to this, so render-doc-page (Task 6) can pass its full config. */
8
+ type NewsletterLike = EmailSubscribeProps & { placement?: 'none' | 'changelog' };
9
+
10
+ /**
11
+ * Decide whether a changelog (`rss: true`) page should auto-render the
12
+ * configured signup. Returns the EmailSubscribe props (placement stripped) when
13
+ * `integrations.newsletter.placement === 'changelog'`, else null. Pure — so the
14
+ * gating is unit-tested without rendering the whole page.
15
+ */
16
+ export function resolveAutoNewsletter(
17
+ rss: boolean | undefined,
18
+ config: { integrations?: { newsletter?: NewsletterLike } },
19
+ pageNewsletter?: boolean,
20
+ ): EmailSubscribeProps | null {
21
+ // Per-page escape hatch: frontmatter `newsletter: false` suppresses the
22
+ // configured auto-placement on THIS page (e.g. a non-changelog page that
23
+ // happens to set rss:true, or a localized changelog placing its own translated
24
+ // <EmailSubscribe>). Only an explicit `false` opts out.
25
+ if (pageNewsletter === false) return null;
26
+ const n = config.integrations?.newsletter;
27
+ if (!rss || !n || n.placement !== 'changelog') return null;
28
+ const { placement: _placement, ...props } = n;
29
+ void _placement;
30
+ return props;
31
+ }
32
+
33
+ /** True when the page's raw MDX already hand-places an `<EmailSubscribe>` tag.
34
+ * Used to suppress auto-placement so an explicit author form isn't doubled.
35
+ *
36
+ * Code is stripped first — fenced blocks (```…```) then inline spans (`…`) — so
37
+ * a page that merely *documents* the component (a changelog entry or guide
38
+ * showing `<EmailSubscribe />` in an example) isn't mistaken for a hand-placed
39
+ * form and wrongly suppress its own `placement: changelog` auto-mount.
40
+ * The trailing char class avoids matching `<EmailSubscribeFoo`. */
41
+ export function mdxHasEmailSubscribe(rawContent: string): boolean {
42
+ const withoutCode = rawContent
43
+ .replace(/```[\s\S]*?```/g, '')
44
+ .replace(/`[^`\n]+`/g, '');
45
+ return /<EmailSubscribe[\s/>]/.test(withoutCode);
46
+ }
@@ -0,0 +1,120 @@
1
+ // builder/build-service/lib/email-subscribe-providers.ts
2
+ import { NATIVE_PROVIDER_IDS } from './newsletter/descriptors';
3
+
4
+ /**
5
+ * Props accepted by <EmailSubscribe> and (minus styling-only fields) by the
6
+ * docs.json `integrations.newsletter` block. All optional — the resolver
7
+ * decides what's renderable.
8
+ */
9
+ export interface EmailSubscribeProps {
10
+ /** Shorthand provider key (case-insensitive). */
11
+ provider?: string;
12
+ /** Mailchimp: full form POST `action` URL. */
13
+ action?: string;
14
+ /** Buttondown / Substack: account username. */
15
+ username?: string;
16
+ /** Beehiiv: full iframe embed `src` URL. */
17
+ src?: string;
18
+ /** Raw embed snippet (any vendor) — escape hatch, takes priority. */
19
+ snippet?: string;
20
+ /** iframe height in px for iframe providers (substack/beehiiv). */
21
+ height?: number;
22
+ /** Optional on-brand heading above the embed. */
23
+ title?: string;
24
+ description?: string;
25
+ /** Start collapsed: render a compact "Subscribe" button that expands to the
26
+ * full form on click (native providers only — embeds can't be controlled). */
27
+ collapsed?: boolean;
28
+ /** Extra class on the wrapper. */
29
+ className?: string;
30
+ }
31
+
32
+ export type ResolvedEmbed =
33
+ | { kind: 'snippet'; html: string }
34
+ | { kind: 'native'; provider: string } // Phase 2: real form + capture endpoint
35
+ | { kind: 'native-pending'; provider: string } // API-only, no native path yet
36
+ | { kind: 'none'; reason: string };
37
+
38
+ /** API-only ESPs with no native path yet — friendly notice, never an error. */
39
+ const NATIVE_PENDING_PROVIDERS = new Set(['mailersend']);
40
+
41
+ /** Escape a user value before interpolating into an HTML attribute so a stray
42
+ * quote/angle-bracket can't break out of the attribute or inject a tag.
43
+ * Escapes single quotes too, so it stays safe even if an attribute is later
44
+ * switched to single-quoted. */
45
+ function escapeAttr(value: string): string {
46
+ return value
47
+ .replace(/&/g, '&amp;')
48
+ .replace(/"/g, '&quot;')
49
+ .replace(/'/g, '&#39;')
50
+ .replace(/</g, '&lt;')
51
+ .replace(/>/g, '&gt;');
52
+ }
53
+
54
+ const EMAIL_INPUT =
55
+ '<input type="email" name="EMAIL" required placeholder="you@example.com" ' +
56
+ 'aria-label="Email address" ' +
57
+ 'style="flex:1;min-width:0;font-size:16px;padding:0.5rem 0.75rem;border:1px solid var(--color-border, #d4d4d8);border-radius:0.375rem;background:var(--color-bg-primary, #fff);color:var(--color-text-primary, #18181b)" />';
58
+
59
+ const SUBMIT_BUTTON =
60
+ '<button type="submit" ' +
61
+ 'style="font-size:16px;padding:0.5rem 1rem;border:0;border-radius:0.375rem;cursor:pointer;background:var(--color-primary, #2563eb);color:#fff">Subscribe</button>';
62
+
63
+ function formShell(action: string, emailName: string, target: string): string {
64
+ // `font-size:16px` on the input avoids iOS auto-zoom (monorepo UI rule).
65
+ // escapeAttr defends the field name even though callers pass literals today —
66
+ // a future provider deriving emailName from config can't inject an attribute.
67
+ const input = EMAIL_INPUT.replace('name="EMAIL"', `name="${escapeAttr(emailName)}"`);
68
+ return (
69
+ `<form action="${action}" method="post" target="${target}" ` +
70
+ `style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;max-width:28rem">` +
71
+ input +
72
+ SUBMIT_BUTTON +
73
+ `</form>`
74
+ );
75
+ }
76
+
77
+ function iframe(src: string, height: number): string {
78
+ // Height is provider-defaulted + author-overridable (`height` prop): a single
79
+ // magic number clips Substack's taller widget or pads Beehiiv's compact one.
80
+ return (
81
+ `<iframe src="${src}" width="100%" height="${height}" frameborder="0" scrolling="no" ` +
82
+ `title="Subscribe" style="border:0;background:transparent;max-width:28rem"></iframe>`
83
+ );
84
+ }
85
+
86
+ /** Provider shorthands whose markup is fully reconstructable from documented,
87
+ * stable inputs. Mailchimp and beehiiv were here pre-native; they are now
88
+ * handled by the NATIVE_PROVIDER_IDS branch above. */
89
+ const PROVIDER_BUILDERS: Record<string, (p: EmailSubscribeProps) => ResolvedEmbed> = {
90
+ buttondown(p) {
91
+ if (!p.username) return { kind: 'none', reason: 'buttondown requires a "username"' };
92
+ const action = `https://buttondown.com/api/emails/embed-subscribe/${encodeURIComponent(p.username)}`;
93
+ return { kind: 'snippet', html: formShell(escapeAttr(action), 'email', 'popupwindow') };
94
+ },
95
+ substack(p) {
96
+ if (!p.username) return { kind: 'none', reason: 'substack requires a "username"' };
97
+ const sub = encodeURIComponent(p.username);
98
+ return { kind: 'snippet', html: iframe(escapeAttr(`https://${sub}.substack.com/embed`), p.height ?? 320) };
99
+ },
100
+ };
101
+
102
+ /** Turn EmailSubscribe props into a renderable embed decision. Pure. */
103
+ export function resolveEmailSubscribeEmbed(p: EmailSubscribeProps): ResolvedEmbed {
104
+ if (p.snippet && p.snippet.trim()) return { kind: 'snippet', html: p.snippet };
105
+
106
+ const provider = p.provider?.trim().toLowerCase();
107
+ if (!provider) return { kind: 'none', reason: 'no provider or snippet specified' };
108
+ // Migration note: sites whose docs.json still says provider:"mailchimp"/"beehiiv"
109
+ // now resolve to a native form. With no dashboard creds the submission returns the
110
+ // uniform 404 and the form shows a neutral message. Owners must configure the
111
+ // provider in the dashboard (agreed native-replaces-embed migration).
112
+ if (NATIVE_PROVIDER_IDS.has(provider)) return { kind: 'native', provider };
113
+ if (NATIVE_PENDING_PROVIDERS.has(provider)) return { kind: 'native-pending', provider };
114
+
115
+ const builder = PROVIDER_BUILDERS[provider];
116
+ if (!builder) {
117
+ return { kind: 'none', reason: `provider "${provider}" has no shorthand — paste its embed via the "snippet" prop` };
118
+ }
119
+ return builder(p);
120
+ }
@@ -393,6 +393,7 @@ export const INTERNAL_API_ROUTES = [
393
393
  '/api/r2', // R2 content serving (app/api/r2/[project]/[...path]) — gated explicitly in proxy.ts
394
394
  '/api/revalidate', // Cache revalidation (app/api/revalidate)
395
395
  '/api/search-ev', // Search analytics proxy (app/api/search-ev)
396
+ '/api/subscribe', // Newsletter subscribe endpoint (app/api/subscribe/[project]) — gated by Origin in route
396
397
  // NOTE: /api/jd is intentionally NOT here — /api/jd/unlock reads
397
398
  // x-project-slug + x-host-at-docs headers that middleware sets.
398
399
  // NOTE: /api/markdown-export is intentionally NOT here either — it serves
@@ -553,6 +554,26 @@ export function getChatApiPath(projectSlug: string): string {
553
554
  return `/api/chat/${projectSlug}`;
554
555
  }
555
556
 
557
+ /**
558
+ * Check if this is a newsletter subscribe request that needs routing.
559
+ *
560
+ * @param pathname - Request pathname
561
+ * @returns true if this is a subscribe request
562
+ */
563
+ export function isSubscribeRequest(pathname: string): boolean {
564
+ return pathname === '/_jd/subscribe' || pathname === '/docs/_jd/subscribe';
565
+ }
566
+
567
+ /**
568
+ * Get the subscribe API path for a project.
569
+ *
570
+ * @param projectSlug - Project identifier
571
+ * @returns Subscribe API route path
572
+ */
573
+ export function getSubscribeApiPath(projectSlug: string): string {
574
+ return `/api/subscribe/${projectSlug}`;
575
+ }
576
+
556
577
  /**
557
578
  * Check if this is a docs search request that needs routing.
558
579
  *
@@ -18,7 +18,7 @@ import type {
18
18
  ExternalAnchorConfig,
19
19
  } from './docs-types';
20
20
 
21
- import { normalizeNavPage, getIconName } from './docs-types';
21
+ import { normalizeNavPage, getIconString } from './docs-types';
22
22
  import { getLanguageDisplayInfo, extractLanguageFromPath } from './language-utils';
23
23
  import { evictOldest } from './cache-utils';
24
24
 
@@ -225,7 +225,7 @@ function resolveGroup(group: GroupConfig): ResolvedGroup {
225
225
 
226
226
  return {
227
227
  name: group.group,
228
- icon: getIconName(group.icon),
228
+ icon: getIconString(group.icon),
229
229
  tag: group.tag,
230
230
  pages: resolvedPages,
231
231
  expanded: group.expanded,
@@ -268,7 +268,7 @@ function resolveTabGroups(tab: TabConfig): ResolvedGroup[] {
268
268
  function resolveTab(tab: TabConfig): ResolvedTab {
269
269
  return {
270
270
  name: tab.tab,
271
- icon: getIconName(tab.icon),
271
+ icon: getIconString(tab.icon),
272
272
  href: tab.href,
273
273
  isExternal: !!tab.href && !tab.groups && !tab.pages,
274
274
  };
@@ -465,7 +465,7 @@ export function resolveNavigation(
465
465
  .map((anchor: ExternalAnchorConfig) => ({
466
466
  name: anchor.name,
467
467
  href: anchor.href,
468
- icon: getIconName(anchor.icon),
468
+ icon: getIconString(anchor.icon),
469
469
  }));
470
470
  }
471
471
 
@@ -476,7 +476,7 @@ export function resolveNavigation(
476
476
  result.externalAnchors.push({
477
477
  name: anchor.anchor,
478
478
  href: anchor.href,
479
- icon: getIconName(anchor.icon),
479
+ icon: getIconString(anchor.icon),
480
480
  });
481
481
  }
482
482
  }
@@ -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.beehiiv.com/v2';
6
+ const headers = (secret: string) => ({ Authorization: `Bearer ${secret}`, 'content-type': 'application/json' });
7
+
8
+ export const beehiivAdapter: NewsletterAdapter = {
9
+ descriptor: getDescriptor('beehiiv')!,
10
+ async validate(creds: NewsletterCreds) {
11
+ if (!creds.publicationId) return { ok: false, reason: 'publicationId required' };
12
+ const { status } = await fetchJson(`${BASE}/publications/${creds.publicationId}`, { method: 'GET', headers: headers(creds.secret) });
13
+ return status === 200 ? { ok: true } : { ok: false, reason: `publication ${status}` };
14
+ },
15
+ async addContact(email: string, creds: NewsletterCreds) {
16
+ if (!creds.publicationId) return { ok: false, code: 'misconfigured' };
17
+ const { status } = await fetchJson(`${BASE}/publications/${creds.publicationId}/subscriptions`, {
18
+ method: 'POST', headers: headers(creds.secret),
19
+ body: JSON.stringify({ email, reactivate_existing: false, send_welcome_email: false }),
20
+ });
21
+ return status >= 200 && status < 300 ? { ok: true } : { ok: false, status };
22
+ },
23
+ };
@@ -0,0 +1,31 @@
1
+ import type { NewsletterAdapter, NewsletterCreds } from '../types';
2
+ import { getDescriptor } from '../descriptors';
3
+ import { fetchJson } from '../http';
4
+
5
+ const BASE = 'https://api.brevo.com/v3';
6
+ const headers = (secret: string) => ({ 'api-key': secret, 'content-type': 'application/json' });
7
+
8
+ export const brevoAdapter: NewsletterAdapter = {
9
+ descriptor: getDescriptor('brevo')!,
10
+ async validate(creds: NewsletterCreds) {
11
+ if (!creds.listId) return { ok: false, reason: 'listId required' };
12
+ // Probe the LIST, not just /account: a valid key with a wrong listId would
13
+ // pass /account but then drop every signup. GET the list to prove both.
14
+ const { status } = await fetchJson(`${BASE}/contacts/lists/${creds.listId}`,
15
+ { method: 'GET', headers: headers(creds.secret) });
16
+ return status === 200 ? { ok: true } : { ok: false, reason: `list ${status}` };
17
+ },
18
+ async addContact(email: string, creds: NewsletterCreds) {
19
+ if (!creds.listId) return { ok: false, code: 'misconfigured' };
20
+ const { status, json } = await fetchJson(`${BASE}/contacts`, {
21
+ method: 'POST', headers: headers(creds.secret),
22
+ // updateEnabled:false → never silently re-subscribes a contact who opted out.
23
+ body: JSON.stringify({ email, listIds: [Number(creds.listId)], updateEnabled: false }),
24
+ });
25
+ if (status >= 200 && status < 300) return { ok: true };
26
+ // A returning subscriber already on the list → idempotent success, not error.
27
+ const code = (json as { code?: string } | null)?.code;
28
+ if (status === 400 && code === 'duplicate_parameter') return { ok: true };
29
+ return { ok: false, status };
30
+ },
31
+ };