jamdesk 1.1.19 → 1.1.21

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 (42) hide show
  1. package/dist/__tests__/unit/openapi-server-variable-description.test.d.ts +2 -0
  2. package/dist/__tests__/unit/openapi-server-variable-description.test.d.ts.map +1 -0
  3. package/dist/__tests__/unit/openapi-server-variable-description.test.js +55 -0
  4. package/dist/__tests__/unit/openapi-server-variable-description.test.js.map +1 -0
  5. package/dist/__tests__/unit/openapi.test.js +20 -5
  6. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  7. package/dist/commands/deploy.d.ts.map +1 -1
  8. package/dist/commands/deploy.js +3 -1
  9. package/dist/commands/deploy.js.map +1 -1
  10. package/dist/commands/dev.d.ts.map +1 -1
  11. package/dist/commands/dev.js +15 -2
  12. package/dist/commands/dev.js.map +1 -1
  13. package/dist/lib/deps.js +6 -6
  14. package/dist/lib/deps.js.map +1 -1
  15. package/package.json +6 -4
  16. package/scripts/patch-openapi-schemas.js +91 -0
  17. package/vendored/app/[[...slug]]/page.tsx +24 -39
  18. package/vendored/app/layout.tsx +16 -7
  19. package/vendored/components/FontAwesomeLoader.tsx +2 -1
  20. package/vendored/components/mdx/YouTube.tsx +21 -3
  21. package/vendored/components/search/SearchModal.tsx +15 -12
  22. package/vendored/components/snippets/generated/CodeLink.tsx +25 -0
  23. package/vendored/components/snippets/generated/HeaderAPI.tsx +44 -0
  24. package/vendored/components/snippets/generated/PlansAvailable.tsx +53 -0
  25. package/vendored/lib/analytics-client.ts +9 -27
  26. package/vendored/lib/docs-types.ts +16 -8
  27. package/vendored/lib/email-templates/build-failure.tsx +3 -0
  28. package/vendored/lib/extract-highlights.ts +2 -0
  29. package/vendored/lib/font-awesome.ts +2 -0
  30. package/vendored/lib/indexnow.ts +3 -1
  31. package/vendored/lib/isr-build-executor.ts +4 -1
  32. package/vendored/lib/page-isr-helpers.ts +9 -0
  33. package/vendored/lib/paths.ts +21 -9
  34. package/vendored/lib/project-slug-context.tsx +27 -0
  35. package/vendored/lib/r2-manifest.ts +1 -0
  36. package/vendored/next.config.js +2 -0
  37. package/vendored/schema/docs-schema.json +17 -0
  38. package/vendored/components/snippets/generated/ar__SnippetIntro.tsx +0 -43
  39. package/vendored/components/snippets/generated/es__SnippetIntro.tsx +0 -43
  40. package/vendored/components/snippets/generated/ja__SnippetIntro.tsx +0 -43
  41. package/vendored/components/snippets/generated/ko__SnippetIntro.tsx +0 -43
  42. package/vendored/postcss.config.js +0 -6
@@ -1,4 +1,4 @@
1
- import { notFound, redirect } from 'next/navigation';
1
+ import { notFound } from 'next/navigation';
2
2
  import { headers } from 'next/headers';
3
3
  import { MDXRemote } from 'next-mdx-remote/rsc';
4
4
 
@@ -47,7 +47,8 @@ import { mdxSecurityOptions } from '@/lib/mdx-security-options';
47
47
  import fs from 'fs';
48
48
  import path from 'path';
49
49
  import matter from 'gray-matter';
50
- import { getDocsConfig, getContentDir } from '@/lib/docs';
50
+ import { getContentDir } from '@/lib/docs';
51
+ import type { DocsConfig } from '@/lib/docs-types';
51
52
  import { buildSeoMetadata, generateAutoDescription, buildSiteTitle } from '@/lib/seo';
52
53
  import { buildJsonLd } from '@/lib/json-ld';
