jamdesk 1.1.142 → 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.
Files changed (43) 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/lib/docs-types.ts +30 -0
  11. package/vendored/lib/email-subscribe-autoplacement.ts +46 -0
  12. package/vendored/lib/email-subscribe-providers.ts +120 -0
  13. package/vendored/lib/middleware-helpers.ts +21 -0
  14. package/vendored/lib/newsletter/adapters/beehiiv.ts +23 -0
  15. package/vendored/lib/newsletter/adapters/brevo.ts +31 -0
  16. package/vendored/lib/newsletter/adapters/kit.ts +35 -0
  17. package/vendored/lib/newsletter/adapters/loops.ts +22 -0
  18. package/vendored/lib/newsletter/adapters/mailchimp.ts +52 -0
  19. package/vendored/lib/newsletter/adapters/resend.ts +23 -0
  20. package/vendored/lib/newsletter/adapters/sendgrid.ts +23 -0
  21. package/vendored/lib/newsletter/descriptors.ts +84 -0
  22. package/vendored/lib/newsletter/http.ts +19 -0
  23. package/vendored/lib/newsletter/registry.ts +27 -0
  24. package/vendored/lib/newsletter/types.ts +52 -0
  25. package/vendored/lib/newsletter-display.ts +43 -0
  26. package/vendored/lib/render-doc-page.tsx +46 -2
  27. package/vendored/lib/seo.ts +13 -2
  28. package/vendored/lib/validate-content-images.ts +150 -0
  29. package/vendored/schema/docs-schema.json +28 -0
  30. package/vendored/shared/status-reporter.ts +1 -1
  31. package/vendored/workspace-package-lock.json +3 -87
  32. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts +0 -2
  33. package/dist/__tests__/unit/dev-workspace-symlinks.test.d.ts.map +0 -1
  34. package/dist/__tests__/unit/dev-workspace-symlinks.test.js +0 -112
  35. package/dist/__tests__/unit/dev-workspace-symlinks.test.js.map +0 -1
  36. package/dist/__tests__/unit/language-filter.test.d.ts +0 -2
  37. package/dist/__tests__/unit/language-filter.test.d.ts.map +0 -1
  38. package/dist/__tests__/unit/language-filter.test.js +0 -166
  39. package/dist/__tests__/unit/language-filter.test.js.map +0 -1
  40. package/dist/lib/language-filter.d.ts +0 -31
  41. package/dist/lib/language-filter.d.ts.map +0 -1
  42. package/dist/lib/language-filter.js +0 -14
  43. package/dist/lib/language-filter.js.map +0 -1
