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