jamdesk 1.1.141 → 1.1.143
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/dist/lib/deprecated-components.d.ts +1 -0
- package/dist/lib/deprecated-components.d.ts.map +1 -1
- package/dist/lib/deprecated-components.js +1 -0
- package/dist/lib/deprecated-components.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/chat/ChatMessage.tsx +49 -15
- package/vendored/components/layout/EmbedLinkInterceptor.tsx +2 -4
- 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/search/SearchModal.tsx +19 -1
- package/vendored/lib/deprecated-components.ts +1 -0
- package/vendored/lib/docs-types.ts +30 -0
- package/vendored/lib/email-subscribe-autoplacement.ts +46 -0
- package/vendored/lib/email-subscribe-providers.ts +120 -0
- package/vendored/lib/firestore-helpers.ts +20 -0
- package/vendored/lib/is-modified-click.ts +11 -0
- package/vendored/lib/middleware-helpers.ts +25 -0
- 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/preprocess-mdx.ts +20 -12
- package/vendored/lib/redis.ts +32 -0
- package/vendored/lib/render-doc-page.tsx +81 -25
- package/vendored/lib/scanner-blocklist.ts +26 -1
- package/vendored/lib/seo.ts +13 -2
- package/vendored/lib/sitemap-xsl.ts +121 -0
- package/vendored/lib/static-file-route.ts +7 -2
- package/vendored/lib/validate-content-images.ts +150 -0
- package/vendored/schema/docs-schema.json +28 -0
- package/vendored/shared/status-reporter.ts +1 -1
- package/vendored/workspace-package-lock.json +87 -171
- 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,167 @@
|
|
|
1
|
+
// builder/build-service/components/mdx/EmailSubscribe.tsx
|
|
2
|
+
'use client';
|
|
3
|
+
|
|
4
|
+
import { useEffect, useRef } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
resolveEmailSubscribeEmbed,
|
|
7
|
+
type EmailSubscribeProps,
|
|
8
|
+
} from '@/lib/email-subscribe-providers';
|
|
9
|
+
import { NativeSubscribeForm } from './NativeSubscribeForm';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Re-create a <script> so the browser executes it. Scripts inserted via
|
|
13
|
+
* innerHTML never run; copying attributes + inline text onto a fresh
|
|
14
|
+
* createElement node and appending it does.
|
|
15
|
+
*/
|
|
16
|
+
export function reviveScript(original: HTMLScriptElement): HTMLScriptElement {
|
|
17
|
+
const s = document.createElement('script');
|
|
18
|
+
for (const attr of Array.from(original.attributes)) s.setAttribute(attr.name, attr.value);
|
|
19
|
+
s.text = original.text;
|
|
20
|
+
return s;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* External vendor scripts (Kit/MailerLite `index.js`) load AT MOST ONCE per src,
|
|
25
|
+
* globally. Re-running one on App Router soft-navigation double-registers the
|
|
26
|
+
* widget (duplicate handlers, double analytics) — the spec's #1 risk. They are
|
|
27
|
+
* appended to <body> (not the mount) so they survive route changes and aren't
|
|
28
|
+
* removed on unmount.
|
|
29
|
+
*
|
|
30
|
+
* KNOWN LIMITATION: a script-based INLINE embed won't re-inject its form on
|
|
31
|
+
* back-navigation (the script won't re-run). For inline use prefer iframe/form
|
|
32
|
+
* embeds; for script-based providers (Kit/MailerLite) place the signup on a
|
|
33
|
+
* dedicated changelog page (full page load) — documented in the component docs.
|
|
34
|
+
*/
|
|
35
|
+
const loadedExternalScripts = new Set<string>();
|
|
36
|
+
|
|
37
|
+
/** Test-only: clears the global load-once cache between cases. */
|
|
38
|
+
export function _resetEmbedScriptCacheForTest(): void {
|
|
39
|
+
loadedExternalScripts.clear();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* <EmailSubscribe> — inline newsletter / changelog-notification signup.
|
|
44
|
+
*
|
|
45
|
+
* Hosts any ESP's paste-able embed (raw `snippet`) or a `provider` shorthand
|
|
46
|
+
* (mailchimp/buttondown/substack/beehiiv). The embed is injected on the client
|
|
47
|
+
* so it previews in `jamdesk dev` and runs on published ISR. Positioned for
|
|
48
|
+
* INLINE embeds; for floating/popup/sticky widgets use site-wide Custom JS.
|
|
49
|
+
*
|
|
50
|
+
* The dashboard editor renders <EmailSubscribePlaceholder> instead (mapped in
|
|
51
|
+
* the editor's builder-bridge) so vendor scripts never execute in the dashboard
|
|
52
|
+
* origin.
|
|
53
|
+
*/
|
|
54
|
+
export function EmailSubscribe(props: EmailSubscribeProps) {
|
|
55
|
+
const { title, description, className, collapsed } = props;
|
|
56
|
+
const mountRef = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const embed = resolveEmailSubscribeEmbed(props);
|
|
58
|
+
const embedKey = embed.kind === 'snippet' ? embed.html : embed.kind;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const mount = mountRef.current;
|
|
62
|
+
if (!mount) return;
|
|
63
|
+
// Defense-in-depth: NEVER execute a customer snippet inside the dashboard
|
|
64
|
+
// editor origin, even if the component-map placeholder override regresses.
|
|
65
|
+
// The editor's builder-bridge.ts sets this flag at module load (Task 9); it
|
|
66
|
+
// is never set on published sites or CLI dev, so injection proceeds there.
|
|
67
|
+
if (typeof window !== 'undefined' &&
|
|
68
|
+
(window as { __JD_EDITOR_PREVIEW__?: boolean }).__JD_EDITOR_PREVIEW__) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (embed.kind !== 'snippet') {
|
|
72
|
+
mount.replaceChildren();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Idempotent: clear any prior injection (StrictMode double-invoke, remount,
|
|
76
|
+
// or snippet change) before injecting the current embed.
|
|
77
|
+
mount.replaceChildren();
|
|
78
|
+
const tpl = document.createElement('template');
|
|
79
|
+
tpl.innerHTML = embed.html;
|
|
80
|
+
for (const old of Array.from(tpl.content.querySelectorAll('script'))) {
|
|
81
|
+
const src = old.getAttribute('src');
|
|
82
|
+
if (src) {
|
|
83
|
+
// External script: load once, globally, on <body> — survives soft-nav.
|
|
84
|
+
old.remove();
|
|
85
|
+
if (!loadedExternalScripts.has(src)) {
|
|
86
|
+
loadedExternalScripts.add(src);
|
|
87
|
+
document.body.appendChild(reviveScript(old as HTMLScriptElement));
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
// Inline script (config like window.CONFIG=…): revive in place.
|
|
91
|
+
old.replaceWith(reviveScript(old as HTMLScriptElement));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
mount.appendChild(tpl.content);
|
|
95
|
+
return () => mount.replaceChildren();
|
|
96
|
+
// embedKey is the only input that changes the injected DOM.
|
|
97
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
98
|
+
}, [embedKey]);
|
|
99
|
+
|
|
100
|
+
if (embed.kind === 'native') {
|
|
101
|
+
// First-party form posting to /_jd/subscribe — no snippet injection.
|
|
102
|
+
return <NativeSubscribeForm provider={embed.provider} title={title} description={description} collapsed={collapsed} className={className} />;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (embed.kind !== 'snippet') {
|
|
106
|
+
if (process.env.NODE_ENV === 'production') {
|
|
107
|
+
// Fail safe: never show end users a "coming soon" / config-error string.
|
|
108
|
+
return <div ref={mountRef} className="jd-emailsubscribe-empty" />;
|
|
109
|
+
}
|
|
110
|
+
const msg =
|
|
111
|
+
embed.kind === 'native-pending'
|
|
112
|
+
? `EmailSubscribe: "${embed.provider}" has no embeddable form yet (API-only). ` +
|
|
113
|
+
'Bridge it via a no-code form (Tally/Formspree → automation) for now — native support is planned.'
|
|
114
|
+
: `EmailSubscribe: ${embed.reason}.`;
|
|
115
|
+
return (
|
|
116
|
+
<div className="jd-emailsubscribe not-prose my-6 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300" role="note">
|
|
117
|
+
{msg}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className={`jd-emailsubscribe not-prose my-6 ${className ?? ''}`.trim()}>
|
|
124
|
+
{title && <p className="mb-1 font-semibold text-theme-text-primary">{title}</p>}
|
|
125
|
+
{description && <p className="mb-3 text-sm text-theme-text-secondary">{description}</p>}
|
|
126
|
+
<div ref={mountRef} className="jd-emailsubscribe-mount" />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Editor-preview stand-in: static placeholder, never executes the embed. Swapped
|
|
133
|
+
* into the editor component map (builder-bridge.ts). Not used on published sites
|
|
134
|
+
* or in CLI dev.
|
|
135
|
+
*/
|
|
136
|
+
/** Placeholder label text per resolved embed kind. Early returns keep the
|
|
137
|
+
* discriminated-union narrowing that makes `embed.provider` / `embed.reason`
|
|
138
|
+
* accessible (avoids a nested ternary). */
|
|
139
|
+
function placeholderLabel(embed: ReturnType<typeof resolveEmailSubscribeEmbed>, title?: string): string {
|
|
140
|
+
switch (embed.kind) {
|
|
141
|
+
case 'snippet':
|
|
142
|
+
case 'native':
|
|
143
|
+
return `${title || 'Email signup'} — live on your published site`;
|
|
144
|
+
case 'native-pending':
|
|
145
|
+
return `Email signup: "${embed.provider}" has no embeddable form — bridge it via a no-code form.`;
|
|
146
|
+
case 'none':
|
|
147
|
+
return `Email signup: not configured (${embed.reason}). Set a provider + id, or a snippet.`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function EmailSubscribePlaceholder(props: EmailSubscribeProps) {
|
|
152
|
+
// Resolve (cheaply, no injection) so the editor surfaces config errors instead
|
|
153
|
+
// of hiding them — a dashboard author would otherwise never see the dev notice
|
|
154
|
+
// (prod env) and could ship a permanently-empty signup.
|
|
155
|
+
const embed = resolveEmailSubscribeEmbed(props);
|
|
156
|
+
const ok = embed.kind === 'snippet' || embed.kind === 'native';
|
|
157
|
+
const label = placeholderLabel(embed, props.title);
|
|
158
|
+
const tone = ok
|
|
159
|
+
? 'border-theme-border bg-theme-bg-secondary text-theme-text-secondary'
|
|
160
|
+
: 'border-amber-400 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300';
|
|
161
|
+
return (
|
|
162
|
+
<div className={`jd-emailsubscribe jd-emailsubscribe--placeholder not-prose my-6 rounded-md border border-dashed p-4 text-sm ${tone}`} role="note">
|
|
163
|
+
<span aria-hidden="true">{ok ? '✉ ' : '⚠ '}</span>
|
|
164
|
+
{label}
|
|
165
|
+
</div>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
@@ -27,6 +27,7 @@ import { Icon } from './Icon';
|
|
|
27
27
|
import { Expandable } from './Expandable';
|
|
28
28
|
import { Frame } from './Frame';
|
|
29
29
|
import { Update } from './Update';
|
|
30
|
+
import { EmailSubscribe } from './EmailSubscribe';
|
|
30
31
|
import { Tabs, Tab } from './Tabs';
|
|
31
32
|
import { Table, Row, Cell } from './Table';
|
|
32
33
|
import { CodePanel } from '../ui/CodePanel';
|
|
@@ -211,6 +212,8 @@ export const MDXComponents = {
|
|
|
211
212
|
Frame,
|
|
212
213
|
// Update for changelog entries
|
|
213
214
|
Update,
|
|
215
|
+
// EmailSubscribe — inline newsletter / changelog-notification signup
|
|
216
|
+
EmailSubscribe,
|
|
214
217
|
// Tabs for organizing content
|
|
215
218
|
Tabs,
|
|
216
219
|
Tab,
|
|
@@ -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’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’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
|
+
}
|
|
@@ -157,6 +157,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
157
157
|
const router = useRouter();
|
|
158
158
|
const resultsContainerRef = useRef<HTMLDivElement>(null);
|
|
159
159
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
160
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
160
161
|
// Track if user clicked a result (to avoid double-tracking on modal close)
|
|
161
162
|
const hasTrackedRef = useRef(false);
|
|
162
163
|
// Store last search state for tracking on modal close
|
|
@@ -519,17 +520,34 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
|
|
|
519
520
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-[var(--color-border)]">
|
|
520
521
|
<i className="fa-solid fa-magnifying-glass h-4 w-4 text-[var(--color-text-muted)] flex-shrink-0" aria-hidden="true" />
|
|
521
522
|
<input
|
|
523
|
+
ref={inputRef}
|
|
522
524
|
type="search"
|
|
523
525
|
placeholder="Search documentation…"
|
|
524
526
|
value={query}
|
|
525
527
|
onChange={(e) => setQuery(e.target.value)}
|
|
526
|
-
className="flex-1 bg-transparent text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none text-base"
|
|
528
|
+
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"
|
|
527
529
|
autoComplete="off"
|
|
528
530
|
autoFocus
|
|
529
531
|
aria-label="Search documentation"
|
|
530
532
|
aria-controls="search-results"
|
|
531
533
|
aria-activedescendant={rows[selectedIndex] ? `search-result-${selectedIndex}` : undefined}
|
|
532
534
|
/>
|
|
535
|
+
{query && (
|
|
536
|
+
<button
|
|
537
|
+
type="button"
|
|
538
|
+
onClick={() => {
|
|
539
|
+
setQuery('');
|
|
540
|
+
inputRef.current?.focus();
|
|
541
|
+
}}
|
|
542
|
+
className="flex items-center justify-center w-4 h-4 cursor-pointer rounded-full bg-[var(--color-text-muted)] hover:bg-[var(--color-text-secondary)] transition-colors flex-shrink-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
543
|
+
aria-label="Clear search"
|
|
544
|
+
>
|
|
545
|
+
<i
|
|
546
|
+
className="fa-solid fa-xmark text-[9px] leading-none text-[var(--color-bg-primary)]"
|
|
547
|
+
aria-hidden="true"
|
|
548
|
+
/>
|
|
549
|
+
</button>
|
|
550
|
+
)}
|
|
533
551
|
<button
|
|
534
552
|
onClick={onClose}
|
|
535
553
|
className="p-1.5 cursor-pointer hover:bg-[var(--color-bg-tertiary)] rounded-lg transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-accent)]"
|
|
@@ -24,6 +24,7 @@ export interface DeprecatedComponentInfo {
|
|
|
24
24
|
/**
|
|
25
25
|
* Map of deprecated components to their replacements.
|
|
26
26
|
* Add new deprecated components here as they are removed.
|
|
27
|
+
* NEVER remove entries: render-time auto-migrate (preprocess-mdx.ts) rewrites stale R2 content with them forever.
|
|
27
28
|
*/
|
|
28
29
|
export const DEPRECATED_COMPONENTS: Record<string, DeprecatedComponentInfo> = {
|
|
29
30
|
CardGroup: {
|
|
@@ -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 };
|
|
@@ -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, '&')
|
|
48
|
+
.replace(/"/g, '"')
|
|
49
|
+
.replace(/'/g, ''')
|
|
50
|
+
.replace(/</g, '<')
|
|
51
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import admin from 'firebase-admin';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when a Firestore project claims this slug (the canonical slug lives at
|
|
5
|
+
* projects/{id}/github/config.slug — collection-group query avoids enumerating
|
|
6
|
+
* every project). Used as the server-side guard for expectOrphan deletes:
|
|
7
|
+
* orphan-purge tooling must never be able to wipe a live tenant, regardless of
|
|
8
|
+
* what a client-side script computed. The collection-group single-field index
|
|
9
|
+
* exemption for `github.slug` (COLLECTION_GROUP ASCENDING) already exists in
|
|
10
|
+
* dashboard/firestore.indexes.json, so this query needs no new index.
|
|
11
|
+
*/
|
|
12
|
+
export async function isSlugActive(slug: string): Promise<boolean> {
|
|
13
|
+
const snap = await admin
|
|
14
|
+
.firestore()
|
|
15
|
+
.collectionGroup('github')
|
|
16
|
+
.where('slug', '==', slug)
|
|
17
|
+
.limit(1)
|
|
18
|
+
.get();
|
|
19
|
+
return !snap.empty;
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MouseEvent } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when the user has asked the browser for special handling — new tab
|
|
5
|
+
* (cmd/ctrl), new window (shift), background tab (middle/non-primary button) —
|
|
6
|
+
* or another handler already claimed the event. Click interceptors must defer
|
|
7
|
+
* to native behavior in these cases instead of hijacking the click.
|
|
8
|
+
*/
|
|
9
|
+
export function isModifiedClick(e: MouseEvent): boolean {
|
|
10
|
+
return e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey;
|
|
11
|
+
}
|