jamdesk 1.0.19 → 1.0.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 (47) hide show
  1. package/dist/__tests__/unit/openapi.test.js +12 -0
  2. package/dist/__tests__/unit/openapi.test.js.map +1 -1
  3. package/dist/commands/openapi-check.d.ts.map +1 -1
  4. package/dist/commands/openapi-check.js +7 -13
  5. package/dist/commands/openapi-check.js.map +1 -1
  6. package/dist/index.js +2 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/deps.js +1 -1
  9. package/dist/lib/openapi/errors.d.ts.map +1 -1
  10. package/dist/lib/openapi/errors.js +17 -0
  11. package/dist/lib/openapi/errors.js.map +1 -1
  12. package/dist/lib/openapi/validator.d.ts +2 -2
  13. package/dist/lib/openapi/validator.d.ts.map +1 -1
  14. package/dist/lib/openapi/validator.js +52 -15
  15. package/dist/lib/openapi/validator.js.map +1 -1
  16. package/package.json +2 -2
  17. package/vendored/app/[[...slug]]/page.tsx +105 -28
  18. package/vendored/app/api/ev/route.ts +4 -1
  19. package/vendored/app/api/indexnow/[key]/route.ts +34 -0
  20. package/vendored/app/api/search-ev/route.ts +69 -0
  21. package/vendored/app/layout.tsx +70 -12
  22. package/vendored/components/chat/ChatInput.tsx +1 -1
  23. package/vendored/components/chat/ChatPanel.tsx +60 -11
  24. package/vendored/components/mdx/Update.tsx +7 -7
  25. package/vendored/components/navigation/Sidebar.tsx +2 -10
  26. package/vendored/components/navigation/TableOfContents.tsx +48 -35
  27. package/vendored/components/search/SearchModal.tsx +9 -5
  28. package/vendored/components/ui/CodePanelModal.tsx +5 -14
  29. package/vendored/hooks/useBodyScrollLock.ts +37 -0
  30. package/vendored/lib/analytics-client.ts +17 -7
  31. package/vendored/lib/docs-types.ts +3 -2
  32. package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
  33. package/vendored/lib/extract-highlights.ts +2 -0
  34. package/vendored/lib/heading-extractor.ts +2 -2
  35. package/vendored/lib/indexnow.ts +77 -0
  36. package/vendored/lib/json-ld.ts +171 -0
  37. package/vendored/lib/middleware-helpers.ts +2 -0
  38. package/vendored/lib/openapi/errors.ts +21 -1
  39. package/vendored/lib/openapi/validator.ts +70 -23
  40. package/vendored/lib/route-helpers.ts +7 -2
  41. package/vendored/lib/search-client.ts +81 -36
  42. package/vendored/lib/seo.ts +77 -0
  43. package/vendored/lib/static-artifacts.ts +204 -5
  44. package/vendored/lib/static-file-route.ts +10 -5
  45. package/vendored/lib/validate-config.ts +1 -0
  46. package/vendored/schema/docs-schema.json +130 -8
  47. package/vendored/scripts/validate-links.cjs +1 -1
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+
5
+ let lockCount = 0;
6
+ let savedOverflow = '';
7
+ let savedPaddingRight = '';
8
+
9
+ /**
10
+ * Lock body scroll when `active` is true. Multiple concurrent locks
11
+ * are ref-counted — body scroll is only restored when all locks release.
12
+ * Compensates for scrollbar removal to prevent layout shift.
13
+ */
14
+ export function useBodyScrollLock(active: boolean): void {
15
+ useEffect(() => {
16
+ if (!active) return;
17
+
18
+ if (lockCount === 0) {
19
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
20
+ savedOverflow = document.body.style.overflow;
21
+ savedPaddingRight = document.body.style.paddingRight;
22
+ document.body.style.overflow = 'hidden';
23
+ if (scrollbarWidth > 0) {
24
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
25
+ }
26
+ }
27
+ lockCount++;
28
+
29
+ return () => {
30
+ lockCount--;
31
+ if (lockCount === 0) {
32
+ document.body.style.overflow = savedOverflow;
33
+ document.body.style.paddingRight = savedPaddingRight;
34
+ }
35
+ };
36
+ }, [active]);
37
+ }
@@ -2,17 +2,28 @@
2
2
  // Tracks search queries and clicks for analytics dashboard