@@ -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,
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Content Image Validation (build-service)
3
+ *
4
+ * Warns when a local image referenced from page/snippet MDX — markdown
5
+ * `![alt](/images/foo.webp)`, `<img src="…">`, `<Image src="…">` — points at a
6
+ * file that isn't in the project's collected assets. Emits non-blocking
7
+ * `missing_image` BuildWarning entries.
8
+ *
9
+ * Nothing else in the build covers this: validate-links.cjs skips asset
10
+ * extensions and the WebP converter silently ignores missing references, so a
11
+ * typo'd or never-uploaded image otherwise ships and only 404s in a reader's
12
+ * browser.
13
+ *
14
+ * PARITY TWIN of the CLI's `builder/cli/src/lib/validate-image-refs.ts` — the
15
+ * two run on different surfaces (in-memory maps here, filesystem walk there) so
16
+ * they can't share a module, but their DETECTION must agree so `jamdesk
17
+ * validate` and the cloud build flag the same references. The extraction
18
+ * (stripCodeRegions + the two regexes + the external/data skip) is copied from
19
+ * that file verbatim; keep them in sync. This build-service copy adds two
20
+ * build-only concerns the CLI lacks: resolution against the already-collected
21
+ * asset list (instead of fs), and convertToWebp awareness (a `.webp` reference
22
+ * backed by a `.png`/`.jpg` source is valid once the converter runs).
23
+ */
24
+
25
+ import path from 'path';
26
+ import { normalizeAssetPath } from './isr-build-executor.js';
27
+ import type { BuildWarning } from '../shared/status-reporter.js';
28
+
29
+ export interface ValidateContentImagesOptions {
30
+ /** When docs.json images.convertToWebp is on, a `.webp` reference is served
31
+ * from a `.png`/`.jpg` source — accept those so conversion isn't flagged. */
32
+ convertToWebp?: boolean;
33
+ /** Max warnings to return (Firestore document-size guard). Default 50. */
34
+ cap?: number;
35
+ }
36
+
37
+ const CONVERTIBLE_EXT_RE = /\.(png|jpe?g)$/i;
38
+
39
+ // --- Extraction (kept identical to the CLI twin) -----------------------------
40
+
41
+ function isExternalOrDataUrl(src: string): boolean {
42
+ return (
43
+ src.startsWith('http://') ||
44
+ src.startsWith('https://') ||
45
+ src.startsWith('//') ||
46
+ src.startsWith('data:')
47
+ );
48
+ }
49
+
50
+ /**
51
+ * Blank out MDX regions that hold non-rendered content — HTML comments, JSX
52
+ * comments, fenced code blocks, inline code spans — replacing them with spaces
53
+ * of equal length so reported line numbers stay accurate. Image-like syntax
54
+ * inside these is illustrative (a tutorial showing `![alt](path)`), not a real
55
+ * reference, and flagging it would be a false positive.
56
+ */
57
+ function stripCodeRegions(content: string): string {
58
+ const blank = (m: string) => m.replace(/[^\n]/g, ' ');
59
+ let out = content.replace(/<!--[\s\S]*?-->/g, blank);
60
+ out = out.replace(/\{\s*\/\*(?:(?!\*\/)[\s\S])*?\*\/\s*\}/g, blank);
61
+ out = out.replace(/(^|\n)[ \t]*(```|~~~)[^\n]*\n([\s\S]*?)\n[ \t]*\2(?=\n|$)/g, blank);
62
+ out = out.replace(/`[^`\n]+`/g, blank);
63
+ return out;
64
+ }
65
+
66
+ function extractImageRefs(content: string): { src: string; line: number }[] {
67
+ const refs: { src: string; line: number }[] = [];
68
+ const scan = stripCodeRegions(content);
69
+
70
+ // <img src="…"> / <Image src="…"> — string literal only (skips {expr}).
71
+ const jsxRegex = /<(?:img|Image)\b[^>]*?\bsrc\s*=\s*(["'])([^"'{}]+?)\1/gi;
72
+ // Markdown ![alt](path) — capture path before an optional title.
73
+ const mdRegex = /!\[[^\]]*\]\(\s*<?([^)\s<>]+)>?(?:\s+["'][^"']*["'])?\s*\)/g;
74
+
75
+ const indexToLine = (idx: number): number => {
76
+ let line = 1;
77
+ for (let i = 0; i < idx; i++) if (scan.charCodeAt(i) === 10) line++;
78
+ return line;
79
+ };
80
+
81
+ for (const m of scan.matchAll(jsxRegex)) refs.push({ src: m[2], line: indexToLine(m.index ?? 0) });
82
+ for (const m of scan.matchAll(mdRegex)) refs.push({ src: m[1], line: indexToLine(m.index ?? 0) });
83
+ return refs;
84
+ }
85
+
86
+ // --- Resolution (build-service: against the collected asset list) ------------
87
+
88
+ /** Resolve a reference to the same key shape as collected asset paths
89
+ * (public/ stripped). Absolute `/x` is project-rooted; a relative ref is
90
+ * resolved against the referencing file's directory. */
91
+ function resolveKey(fileKey: string, src: string): string | null {
92
+ const clean = src.split(/[?#]/)[0];
93
+ if (!clean) return null;
94
+ const rel = clean.startsWith('/')
95
+ ? clean.slice(1)
96
+ : path.posix.normalize(path.posix.join(path.posix.dirname(fileKey), clean));
97
+ return normalizeAssetPath(rel.replace(/^\/+/, ''));
98
+ }
99
+
100
+ /**
101
+ * @param fileContents - path -> MDX content (pages keyed by relative path,
102
+ * snippets by `snippets/<name>`), as assembled in build.ts.
103
+ * @param assetFiles - asset paths relative to project dir (from collectAssetFiles).
104
+ */
105
+ export function validateContentImages(
106
+ fileContents: Map<string, string>,
107
+ assetFiles: string[],
108
+ options: ValidateContentImagesOptions = {},
109
+ ): BuildWarning[] {
110
+ const cap = options.cap ?? 50;
111
+
112
+ const assetSet = new Set(assetFiles.map(normalizeAssetPath));
113
+ if (options.convertToWebp) {
114
+ for (const asset of assetFiles) {
115
+ const normalized = normalizeAssetPath(asset);
116
+ if (CONVERTIBLE_EXT_RE.test(normalized)) {
117
+ assetSet.add(normalized.replace(CONVERTIBLE_EXT_RE, '.webp'));
118
+ }
119
+ }
120
+ }
121
+
122
+ const warnings: BuildWarning[] = [];
123
+ const seen = new Set<string>();
124
+
125
+ for (const [file, content] of fileContents) {
126
+ if (warnings.length >= cap) break;
127
+ for (const { src, line } of extractImageRefs(content)) {
128
+ if (warnings.length >= cap) break;
129
+ const trimmed = src.trim();
130
+ if (!trimmed || isExternalOrDataUrl(trimmed)) continue;
131
+
132
+ const key = resolveKey(file, trimmed);
133
+ if (key === null || assetSet.has(key)) continue;
134
+
135
+ const dedupeKey = `${file}|${key}`;
136
+ if (seen.has(dedupeKey)) continue;
137
+ seen.add(dedupeKey);
138
+
139
+ warnings.push({
140
+ type: 'missing_image',
141
+ file,
142
+ line,
143
+ link: trimmed,
144
+ message: `Image "${trimmed}" referenced in ${file} was not found in the project`,
145
+ });
146
+ }
147
+ }
148
+
149
+ return warnings;
150
+ }
@@ -1158,6 +1158,34 @@
1158
1158
  ],
1159
1159
  "additionalProperties": false
1160
1160
  },
1161
+ "newsletter": {
1162
+ "type": "object",
1163
+ "description": "Inline newsletter / changelog-notification signup rendered by <EmailSubscribe>. Customer owns the list and sending.",
1164
+ "properties": {
1165
+ "provider": {
1166
+ "type": "string",
1167
+ "enum": ["resend", "mailchimp", "kit", "loops", "beehiiv", "brevo", "sendgrid", "buttondown", "substack"],
1168
+ "description": "Newsletter provider. Native providers (resend, mailchimp, kit, loops, beehiiv, brevo, sendgrid) are configured in the dashboard (Settings → Email signups) and render a Jamdesk-hosted capture form. buttondown and substack are embed-only — supply a \"username\" to render their iframe/form."
1169
+ },
1170
+ "action": { "type": "string", "description": "Mailchimp: full form POST action URL." },
1171
+ "username": { "type": "string", "description": "Buttondown / Substack account username." },
1172
+ "src": { "type": "string", "description": "Beehiiv: full iframe embed src URL." },
1173
+ "snippet": { "type": "string", "description": "Raw embed snippet (any vendor) — escape hatch." },
1174
+ "height": { "type": "number", "description": "iframe height in px for substack/beehiiv embeds." },
1175
+ "title": { "type": "string" },
1176
+ "description": { "type": "string" },
1177
+ "collapsed": {
1178
+ "type": "boolean",
1179
+ "description": "Native providers only: render a compact Subscribe button that expands to the form on click, instead of the full email box."
1180
+ },
1181
+ "placement": {
1182
+ "type": "string",
1183
+ "enum": ["none", "changelog"],
1184
+ "description": "'changelog' auto-mounts on rss:true pages; 'none' (default) = manual <EmailSubscribe>."
1185
+ }
1186
+ },
1187
+ "additionalProperties": false
1188
+ },
1161
1189
  "intercom": {
1162
1190
  "type": "object",
1163
1191
  "properties": {
@@ -22,7 +22,7 @@ export interface ProgressUpdate {
22
22
  }
23
23
 
24
24
  /** Warning types that can occur during builds (non-blocking) */
25
- export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_page' | 'missing_openapi_ref' | 'inline_code_on_api_page' | 'invalid_openapi_spec' | 'missing_snippet' | 'risky_expression' | 'missing_favicon' | 'missing_description' | 'missing_logo';
25
+ export type BuildWarningType = 'broken_link' | 'auto_migrate' | 'missing_asset' | 'missing_image' | 'missing_page' | 'missing_openapi_ref' | 'inline_code_on_api_page' | 'invalid_openapi_spec' | 'missing_snippet' | 'risky_expression' | 'missing_favicon' | 'missing_description' | 'missing_logo';
26
26
 
27
27
  /** Build warning structure */
28
28
  export interface BuildWarning {