53
54
  import {
@@ -60,7 +61,7 @@ import {
60
61
  projectExists,
61
62
  type ContentLoader,
62
63
  } from '@/lib/content-loader';
63
- import { getBaseUrl } from '@/lib/page-isr-helpers';
64
+ import { getBaseUrl, pathToSlug } from '@/lib/page-isr-helpers';
64
65
  import {
65
66
  parseOpenApiFrontmatter,
66
67
  getCachedSpec,
@@ -180,9 +181,9 @@ function getAllDocPaths(): string[] {
180
181
  }
181
182
 
182
183
  /**
183
- * Find the first page in navigation (for root redirect)
184
+ * Find the first page in navigation (used to resolve empty root slug in place).
184
185
  */
185
- function findFirstPage(config: ReturnType<typeof getDocsConfig>): string {
186
+ function findFirstPage(config: DocsConfig): string {
186
187
  const navigation = config.navigation;
187
188
 
188
189
  // Helper to extract page path from a page entry
@@ -301,7 +302,7 @@ export async function generateStaticParams() {
301
302
  return !hasUnsupportedPattern;
302
303
  });
303
304
 
304
- // Include empty slug for root route (will redirect to first page)
305
+ // Include empty slug for root route (resolves to first page in-place)
305
306
  return [
306
307
  { slug: [] },
307
308
  ...supportedPaths.map((path) => ({
@@ -336,19 +337,13 @@ export async function generateMetadata({ params }: PageProps) {
336
337
  // Get content loader (ISR: R2, static: filesystem)
337
338
  const loader = getContentLoader(projectSlug ?? undefined);
338
339
 
339
- // Normalize slug: strip /docs prefix when hostAtDocs=true
340
+ // Normalize slug: strip /docs prefix when hostAtDocs=true.
341
+ // Empty root → resolve to first page (see DocPage for the full rationale).
340
342
  const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
341
-
342
- // Handle root route - redirect case, no specific metadata needed
343
- if (normalizedSlug.length === 0) {
344
- const config = await loader.getConfig();
345
- return {
346
- title: { absolute: `${config.name} - Redirecting...` },
347
- robots: { index: false }, // Don't index redirect page
348
- };
349
- }
350
-
351
- const slug = normalizedSlug;
343
+ const isRoot = normalizedSlug.length === 0;
344
+ const slug = isRoot
345
+ ? pathToSlug(findFirstPage(await loader.getConfig()))
346
+ : normalizedSlug;
352
347
  const pagePath = slug.join('/');
353
348
 
354
349
  // Fetch content and config in parallel
@@ -386,6 +381,9 @@ export async function generateMetadata({ params }: PageProps) {
386
381
  title: titleValue,
387
382
  description: data.description || '',
388
383
  ...seoMetadata,
384
+ // Root serves first-page content but canonical points at /{firstPage};
385
+ // noindex as a second dedup signal alongside the canonical tag.
386
+ ...(isRoot && { robots: { index: false, follow: true } }),
389
387
  ...(data.rss ? {
390
388
  alternates: {
391
389
  ...seoMetadata.alternates,
@@ -423,27 +421,14 @@ export default async function DocPage({ params }: PageProps) {
423
421
  // Get content loader (ISR: R2, static: filesystem)
424
422
  const loader = getContentLoader(projectSlug ?? undefined);
425
423
 
426
- // Normalize slug: strip /docs prefix when hostAtDocs=true
427
- // This ensures content is looked up correctly regardless of URL structure
428
- let slug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
429
-
430
- // Handle root route - redirect to first page (or render index directly)
431
- // Next.js treats /index as root, so we must handle 'index' specially to avoid infinite redirect
432
- if (slug.length === 0) {
433
- const config = await loader.getConfig();
434
- const firstPage = findFirstPage(config);
435
-
436
- // If first page is 'index', render it directly (don't redirect to avoid loop)
437
- if (firstPage === 'index') {
438
- slug = ['index'];
439
- } else {
440
- // When hostAtDocs=true, redirect to /docs/firstPage; otherwise /firstPage
441
- const redirectPath = hostAtDocs ? `/docs/${firstPage}` : `/${firstPage}`;
442
- redirect(redirectPath);
443
- }
444
- }
445
-
446
- // Load content and config in parallel
424
+ // Normalize slug: strip /docs prefix when hostAtDocs=true.
425
+ // Empty root renders the first page in place rather than 307'ing — Next's
426
+ // redirect() emits cache-control: private, blocking CDN caching. Canonical
427
+ // + noindex in generateMetadata prevent duplicate indexing.
428
+ const normalizedSlug = normalizeSlugForContent(resolvedParams.slug || [], hostAtDocs);
429
+ const slug = normalizedSlug.length === 0
430
+ ? pathToSlug(findFirstPage(await loader.getConfig()))
431
+ : normalizedSlug;
447
432
  const pagePath = slug.join('/');
448
433
  const [fileContents, config] = await Promise.all([
449
434
  loader.getContent(pagePath).catch(() => null),
@@ -6,7 +6,8 @@ import { ThemeProvider } from '@/components/theme/ThemeProvider';
6
6
  import { LayoutWrapper } from '@/components/layout/LayoutWrapper';
7
7
  import { CodeBlockCopyButton } from '@/components/CodeBlockCopyButton';
8
8
  import { HeaderLinkCopy } from '@/components/HeaderLinkCopy';
9
- import { FontAwesomeLoader, FA_CSS_HREF } from '@/components/FontAwesomeLoader';
9
+ import { FontAwesomeLoader } from '@/components/FontAwesomeLoader';
10
+ import { FA_CSS_HREF } from '@/lib/font-awesome';
10
11
  import { getDocsConfig } from '@/lib/docs';
11
12
  import { getDocsConfig as getIsrDocsConfig } from '@/lib/docs-isr';
12
13
  import { isIsrMode, getProjectFromRequest, getHostAtDocs } from '@/lib/page-isr-helpers';
@@ -17,6 +18,7 @@ import path from 'path';
17
18
  import type { DocsConfig, Favicon, FontConfig } from '@/lib/docs-types';
18
19
  import { transformConfigImagePath } from '@/lib/docs-types';
19
20
  import { LinkPrefixProvider } from '@/lib/link-prefix-context';
21
+ import { ProjectSlugProvider } from '@/lib/project-slug-context';
20
22
  import { getAnalyticsScript } from '@/lib/analytics-script';
21
23
  import { buildSiteTitle } from '@/lib/seo';
22
24
  import { fetchCustomCss, fetchCustomJs } from '@/lib/r2-content';
@@ -35,7 +37,7 @@ const jetbrainsMono = JetBrains_Mono({
35
37
  });
36
38
 
37
39
  /**
38
- * Get favicon path from config, applying asset prefix for /images/ paths.
40
+ * Get favicon path from config, routing it through the /_jd/ asset pipeline.
39
41
  * Returns undefined when no favicon is configured — no default Jamdesk
40
42
  * favicon is injected, so customers without a favicon get no icon.
41
43
  */
@@ -470,9 +472,14 @@ export default async function RootLayout({
470
472
  el.remove();
471
473
  });
472
474
  };
473
- // Run immediately and observe for new elements
475
+ // Run immediately and observe for new elements once body exists
474
476
  removeDevIndicators();
475
- new MutationObserver(removeDevIndicators).observe(document.body, { childList: true, subtree: true });
477
+ var observeBody = function() {
478
+ if (document.body) {
479
+ new MutationObserver(removeDevIndicators).observe(document.body, { childList: true, subtree: true });
480
+ }
481
+ };
482
+ if (document.body) { observeBody(); } else { document.addEventListener('DOMContentLoaded', observeBody, { once: true }); }
476
483
  })();
477
484
  `,
478
485
  }}
@@ -516,9 +523,11 @@ export default async function RootLayout({
516
523
  forcedTheme={appearanceStrict ? (appearanceDefault === 'system' ? undefined : appearanceDefault as 'light' | 'dark') : undefined}
517
524
  >
518
525
  <LinkPrefixProvider prefix={linkPrefix}>
519
- <LayoutWrapper config={config}>
520
- {children}
521
- </LayoutWrapper>
526
+ <ProjectSlugProvider slug={resolvedProjectSlug || ''}>
527
+ <LayoutWrapper config={config}>
528
+ {children}
529
+ </LayoutWrapper>
530
+ </ProjectSlugProvider>
522
531
  </LinkPrefixProvider>
523
532
  {/* Client components for copy buttons after hydration */}
524
533
  <CodeBlockCopyButton />
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect } from 'react';
4
+ import { FA_CSS_HREF } from '@/lib/font-awesome';
4
5
 
5
- export const FA_CSS_HREF = '/_jd/fonts/fontawesome/css/all.min.css';
6
+ export { FA_CSS_HREF };
6
7
 
7
8
  /**
8
9
  * Ensures Font Awesome CSS is loaded in the document head.
@@ -8,13 +8,15 @@ interface YouTubeProps {
8
8
  title?: string;
9
9
  /** Optional: Start time in seconds */
10
10
  start?: number;
11
+ /** Optional: Render as a vertical YouTube Short (9:16 aspect ratio) */
12
+ short?: boolean;
11
13
  }
12
14
 
13
15
  /**
14
- * Lazy-loaded YouTube video embed using lite-youtube-embed.
15
- * Video iframe only loads when user clicks play.
16
+ * YouTube video embed. Standard videos use lite-youtube-embed (lazy-loaded,
17
+ * iframe only loads on click). Shorts use a direct iframe with 9:16 aspect ratio.
16
18
  */
17
- export function YouTube({ id, title, start }: YouTubeProps): React.ReactElement | null {
19
+ export function YouTube({ id, title, start, short }: YouTubeProps): React.ReactElement | null {
18
20
  if (!id) {
19
21
  console.warn('YouTube component requires an "id" prop');
20
22
  return null;
@@ -23,6 +25,22 @@ export function YouTube({ id, title, start }: YouTubeProps): React.ReactElement
23
25
  const startSeconds = start && start > 0 ? Math.floor(start) : undefined;
24
26
  const params = startSeconds ? `rel=0&start=${startSeconds}` : 'rel=0';
25
27
 
28
+ if (short) {
29
+ const src = `https://www.youtube.com/embed/${id}?${params}`;
30
+ return (
31
+ <div className="my-6 mx-auto max-w-[360px]">
32
+ <iframe
33
+ className="w-full aspect-[9/16] rounded-xl"
34
+ src={src}
35
+ title={title || 'YouTube Short'}
36
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
37
+ allowFullScreen
38
+ loading="lazy"
39
+ />
40
+ </div>
41
+ );
42
+ }
43
+
26
44
  return (
27
45
  <div className="my-6 [&_lite-youtube]:rounded-xl [&_lite-youtube]:overflow-hidden">
28
46
  <YouTubeEmbed
@@ -8,9 +8,7 @@ import { useBodyScrollLock } from '@/hooks/useBodyScrollLock';
8
8
  import { getSuggestions } from '@/lib/search-suggestions';
9
9
  import { trackSearch } from '@/lib/analytics-client';
10
10
  import { useLinkPrefix } from '@/lib/link-prefix-context';
11
-
12
- // Build-time env var for client-side configuration
13
- const projectSlug = process.env.NEXT_PUBLIC_PROJECT_SLUG || 'default';
11
+ import { useProjectSlug } from '@/lib/project-slug-context';
14
12
 
15
13
  interface SearchResult {
16
14
  id: string;
@@ -103,6 +101,7 @@ function NoResultsState({ query, onSuggestionClick }: { query: string; onSuggest
103
101
 
104
102
  export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: SearchModalProps) {
105
103
  const linkPrefix = useLinkPrefix();
104
+ const projectSlug = useProjectSlug();
106
105
  const [query, setQuery] = useState('');
107
106
  const [results, setResults] = useState<SearchResult[]>([]);
108
107
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -142,7 +141,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
142
141
  useEffect(() => {
143
142
  if (!isOpen && lastSearchRef.current && !hasTrackedRef.current) {
144
143
  // Modal closed with an untracked search - track it now
145
- trackSearch({
144
+ trackSearch(projectSlug, {
146
145
  type: 'search_query',
147
146
  query: lastSearchRef.current.query,
148
147
  resultsCount: lastSearchRef.current.resultsCount,
@@ -153,15 +152,19 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
153
152
  hasTrackedRef.current = false;
154
153
  lastSearchRef.current = null;
155
154
  }
156
- }, [isOpen]);
155
+ }, [isOpen, projectSlug]);
157
156
 
158
- // Load search data and recent searches on mount
157
+ // Load recent searches (depends on projectSlug from context, kept separate
158
+ // so search-data init doesn't re-fetch when the slug would hypothetically change)
159
159
  useEffect(() => {
160
160
  if (isOpen) {
161
- // Load recent searches
162
161
  setRecentSearches(getRecentSearches(projectSlug));
162
+ }
163
+ }, [isOpen, projectSlug]);
163
164
 
164
- // Initialize search
165
+ // Load search data on mount
166
+ useEffect(() => {
167
+ if (isOpen) {
165
168
  setIsInitializing(true);
166
169
  setInitError(null);
167
170
 
@@ -263,7 +266,7 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
263
266
  else if (query.trim() && results.length === 0 && !isSearching && e.key === 'Enter') {
264
267
  e.preventDefault();
265
268
  // Track this as a committed search with zero results
266
- trackSearch({
269
+ trackSearch(projectSlug, {
267
270
  type: 'search_query',
268
271
  query: query.trim(),
269
272
  resultsCount: 0,
@@ -301,19 +304,19 @@ export function SearchModal({ isOpen, onClose, popularPages, onNavigate }: Searc
301
304
 
302
305
  document.addEventListener('keydown', handleKeyDown);
303
306
  return () => document.removeEventListener('keydown', handleKeyDown);
304
- }, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate]);
307
+ }, [isOpen, query, recentSearches, results, selectedIndex, effectivePopularPages, popularIndex, router, onClose, onNavigate, projectSlug]);
305
308
 
306
309
  const handleResultClick = (result: SearchResult, index: number) => {
307
310
  if (query.trim()) {
308
311
  // Track search_query event (the search itself)
309
- trackSearch({
312
+ trackSearch(projectSlug, {
310
313
  type: 'search_query',
311
314
  query: query.trim(),
312
315
  resultsCount: results.length,
313
316
  });
314
317
 
315
318
  // Track search_click event (the result they clicked)
316
- trackSearch({
319
+ trackSearch(projectSlug, {
317
320
  type: 'search_click',
318
321
  query: query.trim(),
319
322
  resultsCount: results.length,
@@ -0,0 +1,25 @@
1
+ // Auto-generated file - do not edit manually
2
+ // @ts-nocheck
3
+
4
+ import React from 'react';
5
+
6
+ // Import built-in MDX components that snippets can use
7
+ import { Note, Info, Warning, Tip, Check, Danger, Callout } from '@/components/mdx/Callouts';
8
+ import { Card } from '@/components/mdx/Card';
9
+ import { CardGroup } from '@/components/mdx/CardGroup';
10
+ import { ParamField } from '@/components/mdx/ParamField';
11
+ import { ResponseField } from '@/components/mdx/ResponseField';
12
+ import { Accordion, AccordionGroup } from '@/components/mdx/Accordion';
13
+ import { CodeGroup } from '@/components/mdx/CodeGroup';
14
+ import { Steps, Step } from '@/components/mdx/Steps';
15
+
16
+
17
+ const CodeLink = ({ title, href }: any) => {
18
+ return (
19
+ <Card title={`${title}`} href={`${href}`} horizontal icon="code">
20
+ {" "}
21
+ </Card>
22
+ );
23
+ };
24
+
25
+ export default CodeLink;
@@ -0,0 +1,44 @@
1
+ // Auto-generated file - do not edit manually
2
+ // @ts-nocheck
3
+
4
+ import React from 'react';
5
+
6
+ // Import built-in MDX components that snippets can use
7
+ import { Note, Info, Warning, Tip, Check, Danger, Callout } from '@/components/mdx/Callouts';
8
+ import { Card } from '@/components/mdx/Card';
9
+ import { CardGroup } from '@/components/mdx/CardGroup';
10
+ import { ParamField } from '@/components/mdx/ParamField';
11
+ import { ResponseField } from '@/components/mdx/ResponseField';
12
+ import { Accordion, AccordionGroup } from '@/components/mdx/Accordion';
13
+ import { CodeGroup } from '@/components/mdx/CodeGroup';
14
+ import { Steps, Step } from '@/components/mdx/Steps';
15
+
16
+
17
+ const HeaderAPI = ({ noProfileKey, profileKeyRequired }: any) => (
18
+ <>
19
+ <ParamField header="Authorization" type="string" required>
20
+ <a href="/apis/overview#authorization">API Key</a> of the Primary Profile.
21
+ <br />
22
+ <br />
23
+ Format: <code>Authorization: Bearer API_KEY</code>
24
+ </ParamField>
25
+ {!noProfileKey &&
26
+ (profileKeyRequired ? (
27
+ <ParamField header="Profile-Key" type="string" required>
28
+ <a href="/apis/overview#profile-key-format">Profile Key</a> of a User Profile.
29
+ <br />
30
+ <br />
31
+ Format: <code>Profile-Key: PROFILE_KEY</code>
32
+ </ParamField>
33
+ ) : (
34
+ <ParamField header="Profile-Key" type="string">
35
+ <a href="/apis/overview#profile-key-format">Profile Key</a> of a User Profile.
36
+ <br />
37
+ <br />
38
+ Format: <code>Profile-Key: PROFILE_KEY</code>
39
+ </ParamField>
40
+ ))}
41
+ </>
42
+ );
43
+
44
+ export default HeaderAPI;
@@ -0,0 +1,53 @@
1
+ // Auto-generated file - do not edit manually
2
+ // @ts-nocheck
3
+
4
+ import React from 'react';
5
+
6
+ // Import built-in MDX components that snippets can use
7
+ import { Note, Info, Warning, Tip, Check, Danger, Callout } from '@/components/mdx/Callouts';
8
+ import { Card } from '@/components/mdx/Card';
9
+ import { CardGroup } from '@/components/mdx/CardGroup';
10
+ import { ParamField } from '@/components/mdx/ParamField';
11
+ import { ResponseField } from '@/components/mdx/ResponseField';
12
+ import { Accordion, AccordionGroup } from '@/components/mdx/Accordion';
13
+ import { CodeGroup } from '@/components/mdx/CodeGroup';
14
+ import { Steps, Step } from '@/components/mdx/Steps';
15
+
16
+
17
+ const PlansAvailable = ({ plans, maxPackRequired }: any) => {
18
+ let displayPlans = plans;
19
+
20
+ if (plans.length === 1) {
21
+ const lowerCasePlan = plans[0].toLowerCase();
22
+ if (lowerCasePlan === "basic") {
23
+ displayPlans = ["Basic", "Premium", "Business", "Enterprise"];
24
+ } else if (lowerCasePlan === "business") {
25
+ displayPlans = ["Business", "Enterprise"];
26
+ } else if (lowerCasePlan === "premium") {
27
+ displayPlans = ["Premium", "Business", "Enterprise"];
28
+ }
29
+ }
30
+
31
+ return (
32
+
33
+ <Note>
34
+ Available on {displayPlans.length === 1 ? "the " : ""}
35
+ {displayPlans.join(", ").replace(/\b\w/g, (l) => l.toUpperCase())}{" "}
36
+ {displayPlans.length > 1 ? "plans" : "plan"}.
37
+
38
+ {maxPackRequired && (
39
+
40
+ <a href="https://www.acme.com/docs/additional/maxpack"
41
+ className="flex items-center mt-2 cursor-pointer"
42
+ >
43
+ <span className="px-1.5 py-0.5 rounded text-sm" style={{backgroundColor: '#C264B6', color: 'white', fontSize: '12px'}}>
44
+ Max Pack required
45
+ </span>
46
+ </a>
47
+ )}
48
+ </Note>
49
+
50
+ );
51
+ };
52
+
53
+ export default PlansAvailable;
@@ -1,11 +1,9 @@
1
- // Search Analytics Client
2
- // Tracks search queries and clicks for analytics dashboard
1
+ // Endpoint is /_jd/search-ev (not /api/search-ev) so sites fronted by the
2
+ // jamdesk.com Cloudflare Worker which only proxies /_jd/* — reach the ISR
3
+ // app. Middleware rewrites /_jd/search-ev to /api/search-ev.
3
4
 
4
- // Firebase Function URL for search analytics
5
- const ANALYTICS_URL = '/api/search-ev';
5
+ const ANALYTICS_URL = '/_jd/search-ev';
6
6
 
7
- // Reuse the same session ID as the pageview tracking script (localStorage with 30-min inactivity timeout).
8
- // Every call refreshes the timestamp, so active users keep the same session.
9
7
  const SESSION_TIMEOUT_MS = 1800000; // 30 minutes
10
8
 
11
9
  function getSessionId(): string {
@@ -16,7 +14,6 @@ function getSessionId(): string {
16
14
  const sessionTs = localStorage.getItem('jamdesk_session_ts');
17
15
 
18
16
  if (!sessionId || !sessionTs || (now - parseInt(sessionTs)) >= SESSION_TIMEOUT_MS) {
19
- // Match the tracking script's format: 16 random bytes as hex + timestamp in base36
20
17
  const bytes = new Uint8Array(16);
21
18
  crypto.getRandomValues(bytes);
22
19
  sessionId = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +
@@ -27,7 +24,6 @@ function getSessionId(): string {
27
24
  return sessionId;
28
25
  }
29
26
 
30
- // Use separate event types to avoid double-counting
31
27
  interface SearchQueryEvent {
32
28
  type: 'search_query';
33
29
  query: string;
@@ -48,22 +44,14 @@ interface SearchClickEvent {
48
44
  type SearchEvent = SearchQueryEvent | SearchClickEvent;
49
45
 
50
46
  /**
51
- * Track a search analytics event.
52
- *
53
- * Note: This function returns immediately without waiting for the request to complete.
54
- * The fetch request runs in the background with `keepalive: true` to survive page navigation.
55
- * Errors are silently ignored since analytics should never break the user experience.
47
+ * Fire-and-forget. The slug must be supplied at runtime (via `useProjectSlug()`)
48
+ * because `NEXT_PUBLIC_*` vars are baked per-build and the ISR app is multi-tenant.
56
49
  */
57
- export function trackSearch(event: SearchEvent): void {
58
- // Don't track in development
50
+ export function trackSearch(projectSlug: string, event: SearchEvent): void {
59
51
  if (process.env.NODE_ENV === 'development') return;
60
-
61
- const projectSlug = process.env.NEXT_PUBLIC_PROJECT_SLUG;
62
52
  if (!projectSlug) return;
63
53
 
64
54
  try {
65
- // Use fetch with keepalive for non-blocking analytics
66
- // Note: sendBeacon with application/json triggers CORS preflight which can fail silently
67
55
  const payload = JSON.stringify({
68
56
  projectSlug,
69
57
  sessionId: getSessionId(),
@@ -71,17 +59,11 @@ export function trackSearch(event: SearchEvent): void {
71
59
  timestamp: Date.now(),
72
60
  });
73
61
 
74
- // fetch with keepalive allows request to complete even if page navigates
75
- // This is more reliable than sendBeacon for JSON payloads
76
62
  fetch(ANALYTICS_URL, {
77
63
  method: 'POST',
78
64
  headers: { 'Content-Type': 'application/json' },
79
65
  body: payload,
80
66
  keepalive: true,
81
- }).catch(() => {
82
- // Silent fail - analytics should never break the app
83
- });
84
- } catch {
85
- // Silent fail - analytics should never break the app
86
- }
67
+ }).catch(() => {});
68
+ } catch {}
87
69
  }
@@ -741,6 +741,14 @@ export interface SpellcheckConfig {
741
741
  ignore?: string[];
742
742
  }
743
743
 
744
+ /**
745
+ * Image optimization configuration
746
+ */
747
+ export interface ImagesConfig {
748
+ /** Automatically convert PNG/JPG images to WebP format during build (default: false) */
749
+ convertToWebp?: boolean;
750
+ }
751
+
744
752
  // =============================================================================
745
753
  // MAIN DOCS CONFIG
746
754
  // =============================================================================
@@ -807,6 +815,7 @@ export interface DocsConfig {
807
815
  analytics?: AnalyticsConfig;
808
816
  chat?: ChatConfig;
809
817
  spellcheck?: SpellcheckConfig;
818
+ images?: ImagesConfig;
810
819
 
811
820
  // Mintlify compatibility fields (normalized at load time)
812
821
  modeToggle?: ModeToggleConfig;
@@ -872,7 +881,7 @@ export function appendAssetVersion(url: string, assetVersion?: string): string {
872
881
  }
873
882
 
874
883
  /**
875
- * Transform an image path from /images/... to /_jd/images/...
884
+ * Transform a local asset path to /_jd/... for routing through the asset pipeline.
876
885
  *
877
886
  * Used for config-defined images (favicon, logo) that need the asset prefix.
878
887
  * Also normalizes relative paths and strips /public/ prefix (Next.js convention).
@@ -882,23 +891,22 @@ export function transformConfigImagePath(
882
891
  assetVersion?: string,
883
892
  ): string | undefined {
884
893
  if (!path) return path;
885
- // Skip external URLs
886
- if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
894
+ if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//') || path.startsWith('data:')) {
887
895
  return path;
888
896
  }
889
- // Ensure absolute path — relative paths break on nested routes
897
+ // Relative paths break on nested routes
890
898
  let normalized = path.startsWith('/') ? path : `/${path}`;
891
899
  // Strip /public/ prefix (Next.js convention: public/logo.png served at /logo.png)
892
900
  normalized = normalized.replace(/^\/public\//, '/');
893
- if (normalized.startsWith('/images/')) {
894
- return appendAssetVersion(ASSET_PREFIX + normalized, assetVersion);
901
+ if (normalized.startsWith(ASSET_PREFIX + '/')) {
902
+ return normalized;
895
903
  }
896
- return normalized;
904
+ return appendAssetVersion(ASSET_PREFIX + normalized, assetVersion);
897
905
  }
898
906
 
899
907
  /**
900
908
  * Normalize logo config to consistent object format.
901
- * Also transforms /images/... paths to /_jd/images/...
909
+ * Also transforms local paths to /_jd/... via transformConfigImagePath.
902
910
  */
903
911
  export function normalizeLogo(logo: Logo | undefined, assetVersion?: string): LogoConfig | null {
904
912
  if (!logo) return null;
@@ -100,8 +100,11 @@ function formatPhase(phase?: string): string {
100
100
  validate: 'Configuration Validation',
101
101
  copy_content: 'Content Preparation',
102
102
  nextjs_build: 'Next.js Build',
103
+ optimize_images: 'Image Optimization',
103
104
  r2_upload: 'CDN Upload',
105
+ embeddings: 'AI Search Indexing',
104
106
  vercel_purge: 'Cache Refresh',
107
+ cleanup: 'Cleanup',
105
108
  };
106
109
  return phaseLabels[phase] || phase;
107
110
  }
@@ -16,6 +16,7 @@ export interface ExtractedHighlights {
16
16
  redirectCount: number;
17
17
  pageCount: number;
18
18
  hasOpenApi: boolean;
19
+ imageOptimization: boolean;
19
20
  lastUpdatedAt: number;
20
21
  }
21
22
 
@@ -121,6 +122,7 @@ export function extractConfigHighlights(config: DocsConfig, pageCount: number =
121
122
  redirectCount: config.redirects?.length ?? 0,
122
123
  pageCount,
123
124
  hasOpenApi: hasOpenApiConfigured(config),
125
+ imageOptimization: config.images?.convertToWebp === true,
124
126
  lastUpdatedAt: Date.now(),
125
127
  };
126
128
  }
@@ -0,0 +1,2 @@
1
+ /** Path to Font Awesome CSS — shared between server layout and client loader. */
2
+ export const FA_CSS_HREF = '/_jd/fonts/fontawesome/css/all.min.css';
@@ -60,6 +60,9 @@ export async function submitToIndexNow(
60
60
  }
61
61
 
62
62
  const submitted = Math.min(urls.length, MAX_BATCH_SIZE);
63
+ // keyLocation omitted — IndexNow requires it at the root level of the host,
64
+ // and our key verification route is at /api/indexnow/{key} (a subdirectory).
65
+ // When omitted, IndexNow defaults to /{key}.txt which it verifies asynchronously.
63
66
  const res = await fetch(INDEXNOW_ENDPOINT, {
64
67
  method: 'POST',
65
68
  headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -67,7 +70,6 @@ export async function submitToIndexNow(
67
70
  body: JSON.stringify({
68
71
  host,
69
72
  key,
70
- keyLocation: `https://${host}/api/indexnow/${key}`,
71
73
  urlList: urls.slice(0, MAX_BATCH_SIZE),
72
74
  }),
73
75
  });
@@ -205,7 +205,10 @@ export const ISR_PHASES = {
205
205
  copy_content: { label: 'Preparing content...', weight: 10 },
206
206
  // ISR mode: nextjs_build is instant (pages compiled on-demand by Vercel)
207
207
  nextjs_build: { label: 'Building documentation...', weight: 5 },
208
- r2_upload: { label: 'Uploading to CDN...', weight: 40 },
208
+ // Only runs when docs.json `images.convertToWebp` is enabled; the dashboard
209
+ // auto-marks this row skipped when no timing entry is recorded.
210
+ optimize_images: { label: 'Optimizing images...', weight: 5 },
211
+ r2_upload: { label: 'Uploading to CDN...', weight: 35 },
209
212
  embeddings: { label: 'Indexing AI search + chat...', weight: 5 },
210
213
  vercel_purge: { label: 'Refreshing cache...', weight: 20 },
211
214
  cleanup: { label: 'Cleaning up...', weight: 5 },
@@ -61,6 +61,15 @@ export function normalizeSlugForContent(slug: string[], hostAtDocs: boolean): st
61
61
  return slug;
62
62
  }
63
63
 
64
+ /**
65
+ * Split a page path (e.g. `"get-started/introduction"`) into the slug array
66
+ * Next.js expects. Collapses leading/trailing/repeated slashes so it accepts
67
+ * every shape `findFirstPage()` may return.
68
+ */
69
+ export function pathToSlug(path: string): string[] {
70
+ return path.split('/').filter(Boolean);
71
+ }
72
+
64
73
  /**
65
74
  * Get generateStaticParams result for ISR mode.
66
75
  *