3
3
 
4
4
  // Firebase Function URL for search analytics
5
- const ANALYTICS_URL = 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackSearchAnalytics';
5
+ const ANALYTICS_URL = '/api/search-ev';
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
+ const SESSION_TIMEOUT_MS = 1800000; // 30 minutes
6
10
 
7
- // Session ID persists for the browser session
8
11
  function getSessionId(): string {
9
12
  if (typeof window === 'undefined') return 'ssr';
10
13
 
11
- let sessionId = sessionStorage.getItem('jd_session_id');
12
- if (!sessionId) {
13
- sessionId = `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
14
- sessionStorage.setItem('jd_session_id', sessionId);
14
+ const now = Date.now();
15
+ let sessionId = localStorage.getItem('jamdesk_session_id');
16
+ const sessionTs = localStorage.getItem('jamdesk_session_ts');
17
+
18
+ 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
+ const bytes = new Uint8Array(16);
21
+ crypto.getRandomValues(bytes);
22
+ sessionId = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('') +
23
+ now.toString(36);
24
+ localStorage.setItem('jamdesk_session_id', sessionId);
15
25
  }
26
+ localStorage.setItem('jamdesk_session_ts', now.toString());
16
27
  return sessionId;
17
28
  }
18
29
 
@@ -67,7 +78,6 @@ export function trackSearch(event: SearchEvent): void {
67
78
  headers: { 'Content-Type': 'application/json' },
68
79
  body: payload,
69
80
  keepalive: true,
70
- mode: 'cors',
71
81
  }).catch(() => {
72
82
  // Silent fail - analytics should never break the app
73
83
  });
@@ -467,6 +467,7 @@ export interface PageFrontmatter {
467
467
  api?: string;
468
468
  openapi?: string;
469
469
  hideFooter?: boolean;
470
+ rss?: boolean;
470
471
  }
471
472
 
472
473
  // =============================================================================
@@ -594,7 +595,7 @@ export interface IntegrationsConfig {
594
595
  osano?: { scriptSource: string };
595
596
  pirsch?: { id: string };
596
597
  posthog?: { apiKey: string; apiHost?: string };
597
- plausible?: { domain: string; server?: string };
598
+ plausible?: { domain?: string; server?: string; scriptUrl?: string };
598
599
  segment?: { key: string };
599
600
  telemetry?: { enabled: boolean };
600
601
  cookies?: { key?: string; value?: string };
@@ -621,7 +622,7 @@ export interface SearchConfig {
621
622
  * AI Chat configuration
622
623
  */
623
624
  export interface ChatConfig {
624
- /** Enable AI chat assistant (default: false) */
625
+ /** Enable AI chat assistant (default: true) */
625
626
  enabled?: boolean;
626
627
  /** Starter questions shown in empty state (max 4). Auto-generated by Haiku during builds when omitted. Set to [] to disable. */
627
628
  starterQuestions?: string[];
@@ -24,16 +24,16 @@ const darkModeStyles = `
24
24
  @media (prefers-color-scheme: dark) {
25
25
  .email-logo-light { display: none !important; }
26
26
  .email-logo-dark { display: block !important; }
27
- .email-body { background-color: #0A1628 !important; }
28
- .email-container { background-color: #111827 !important; }
29
- .email-content { background-color: #111827 !important; }
30
- .email-footer { background-color: #111827 !important; border-color: #374151 !important; }
31
- .email-title { color: #F9FAFB !important; }
32
- .email-paragraph { color: #E5E7EB !important; }
33
- .email-footer-text { color: #9CA3AF !important; }
27
+ .email-body { background-color: #181210 !important; }
28
+ .email-container { background-color: #2c2420 !important; }
29
+ .email-content { background-color: #2c2420 !important; }
30
+ .email-footer { background-color: #2c2420 !important; border-color: #3a302a !important; }
31
+ .email-title { color: #f0ebe5 !important; }
32
+ .email-paragraph { color: #c4b8ac !important; }
33
+ .email-footer-text { color: #9a8e82 !important; }
34
34
 
35
- /* Prevent white text inversion on indigo elements */
36
- .email-button { color: #ffffff !important; background-color: #635BFF !important; }
35
+ /* Prevent white text inversion on accent elements */
36
+ .email-button { color: #ffffff !important; background-color: #ff3621 !important; }
37
37
 
38
38
  /* Error box */
39
39
  .email-error-box { background-color: #450a0a !important; border-color: #dc2626 !important; }
@@ -52,13 +52,13 @@ export interface BaseLayoutProps {
52
52
 
53
53
  // Muted dashboard style guide colors
54
54
  export const colors = {
55
- primary: '#635BFF', // Indigo
56
- primaryMuted: '#9966FF', // Purple accent
57
- background: '#F6F9FC', // bg-primary
55
+ primary: '#ff3621', // Warm red
56
+ primaryMuted: '#ff3621', // Warm red
57
+ background: '#faf8f5', // bg-primary
58
58
  cardBg: '#ffffff', // bg-secondary
59
- textPrimary: '#0A2540', // primary-navy
60
- textSecondary: '#425466', // text-secondary
61
- border: '#E3E8EE', // border-primary
59
+ textPrimary: '#1b3139', // primary-navy
60
+ textSecondary: '#5a6f77', // text-secondary
61
+ border: '#e8e4df', // border-primary
62
62
  // Error colors (used by ErrorBox component)
63
63
  errorBg: '#FEF2F2',
64
64
  errorBorder: '#EF4444',
@@ -11,6 +11,7 @@ export interface ExtractedHighlights {
11
11
  seoIndexable: boolean;
12
12
  analyticsIntegrations: string[];
13
13
  apiPlaygroundType: 'interactive' | 'simple' | 'hidden' | null;
14
+ chatEnabled: boolean;
14
15
  languageCount: number;
15
16
  redirectCount: number;
16
17
  pageCount: number;
@@ -115,6 +116,7 @@ export function extractConfigHighlights(config: DocsConfig, pageCount: number =
115
116
  seoIndexable,
116
117
  analyticsIntegrations,
117
118
  apiPlaygroundType,
119
+ chatEnabled: config.chat?.enabled !== false,
118
120
  languageCount: countLanguages(config),
119
121
  redirectCount: config.redirects?.length ?? 0,
120
122
  pageCount,
@@ -2,7 +2,7 @@
2
2
  * Heading extractor for link validation.
3
3
  * Extracts heading slugs from MDX content to validate #fragment links.
4
4
  *
5
- * Uses the same slug generation as TableOfContents.tsx (client-side DOM IDs).
5
+ * Canonical source for generateSlug imported by TableOfContents.tsx and Update.tsx.
6
6
  */
7
7
 
8
8
  export interface HeadingInfo {
@@ -14,7 +14,7 @@ export interface HeadingInfo {
14
14
 
15
15
  /**
16
16
  * Generate a URL-friendly slug from heading text.
17
- * Must stay in sync with TableOfContents.tsx:207-209.
17
+ * Canonical implementation — also duplicated in validate-links.cjs (CJS, can't import).
18
18
  */
19
19
  export function generateSlug(text: string): string {
20
20
  return text
@@ -0,0 +1,77 @@
1
+ /**
2
+ * IndexNow URL Submission for Documentation Sites
3
+ *
4
+ * Notifies search engines about changed pages after builds.
5
+ * Modeled after marketing/lib/indexnow.ts but parameterized for
6
+ * multi-tenant use (dynamic host/key per project).
7
+ */
8
+
9
+ import { createHash } from 'crypto';
10
+
11
+ const INDEXNOW_ENDPOINT = 'https://api.indexnow.org/indexnow';
12
+ const FETCH_TIMEOUT_MS = 5_000;
13
+ const MAX_BATCH_SIZE = 10_000;
14
+ const KEY_SALT = 'jamdesk-indexnow-v1';
15
+
16
+ export interface IndexNowResult {
17
+ success: boolean;
18
+ status: number;
19
+ submitted: number;
20
+ }
21
+
22
+ /** IndexNow accepts both 200 (OK) and 202 (Accepted) as success. */
23
+ function isSuccessStatus(status: number): boolean {
24
+ return status === 200 || status === 202;
25
+ }
26
+
27
+ /** Deterministic IndexNow key for a project (32-char hex). */
28
+ export function generateIndexNowKey(projectSlug: string): string {
29
+ return createHash('md5').update(`${KEY_SALT}:${projectSlug}`).digest('hex');
30
+ }
31
+
32
+ /** Build full URLs from changed content paths. */
33
+ export function buildChangedUrls(
34
+ changedPaths: string[], baseUrl: string, hostAtDocs: boolean
35
+ ): string[] {
36
+ if (changedPaths.length === 0) return [];
37
+ const prefix = hostAtDocs ? '/docs' : '';
38
+ return changedPaths.map((p) => {
39
+ // Defensive: getChangedPaths() already strips extensions, but guard against raw file paths
40
+ const clean = p.replace(/\.mdx?$/, '');
41
+ return `${baseUrl}${prefix}/${clean}`;
42
+ });
43
+ }
44
+
45
+ /** Submit URLs to IndexNow. Single URL uses GET, multiple uses batch POST. */
46
+ export async function submitToIndexNow(
47
+ urls: string[], host: string, key: string
48
+ ): Promise<IndexNowResult> {
49
+ if (urls.length === 0) {
50
+ return { success: true, status: 200, submitted: 0 };
51
+ }
52
+
53
+ const timeoutSignal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
54
+
55
+ if (urls.length === 1) {
56
+ const params = new URLSearchParams({ url: urls[0], key });
57
+ const res = await fetch(`${INDEXNOW_ENDPOINT}?${params}`, {
58
+ signal: timeoutSignal,
59
+ });
60
+ return { success: isSuccessStatus(res.status), status: res.status, submitted: 1 };
61
+ }
62
+
63
+ const submitted = Math.min(urls.length, MAX_BATCH_SIZE);
64
+ const res = await fetch(INDEXNOW_ENDPOINT, {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
67
+ signal: timeoutSignal,
68
+ body: JSON.stringify({
69
+ host,
70
+ key,
71
+ keyLocation: `https://${host}/api/indexnow/${key}`,
72
+ urlList: urls.slice(0, MAX_BATCH_SIZE),
73
+ }),
74
+ });
75
+
76
+ return { success: isSuccessStatus(res.status), status: res.status, submitted };
77
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * JSON-LD Structured Data for Documentation Pages
3
+ *
4
+ * Generates WebSite + BreadcrumbList schemas for search engine rich results.
5
+ * Rendered as <script type="application/ld+json"> in page.tsx.
6
+ *
7
+ * Breadcrumb path extraction is server-side only (pure functions, no hooks).
8
+ * Intentionally separate from Breadcrumb.tsx which is a 'use client' component.
9
+ */
10
+
11
+ import type { DocsConfig, NavigationPage, GroupConfig } from './docs-types';
12
+ import { normalizeNavPage } from './docs-types';
13
+
14
+ interface BreadcrumbEntry {
15
+ name: string;
16
+ url: string;
17
+ }
18
+
19
+ export interface JsonLdOptions {
20
+ config: DocsConfig;
21
+ pagePath: string;
22
+ pageTitle: string;
23
+ baseUrl: string;
24
+ }
25
+
26
+ export interface JsonLdGraph {
27
+ '@context': string;
28
+ '@graph': Array<Record<string, unknown>>;
29
+ }
30
+
31
+ function formatSlugAsTitle(slug: string): string {
32
+ const lastSegment = slug.split('/').pop()!;
33
+ return lastSegment
34
+ .replace(/-/g, ' ')
35
+ .replace(/\b\w/g, (l) => l.toUpperCase());
36
+ }
37
+
38
+ /**
39
+ * Find the first leaf page path in a list of navigation items.
40
+ * Recurses into nested groups until it finds a non-group page.
41
+ */
42
+ function getFirstPage(pages: (NavigationPage | GroupConfig)[]): string | null {
43
+ for (const item of pages) {
44
+ if (typeof item === 'object' && 'group' in item) {
45
+ if (item.pages) {
46
+ const found = getFirstPage(item.pages);
47
+ if (found) return found;
48
+ }
49
+ } else {
50
+ return normalizeNavPage(item).path;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Build a group's URL from its first page, falling back to baseUrl. */
57
+ function buildGroupUrl(group: GroupConfig, baseUrl: string): string {
58
+ const firstPage = group.pages ? getFirstPage(group.pages) : null;
59
+ return firstPage ? `${baseUrl}/${firstPage}` : baseUrl;
60
+ }
61
+
62
+ /**
63
+ * Find the breadcrumb path for a page by walking the navigation tree.
64
+ * Returns [{name, url}] from Home -> group(s) -> current page.
65
+ */
66
+ export function findBreadcrumbPath(
67
+ config: DocsConfig, targetSlug: string, baseUrl: string, pageTitle?: string
68
+ ): BreadcrumbEntry[] {
69
+ const nav = config.navigation;
70
+ const home: BreadcrumbEntry = { name: 'Home', url: baseUrl };
71
+ const pageEntry: BreadcrumbEntry = {
72
+ name: pageTitle || formatSlugAsTitle(targetSlug),
73
+ url: `${baseUrl}/${targetSlug}`,
74
+ };
75
+
76
+ function searchPages(
77
+ pages: (NavigationPage | GroupConfig)[], trail: BreadcrumbEntry[]
78
+ ): BreadcrumbEntry[] | null {
79
+ for (const item of pages) {
80
+ if (typeof item === 'object' && 'group' in item) {
81
+ const groupUrl = buildGroupUrl(item, baseUrl);
82
+ const next = [...trail, { name: item.group, url: groupUrl }];
83
+ if (item.pages) {
84
+ const found = searchPages(item.pages, next);
85
+ if (found) return found;
86
+ }
87
+ } else {
88
+ if (normalizeNavPage(item).path === targetSlug) return trail;
89
+ }
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function searchGroups(groups: GroupConfig[]): BreadcrumbEntry[] | null {
95
+ for (const group of groups) {
96
+ const groupUrl = buildGroupUrl(group, baseUrl);
97
+ const trail = [{ name: group.group, url: groupUrl }];
98
+ if (group.pages) {
99
+ const found = searchPages(group.pages, trail);
100
+ if (found) return found;
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ // Search all navigation structures (mirrors Breadcrumb.tsx order)
107
+ let trail: BreadcrumbEntry[] | null = null;
108
+
109
+ if (!trail && nav.languages) {
110
+ for (const lang of nav.languages) {
111
+ if (!lang.tabs) continue;
112
+ for (const tab of lang.tabs) {
113
+ if (tab.groups) trail = searchGroups(tab.groups);
114
+ if (trail) break;
115
+ }
116
+ if (trail) break;
117
+ }
118
+ }
119
+ if (!trail && nav.anchors) {
120
+ for (const anchor of nav.anchors) {
121
+ if (anchor.groups) trail = searchGroups(anchor.groups);
122
+ if (trail) break;
123
+ }
124
+ }
125
+ if (!trail && nav.tabs) {
126
+ for (const tab of nav.tabs) {
127
+ if (tab.groups) trail = searchGroups(tab.groups);
128
+ if (trail) break;
129
+ }
130
+ }
131
+ if (!trail && nav.groups) {
132
+ trail = searchGroups(nav.groups);
133
+ }
134
+
135
+ // Deduplicate: remove group entries whose URL matches the next entry.
136
+ // Groups link to their first page, which may be the current page itself.
137
+ const result = [home, ...(trail || []), pageEntry];
138
+ return result.filter((entry, i) =>
139
+ i === result.length - 1 || entry.url !== result[i + 1]?.url
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Build JSON-LD structured data for a documentation page.
145
+ * Returns a schema.org @graph with WebSite + BreadcrumbList.
146
+ */
147
+ export function buildJsonLd(options: JsonLdOptions): JsonLdGraph {
148
+ const { config, pagePath, pageTitle, baseUrl } = options;
149
+ const breadcrumbs = findBreadcrumbPath(config, pagePath, baseUrl, pageTitle);
150
+
151
+ return {
152
+ '@context': 'https://schema.org',
153
+ '@graph': [
154
+ {
155
+ '@type': 'WebSite',
156
+ name: config.name,
157
+ url: baseUrl,
158
+ ...(config.description && { description: config.description }),
159
+ },
160
+ {
161
+ '@type': 'BreadcrumbList',
162
+ itemListElement: breadcrumbs.map((item, i) => ({
163
+ '@type': 'ListItem',
164
+ position: i + 1,
165
+ name: item.name,
166
+ item: item.url,
167
+ })),
168
+ },
169
+ ],
170
+ };
171
+ }
@@ -286,12 +286,14 @@ export async function checkRedirects(
286
286
  export const INTERNAL_API_ROUTES = [
287
287
  '/api/assets', // Asset serving from R2 (app/api/assets/[...path])
288
288
  '/api/ev', // Analytics events (app/api/ev)
289
+ '/api/indexnow', // IndexNow key verification (app/api/indexnow/[key])
289
290
  '/api/isr-health', // Health check endpoint (app/api/isr-health)
290
291
  '/api/chat', // Chat endpoint (app/api/chat/[project])
291
292
  '/api/mcp', // MCP endpoint (app/api/mcp/[project])
292
293
  '/api/og', // OG image generation (app/api/og)
293
294
  '/api/r2', // R2 content serving (app/api/r2/[project]/[...path])
294
295
  '/api/revalidate', // Cache revalidation (app/api/revalidate)
296
+ '/api/search-ev', // Search analytics proxy (app/api/search-ev)
295
297
  ];
296
298
 
297
299
  /**
@@ -81,6 +81,26 @@ export function formatOpenApiError(
81
81
  };
82
82
  }
83
83
 
84
+ // Network/fetch error (URL validation) — check before "invalid" pattern
85
+ // since URLs may contain "invalid" in the domain name
86
+ if (
87
+ message.includes('ENOTFOUND') ||
88
+ message.includes('ECONNREFUSED') ||
89
+ message.includes('getaddrinfo') ||
90
+ message.includes('ETIMEDOUT') ||
91
+ message.includes('request to') ||
92
+ message.includes('fetch failed') ||
93
+ message.includes('Error downloading')
94
+ ) {
95
+ return {
96
+ type: 'validation_error',
97
+ specPath,
98
+ message: `Failed to fetch OpenAPI specification: ${specPath}`,
99
+ suggestion: 'Check that the URL is correct and the server is accessible.',
100
+ details: message,
101
+ };
102
+ }
103
+
84
104
  // Invalid type or value
85
105
  if (message.includes('type') || message.includes('must be') || message.includes('invalid')) {
86
106
  return {
@@ -91,7 +111,7 @@ export function formatOpenApiError(
91
111
  details: message,
92
112
  };
93
113
  }
94
-
114
+
95
115
  // Generic validation error
96
116
  return {
97
117
  type: 'validation_error',
@@ -9,7 +9,7 @@ import SwaggerParser from '@apidevtools/swagger-parser';
9
9
  import fs from 'fs';
10
10
  import path from 'path';
11
11
  import type { OpenAPI } from 'openapi-types';
12
- import type { ValidationResult } from './types';
12
+ import type { ValidationResult, OpenApiValidationError } from './types';
13
13
  import { formatOpenApiError, createFileNotFoundError } from './errors';
14
14
 
15
15
  /**
@@ -32,12 +32,49 @@ function detectVersion(api: OpenAPI.Document): '2.0' | '3.0' | '3.1' {
32
32
  }
33
33
 
34
34
  /**
35
- * Validate an OpenAPI specification file
36
- *
35
+ * Check for duplicate operationIds across all endpoints.
36
+ * SwaggerParser.validate() does not catch these, but they break
37
+ * docs navigation and routing.
38
+ */
39
+ function checkDuplicateOperationIds(
40
+ api: OpenAPI.Document,
41
+ specPath: string
42
+ ): OpenApiValidationError | null {
43
+ const seen = new Map<string, string>(); // operationId → "METHOD /path"
44
+ const paths = (api as any).paths || {};
45
+ const methods = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options'];
46
+
47
+ for (const [pathStr, pathItem] of Object.entries(paths)) {
48
+ for (const method of methods) {
49
+ const operation = (pathItem as Record<string, any>)?.[method];
50
+ if (!operation?.operationId) continue;
51
+
52
+ const id = operation.operationId;
53
+ const endpoint = `${method.toUpperCase()} ${pathStr}`;
54
+
55
+ if (seen.has(id)) {
56
+ return {
57
+ type: 'validation_error',
58
+ specPath,
59
+ message: `Duplicate operationId "${id}" in OpenAPI specification: ${specPath}`,
60
+ suggestion: `operationId "${id}" is used by both ${seen.get(id)} and ${endpoint}. Each operationId must be unique.`,
61
+ };
62
+ }
63
+
64
+ seen.set(id, endpoint);
65
+ }
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Validate an OpenAPI specification file or URL
73
+ *
37
74
  * This validates the spec against the OpenAPI schema and resolves all $ref references.
38
75
  * The returned api object has all references dereferenced (inlined).
39
- *
40
- * @param specPath - Path to the spec file (relative to projectDir)
76
+ *
77
+ * @param specPath - Path to the spec file (relative to projectDir) or a URL
41
78
  * @param projectDir - Project root directory
42
79
  * @returns Validation result with dereferenced API document if valid
43
80
  */
@@ -45,33 +82,43 @@ export async function validateOpenApiSpec(
45
82
  specPath: string,
46
83
  projectDir: string
47
84
  ): Promise<ValidationResult> {
48
- // Normalize the spec path (remove leading slash if present)
49
- const normalizedPath = specPath.startsWith('/') ? specPath.slice(1) : specPath;
50
- const fullPath = path.join(projectDir, normalizedPath);
51
-
52
- // Check file exists first (better error message than swagger-parser's)
53
- if (!fs.existsSync(fullPath)) {
54
- return {
55
- valid: false,
56
- error: createFileNotFoundError(specPath),
57
- };
85
+ const isUrl = specPath.startsWith('http://') || specPath.startsWith('https://');
86
+
87
+ let target: string;
88
+
89
+ if (isUrl) {
90
+ // SwaggerParser.validate() natively supports URLs
91
+ target = specPath;
92
+ } else {
93
+ const normalizedPath = specPath.startsWith('/') ? specPath.slice(1) : specPath;
94
+ target = path.join(projectDir, normalizedPath);
95
+
96
+ if (!fs.existsSync(target)) {
97
+ return {
98
+ valid: false,
99
+ error: createFileNotFoundError(specPath),
100
+ };
101
+ }
58
102
  }
59
-
103
+
60
104
  try {
61
- // Validate AND dereference in one step
62
- // This throws if validation fails
63
- const api = await SwaggerParser.validate(fullPath) as OpenAPI.Document;
64
-
105
+ const api = await SwaggerParser.validate(target) as OpenAPI.Document;
106
+
65
107
  const version = detectVersion(api);
66
-
67
- // Warn about Swagger 2.0
108
+
68
109
  if (version === '2.0') {
69
110
  console.warn(
70
111
  `Note: ${specPath} uses OpenAPI 2.0 (Swagger). ` +
71
112
  `Consider upgrading to OpenAPI 3.0+ for full feature support.`
72
113
  );
73
114
  }
74
-
115
+
116
+ // Check for duplicate operationIds (swagger-parser doesn't catch these)
117
+ const duplicateError = checkDuplicateOperationIds(api, specPath);
118
+ if (duplicateError) {
119
+ return { valid: false, error: duplicateError };
120
+ }
121
+
75
122
  return {
76
123
  valid: true,
77
124
  api,
@@ -5,6 +5,11 @@
5
5
  import { redis } from './redis';
6
6
  import { isCustomDomain, parseRedisConfig } from './domain-helpers';
7
7
 
8
+ const analyticsHeaders = {
9
+ 'Content-Type': 'application/json',
10
+ 'X-Analytics-Secret': process.env.ANALYTICS_SECRET || '',
11
+ };
12
+
8
13
  /**
9
14
  * Resolve docsPath for a project ('' or '/docs').
10
15
  * Queries Redis for projectCfg: or domainCfg: keys to determine hostAtDocs.
@@ -51,7 +56,7 @@ export async function trackServerAnalytics(params: {
51
56
  try {
52
57
  await fetch(trackingUrl, {
53
58
  method: 'POST',
54
- headers: { 'Content-Type': 'application/json' },
59
+ headers: analyticsHeaders,
55
60
  body: JSON.stringify({
56
61
  ...params,
57
62
  sessionId: `${params.source}-${Date.now()}`,
@@ -80,7 +85,7 @@ export async function trackChatAnalytics(params: {
80
85
  userAgent?: string;
81
86
  }): Promise<void> {
82
87
  const trackingUrl = process.env.CHAT_TRACKING_URL || 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackChatAnalytics';
83
- const headers: Record<string, string> = { 'Content-Type': 'application/json' };
88
+ const headers: Record<string, string> = {...analyticsHeaders};
84
89
  if (params.userAgent) {
85
90
  headers['User-Agent'] = params.userAgent;
86
91
  }