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.
- package/dist/__tests__/unit/openapi.test.js +12 -0
- package/dist/__tests__/unit/openapi.test.js.map +1 -1
- package/dist/commands/openapi-check.d.ts.map +1 -1
- package/dist/commands/openapi-check.js +7 -13
- package/dist/commands/openapi-check.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/deps.js +1 -1
- package/dist/lib/openapi/errors.d.ts.map +1 -1
- package/dist/lib/openapi/errors.js +17 -0
- package/dist/lib/openapi/errors.js.map +1 -1
- package/dist/lib/openapi/validator.d.ts +2 -2
- package/dist/lib/openapi/validator.d.ts.map +1 -1
- package/dist/lib/openapi/validator.js +52 -15
- package/dist/lib/openapi/validator.js.map +1 -1
- package/package.json +2 -2
- package/vendored/app/[[...slug]]/page.tsx +105 -28
- package/vendored/app/api/ev/route.ts +4 -1
- package/vendored/app/api/indexnow/[key]/route.ts +34 -0
- package/vendored/app/api/search-ev/route.ts +69 -0
- package/vendored/app/layout.tsx +70 -12
- package/vendored/components/chat/ChatInput.tsx +1 -1
- package/vendored/components/chat/ChatPanel.tsx +60 -11
- package/vendored/components/mdx/Update.tsx +7 -7
- package/vendored/components/navigation/Sidebar.tsx +2 -10
- package/vendored/components/navigation/TableOfContents.tsx +48 -35
- package/vendored/components/search/SearchModal.tsx +9 -5
- package/vendored/components/ui/CodePanelModal.tsx +5 -14
- package/vendored/hooks/useBodyScrollLock.ts +37 -0
- package/vendored/lib/analytics-client.ts +17 -7
- package/vendored/lib/docs-types.ts +3 -2
- package/vendored/lib/email-templates/components/base-layout.tsx +15 -15
- package/vendored/lib/extract-highlights.ts +2 -0
- package/vendored/lib/heading-extractor.ts +2 -2
- package/vendored/lib/indexnow.ts +77 -0
- package/vendored/lib/json-ld.ts +171 -0
- package/vendored/lib/middleware-helpers.ts +2 -0
- package/vendored/lib/openapi/errors.ts +21 -1
- package/vendored/lib/openapi/validator.ts +70 -23
- package/vendored/lib/route-helpers.ts +7 -2
- package/vendored/lib/search-client.ts +81 -36
- package/vendored/lib/seo.ts +77 -0
- package/vendored/lib/static-artifacts.ts +204 -5
- package/vendored/lib/static-file-route.ts +10 -5
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/schema/docs-schema.json +130 -8
- 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 = '
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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:
|
|
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: #
|
|
28
|
-
.email-container { background-color: #
|
|
29
|
-
.email-content { background-color: #
|
|
30
|
-
.email-footer { background-color: #
|
|
31
|
-
.email-title { color: #
|
|
32
|
-
.email-paragraph { color: #
|
|
33
|
-
.email-footer-text { color: #
|
|
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
|
|
36
|
-
.email-button { color: #ffffff !important; background-color: #
|
|
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: '#
|
|
56
|
-
primaryMuted: '#
|
|
57
|
-
background: '#
|
|
55
|
+
primary: '#ff3621', // Warm red
|
|
56
|
+
primaryMuted: '#ff3621', // Warm red
|
|
57
|
+
background: '#faf8f5', // bg-primary
|
|
58
58
|
cardBg: '#ffffff', // bg-secondary
|
|
59
|
-
textPrimary: '#
|
|
60
|
-
textSecondary: '#
|
|
61
|
-
border: '#
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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:
|
|
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> = {
|
|
88
|
+
const headers: Record<string, string> = {...analyticsHeaders};
|
|
84
89
|
if (params.userAgent) {
|
|
85
90
|
headers['User-Agent'] = params.userAgent;
|
|
86
91
|
}
|