jamdesk 1.0.11 → 1.0.13
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/deploy-templates.test.js +6 -1
- package/dist/__tests__/unit/deploy-templates.test.js.map +1 -1
- package/dist/commands/deploy/templates.d.ts.map +1 -1
- package/dist/commands/deploy/templates.js +7 -2
- package/dist/commands/deploy/templates.js.map +1 -1
- package/dist/lib/deps.js +3 -3
- package/package.json +2 -2
- package/vendored/app/[[...slug]]/page.tsx +5 -2
- package/vendored/app/layout.tsx +9 -119
- package/vendored/components/mdx/ApiPage.tsx +18 -0
- package/vendored/components/mdx/OpenApiEndpoint.tsx +3 -3
- package/vendored/components/navigation/Breadcrumb.tsx +63 -3
- package/vendored/components/navigation/Sidebar.tsx +59 -38
- package/vendored/components/snippets/generated/CodeLink.tsx +25 -0
- package/vendored/components/snippets/generated/HeaderAPI.tsx +44 -0
- package/vendored/components/snippets/generated/PlansAvailable.tsx +53 -0
- package/vendored/components/snippets/generated/SnippetIntro.tsx +43 -0
- package/vendored/components/ui/CodePanel.tsx +7 -0
- package/vendored/lib/analytics-script.ts +147 -0
- package/vendored/lib/crypto-helpers.ts +25 -0
- package/vendored/lib/enhance-navigation.ts +175 -0
- package/vendored/lib/heading-extractor.ts +117 -0
- package/vendored/lib/mdx.ts +1 -1
- package/vendored/scripts/validate-links.cjs +216 -48
|
@@ -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;
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
// Helper component for rendering plain MDX snippets
|
|
18
|
+
// Content is from project snippets (controlled source), not user input
|
|
19
|
+
const PlainMdxSnippet = ({ content }: { content: string }) => {
|
|
20
|
+
const formattedContent = content
|
|
21
|
+
.split('\n\n')
|
|
22
|
+
.map((paragraph) => {
|
|
23
|
+
let html = paragraph.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
24
|
+
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
25
|
+
html = html.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
|
|
26
|
+
return html;
|
|
27
|
+
})
|
|
28
|
+
.filter(p => p.trim());
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="snippet-content">
|
|
32
|
+
{formattedContent.map((p, i) => (
|
|
33
|
+
<p key={i} dangerouslySetInnerHTML={{ __html: p }} />
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SnippetIntro = () => {
|
|
40
|
+
return <PlainMdxSnippet content={"One of the core principles of software development is DRY (Don't Repeat\nYourself). This is a principle that apply to documentation as\nwell. If you find yourself repeating the same content in multiple places, you\nshould consider creating a custom snippet to keep your content in sync."} />;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export default SnippetIntro;
|
|
@@ -121,6 +121,7 @@ export function CodePanel({
|
|
|
121
121
|
const [scrollRatio, setScrollRatio] = useState(0); // 0 to 1, for custom scrollbar position
|
|
122
122
|
const [thumbWidth, setThumbWidth] = useState(30); // Percentage width of thumb
|
|
123
123
|
const [isDragging, setIsDragging] = useState(false);
|
|
124
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
124
125
|
const tabsRef = useRef<HTMLDivElement>(null);
|
|
125
126
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
126
127
|
const contentRef = useRef<HTMLDivElement>(null);
|
|
@@ -149,6 +150,11 @@ export function CodePanel({
|
|
|
149
150
|
}
|
|
150
151
|
};
|
|
151
152
|
|
|
153
|
+
// Notify parent (ApiPage) after tab content re-renders so sidebar heights recalculate
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
panelRef.current?.dispatchEvent(new CustomEvent('codepanel-tab-change', { bubbles: true }));
|
|
156
|
+
}, [activeTab]);
|
|
157
|
+
|
|
152
158
|
// Check for overflow and scroll position
|
|
153
159
|
useEffect(() => {
|
|
154
160
|
const checkOverflow = () => {
|
|
@@ -246,6 +252,7 @@ export function CodePanel({
|
|
|
246
252
|
|
|
247
253
|
return (
|
|
248
254
|
<div
|
|
255
|
+
ref={panelRef}
|
|
249
256
|
className={`rounded-xl overflow-hidden not-prose w-full flex flex-col ${className}`}
|
|
250
257
|
style={{ border: `0.5px solid ${codePanelColors.border}`, boxShadow: 'var(--shadow-lg)' }}
|
|
251
258
|
data-code-panel={panelType || 'inline'}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Analytics script generation for ISR layout
|
|
3
|
+
*
|
|
4
|
+
* Generates the inline tracking script injected into documentation pages.
|
|
5
|
+
* The slug can be server-injected (ISR mode) or null (static mode, uses
|
|
6
|
+
* client-side hostname detection as fallback).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Same pattern as validSlugPattern in path-safety.ts */
|
|
10
|
+
const SAFE_SLUG_PATTERN = /^[a-z0-9-]+$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sanitize a project slug for safe injection into an inline <script>.
|
|
14
|
+
* Only allows lowercase alphanumeric characters and hyphens.
|
|
15
|
+
* Returns null if the slug is invalid, empty, or contains unsafe characters.
|
|
16
|
+
*/
|
|
17
|
+
export function sanitizeSlugForScript(slug: string | null | undefined): string | null {
|
|
18
|
+
if (!slug || typeof slug !== 'string') return null;
|
|
19
|
+
return SAFE_SLUG_PATTERN.test(slug) ? slug : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate the analytics tracking script content for inline injection.
|
|
24
|
+
*
|
|
25
|
+
* When slug is provided (ISR mode), it's injected directly — works on any domain
|
|
26
|
+
* including bare domains (jamdesk.com) and custom domains (docs.acme.com).
|
|
27
|
+
* When slug is null (static mode), falls back to client-side subdomain detection.
|
|
28
|
+
*/
|
|
29
|
+
export function getAnalyticsScript(rawSlug: string | null): string {
|
|
30
|
+
const slug = sanitizeSlugForScript(rawSlug);
|
|
31
|
+
const slugLiteral = slug ? `'${slug}'` : 'null';
|
|
32
|
+
|
|
33
|
+
return `
|
|
34
|
+
(function() {
|
|
35
|
+
var h = location.hostname;
|
|
36
|
+
|
|
37
|
+
// Server-injected slug (ISR mode) or client-side detection (static mode)
|
|
38
|
+
var slug = ${slugLiteral};
|
|
39
|
+
|
|
40
|
+
// Fallback: extract from subdomain (static/dev mode)
|
|
41
|
+
if (!slug) {
|
|
42
|
+
var parts = h.split('.');
|
|
43
|
+
if ((h.endsWith('.jamdesk.com') || h.endsWith('.jamdesk.dev') || h.endsWith('.jamdesk.app')) && parts.length >= 3) {
|
|
44
|
+
slug = parts[0];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Skip tracking for localhost or if no valid slug
|
|
49
|
+
if (!slug || h.indexOf('localhost') !== -1) {
|
|
50
|
+
console.log('[Jamdesk Analytics] Skipped: no valid project slug');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Generate session ID (persisted for 30 minutes of inactivity)
|
|
55
|
+
var SESSION_KEY = 'jd_sid';
|
|
56
|
+
var SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
57
|
+
|
|
58
|
+
function getSessionId() {
|
|
59
|
+
var stored = sessionStorage.getItem(SESSION_KEY);
|
|
60
|
+
var data = stored ? JSON.parse(stored) : null;
|
|
61
|
+
var now = Date.now();
|
|
62
|
+
|
|
63
|
+
if (data && (now - data.lastActivity) < SESSION_TIMEOUT) {
|
|
64
|
+
data.lastActivity = now;
|
|
65
|
+
sessionStorage.setItem(SESSION_KEY, JSON.stringify(data));
|
|
66
|
+
return data.id;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// New session
|
|
70
|
+
var newId = Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
|
71
|
+
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id: newId, lastActivity: now }));
|
|
72
|
+
return newId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Get timezone for country fallback
|
|
76
|
+
function getTimezone() {
|
|
77
|
+
try {
|
|
78
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Track page view
|
|
85
|
+
function trackPageView() {
|
|
86
|
+
var data = {
|
|
87
|
+
projectSlug: slug,
|
|
88
|
+
path: location.pathname,
|
|
89
|
+
referrer: document.referrer || null,
|
|
90
|
+
userAgent: navigator.userAgent,
|
|
91
|
+
sessionId: getSessionId(),
|
|
92
|
+
type: 'pageview',
|
|
93
|
+
eventId: slug + '-' + location.pathname + '-' + Date.now(),
|
|
94
|
+
timezone: getTimezone()
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Send to analytics endpoint (proxied via first-party domain to avoid ad blockers)
|
|
98
|
+
fetch('/_jd/ev', {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'Content-Type': 'application/json' },
|
|
101
|
+
body: JSON.stringify(data),
|
|
102
|
+
keepalive: true
|
|
103
|
+
}).catch(function() { /* Ignore errors */ });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Track current path to avoid duplicate tracking
|
|
107
|
+
var lastTrackedPath = location.pathname;
|
|
108
|
+
|
|
109
|
+
// Track page view (with deduplication)
|
|
110
|
+
function trackIfNewPath() {
|
|
111
|
+
var currentPath = location.pathname;
|
|
112
|
+
if (currentPath !== lastTrackedPath) {
|
|
113
|
+
lastTrackedPath = currentPath;
|
|
114
|
+
trackPageView();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Track initial page view
|
|
119
|
+
trackPageView();
|
|
120
|
+
|
|
121
|
+
// Track SPA navigation (History API)
|
|
122
|
+
var origPushState = history.pushState;
|
|
123
|
+
history.pushState = function() {
|
|
124
|
+
origPushState.apply(this, arguments);
|
|
125
|
+
setTimeout(trackIfNewPath, 0);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
var origReplaceState = history.replaceState;
|
|
129
|
+
history.replaceState = function() {
|
|
130
|
+
origReplaceState.apply(this, arguments);
|
|
131
|
+
setTimeout(trackIfNewPath, 0);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
window.addEventListener('popstate', function() {
|
|
135
|
+
setTimeout(trackIfNewPath, 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Fallback: Poll for URL changes (catches Next.js soft navigation)
|
|
139
|
+
setInterval(function() {
|
|
140
|
+
if (location.pathname !== lastTrackedPath) {
|
|
141
|
+
lastTrackedPath = location.pathname;
|
|
142
|
+
trackPageView();
|
|
143
|
+
}
|
|
144
|
+
}, 1000);
|
|
145
|
+
})();
|
|
146
|
+
`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// DO NOT EDIT — this file is auto-synced from shared/. Edit the source in shared/ and run ./scripts/sync-shared.sh
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared Cryptographic Helpers
|
|
5
|
+
*
|
|
6
|
+
* Constant-time comparison using HMAC to avoid length-leak timing attacks.
|
|
7
|
+
*
|
|
8
|
+
* SYNC TARGET: This file is the source of truth.
|
|
9
|
+
* Synced to: builder/build-service/lib, proxy/lib
|
|
10
|
+
*/
|
|
11
|
+
import { timingSafeEqual, createHmac } from 'crypto';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Constant-time string comparison using HMAC.
|
|
15
|
+
*
|
|
16
|
+
* Unlike naive timingSafeEqual (which requires equal-length buffers and
|
|
17
|
+
* leaks length via early return), this HMACs both values first so
|
|
18
|
+
* comparisons are always on fixed-length 32-byte digests.
|
|
19
|
+
*/
|
|
20
|
+
export function secretsEqual(a: string, b: string): boolean {
|
|
21
|
+
const key = 'jamdesk-secret-comparison';
|
|
22
|
+
const hmacA = createHmac('sha256', key).update(a).digest();
|
|
23
|
+
const hmacB = createHmac('sha256', key).update(b).digest();
|
|
24
|
+
return timingSafeEqual(hmacA, hmacB);
|
|
25
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation Enhancement
|
|
3
|
+
*
|
|
4
|
+
* Enhances docs.json navigation entries with metadata from MDX frontmatter:
|
|
5
|
+
* - Sidebar titles (sidebarTitle > title)
|
|
6
|
+
* - HTTP method badges (from openapi: or api: frontmatter)
|
|
7
|
+
* - Icons and tags
|
|
8
|
+
*
|
|
9
|
+
* Port of scripts/enhance-navigation.cjs for use in the production build pipeline.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
DocsConfig,
|
|
14
|
+
NavigationPage,
|
|
15
|
+
NavigationPageObject,
|
|
16
|
+
GroupConfig,
|
|
17
|
+
} from './docs-types.js';
|
|
18
|
+
|
|
19
|
+
type HttpMethod = NonNullable<NavigationPageObject['method']>;
|
|
20
|
+
|
|
21
|
+
export interface PageInfo {
|
|
22
|
+
path: string;
|
|
23
|
+
frontmatter: Record<string, unknown>;
|
|
24
|
+
content: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const VALID_METHODS: readonly string[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse HTTP method from `api:` frontmatter field.
|
|
31
|
+
* Example: "POST /analytics/post" -> "POST"
|
|
32
|
+
*/
|
|
33
|
+
export function parseApiMethod(apiField: unknown): HttpMethod | null {
|
|
34
|
+
if (!apiField || typeof apiField !== 'string') return null;
|
|
35
|
+
const trimmed = apiField.trim().toUpperCase();
|
|
36
|
+
for (const method of VALID_METHODS) {
|
|
37
|
+
if (trimmed.startsWith(method)) return method as HttpMethod;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse HTTP method from `openapi:` frontmatter field.
|
|
44
|
+
* Example: "/openapi/spec.yml GET /api/v1/users" -> "GET"
|
|
45
|
+
*/
|
|
46
|
+
export function parseOpenApiMethod(openapiField: unknown): HttpMethod | null {
|
|
47
|
+
if (!openapiField || typeof openapiField !== 'string') return null;
|
|
48
|
+
const parts = openapiField.trim().split(/\s+/);
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
const upper = part.toUpperCase();
|
|
51
|
+
if (VALID_METHODS.includes(upper)) return upper as HttpMethod;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildFrontmatterMap(
|
|
57
|
+
pageInfos: PageInfo[],
|
|
58
|
+
): Map<string, Record<string, unknown>> {
|
|
59
|
+
const map = new Map<string, Record<string, unknown>>();
|
|
60
|
+
for (const info of pageInfos) {
|
|
61
|
+
const pagePath = info.path.replace(/\.mdx?$/, '');
|
|
62
|
+
map.set(pagePath, info.frontmatter);
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Enhance a single page entry with frontmatter data.
|
|
69
|
+
* Preserves existing explicit values — only fills in missing ones.
|
|
70
|
+
*/
|
|
71
|
+
function enhancePage(
|
|
72
|
+
page: NavigationPage,
|
|
73
|
+
map: Map<string, Record<string, unknown>>,
|
|
74
|
+
): NavigationPage {
|
|
75
|
+
const pagePath = typeof page === 'string' ? page : page.page;
|
|
76
|
+
const existing: Partial<NavigationPageObject> =
|
|
77
|
+
typeof page === 'object' ? page : {};
|
|
78
|
+
|
|
79
|
+
const fm = map.get(pagePath);
|
|
80
|
+
if (!fm) return page;
|
|
81
|
+
|
|
82
|
+
// sidebarTitle takes priority over title for sidebar display
|
|
83
|
+
const title =
|
|
84
|
+
existing.title ||
|
|
85
|
+
(fm.sidebarTitle as string | undefined) ||
|
|
86
|
+
(fm.title as string | undefined);
|
|
87
|
+
const method =
|
|
88
|
+
existing.method || parseApiMethod(fm.api) || parseOpenApiMethod(fm.openapi);
|
|
89
|
+
const icon = existing.icon || (fm.icon as string | undefined);
|
|
90
|
+
const tag = existing.tag || (fm.tag as string | undefined);
|
|
91
|
+
|
|
92
|
+
if (title || method || icon || tag) {
|
|
93
|
+
return {
|
|
94
|
+
page: pagePath,
|
|
95
|
+
...(title && { title }),
|
|
96
|
+
...(method && { method }),
|
|
97
|
+
...(icon && { icon }),
|
|
98
|
+
...(tag && { tag }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return page;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Recursively enhance all pages/groups in a navigation array.
|
|
107
|
+
*/
|
|
108
|
+
function enhancePages(
|
|
109
|
+
pages: (NavigationPage | GroupConfig)[],
|
|
110
|
+
map: Map<string, Record<string, unknown>>,
|
|
111
|
+
): (NavigationPage | GroupConfig)[] {
|
|
112
|
+
return pages.map((item) => {
|
|
113
|
+
if (typeof item === 'object' && 'group' in item) {
|
|
114
|
+
return enhanceNavNode(
|
|
115
|
+
item as unknown as Record<string, unknown>,
|
|
116
|
+
map,
|
|
117
|
+
) as unknown as GroupConfig;
|
|
118
|
+
}
|
|
119
|
+
return enhancePage(item as NavigationPage, map);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Navigation keys whose children are sub-nodes (recurse with enhanceNavNode). */
|
|
124
|
+
const RECURSE_KEYS = [
|
|
125
|
+
'groups', 'tabs', 'anchors', 'dropdowns',
|
|
126
|
+
'products', 'versions', 'languages', 'menu',
|
|
127
|
+
] as const;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Generic recursive walker — enhances any navigation node that has
|
|
131
|
+
* pages, groups, tabs, anchors, dropdowns, products, versions,
|
|
132
|
+
* languages, or menu keys.
|
|
133
|
+
*/
|
|
134
|
+
function enhanceNavNode(
|
|
135
|
+
node: Record<string, unknown>,
|
|
136
|
+
map: Map<string, Record<string, unknown>>,
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const result = { ...node };
|
|
139
|
+
|
|
140
|
+
// Pages contain leaf page entries (strings or objects) mixed with nested groups
|
|
141
|
+
if (Array.isArray(node.pages)) {
|
|
142
|
+
result.pages = enhancePages(node.pages, map);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// All other array keys contain sub-nodes that need recursive enhancement
|
|
146
|
+
for (const key of RECURSE_KEYS) {
|
|
147
|
+
if (Array.isArray(node[key])) {
|
|
148
|
+
result[key] = (node[key] as Record<string, unknown>[]).map((child) =>
|
|
149
|
+
enhanceNavNode(child, map),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return result;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Enhance docs.json navigation with frontmatter metadata.
|
|
159
|
+
* Returns a new config — does NOT mutate the input.
|
|
160
|
+
*/
|
|
161
|
+
export function enhanceConfigNavigation(
|
|
162
|
+
config: DocsConfig,
|
|
163
|
+
pageInfos: PageInfo[],
|
|
164
|
+
): DocsConfig {
|
|
165
|
+
const nav = config.navigation;
|
|
166
|
+
if (!nav || Object.keys(nav).length === 0) return config;
|
|
167
|
+
|
|
168
|
+
const map = buildFrontmatterMap(pageInfos);
|
|
169
|
+
const enhanced = enhanceNavNode(
|
|
170
|
+
nav as Record<string, unknown>,
|
|
171
|
+
map,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
return { ...config, navigation: enhanced as DocsConfig['navigation'] };
|
|
175
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heading extractor for link validation.
|
|
3
|
+
* Extracts heading slugs from MDX content to validate #fragment links.
|
|
4
|
+
*
|
|
5
|
+
* Uses the same slug generation as TableOfContents.tsx (client-side DOM IDs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface HeadingInfo {
|
|
9
|
+
id: string;
|
|
10
|
+
text: string;
|
|
11
|
+
level: number;
|
|
12
|
+
line: number; // 1-indexed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Generate a URL-friendly slug from heading text.
|
|
17
|
+
* Must stay in sync with TableOfContents.tsx:207-209.
|
|
18
|
+
*/
|
|
19
|
+
export function generateSlug(text: string): string {
|
|
20
|
+
return text
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
23
|
+
.replace(/^-+|-+$/g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/;
|
|
27
|
+
const FENCE_REGEX = /^(`{3,}|~{3,})/;
|
|
28
|
+
const UPDATE_LABEL_REGEX = /<Update\s+label=["']([^"']+)["']/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Extract all heading slugs from MDX content.
|
|
32
|
+
* Includes markdown headings and <Update label="..."> component anchors.
|
|
33
|
+
* Skips content inside fenced code blocks.
|
|
34
|
+
*/
|
|
35
|
+
export function extractHeadings(content: string): HeadingInfo[] {
|
|
36
|
+
const headings: HeadingInfo[] = [];
|
|
37
|
+
const lines = content.split('\n');
|
|
38
|
+
let inCodeBlock = false;
|
|
39
|
+
let fencePattern = '';
|
|
40
|
+
|
|
41
|
+
for (let i = 0; i < lines.length; i++) {
|
|
42
|
+
const line = lines[i];
|
|
43
|
+
const fenceMatch = line.match(FENCE_REGEX);
|
|
44
|
+
|
|
45
|
+
if (fenceMatch) {
|
|
46
|
+
if (!inCodeBlock) {
|
|
47
|
+
inCodeBlock = true;
|
|
48
|
+
fencePattern = fenceMatch[1];
|
|
49
|
+
continue;
|
|
50
|
+
} else if (line.startsWith(fencePattern)) {
|
|
51
|
+
inCodeBlock = false;
|
|
52
|
+
fencePattern = '';
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (inCodeBlock) continue;
|
|
58
|
+
|
|
59
|
+
const headingMatch = line.match(HEADING_REGEX);
|
|
60
|
+
if (headingMatch) {
|
|
61
|
+
const level = headingMatch[1].length;
|
|
62
|
+
const text = headingMatch[2].trim();
|
|
63
|
+
const id = generateSlug(text);
|
|
64
|
+
if (id) {
|
|
65
|
+
headings.push({ id, text, level, line: i + 1 });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const updateMatch = line.match(UPDATE_LABEL_REGEX);
|
|
70
|
+
if (updateMatch) {
|
|
71
|
+
const text = updateMatch[1];
|
|
72
|
+
const id = generateSlug(text);
|
|
73
|
+
if (id) {
|
|
74
|
+
headings.push({ id, text, level: 2, line: i + 1 });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return headings;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Check if MDX content has api: or openapi: in its frontmatter.
|
|
84
|
+
* API endpoint pages generate dynamic anchor IDs at render time (#param-*, etc.)
|
|
85
|
+
* that aren't in the MDX source, so fragment validation is skipped for them.
|
|
86
|
+
*/
|
|
87
|
+
function hasApiFrontmatter(content: string): boolean {
|
|
88
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
89
|
+
if (!match) return false;
|
|
90
|
+
return /^(openapi|api):/m.test(match[1]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build a map of page path -> heading slug set.
|
|
95
|
+
* Keys are page paths without .mdx extension (matching docs.json navigation format).
|
|
96
|
+
* Skips API endpoint pages (they generate dynamic anchors at render time).
|
|
97
|
+
*
|
|
98
|
+
* Unlike buildHeadingMapFromFiles() in validate-links.cjs, this doesn't add dual
|
|
99
|
+
* keys for index pages. The CJS validator handles index resolution via
|
|
100
|
+
* getTargetHeadingSlugs() which derives the key from the filesystem path.
|
|
101
|
+
*
|
|
102
|
+
* @param fileContents - Map of relative file paths (with .mdx) to content.
|
|
103
|
+
* Keys come from build.ts fileContents Map (e.g., 'getting-started.mdx', 'api/auth.mdx').
|
|
104
|
+
*/
|
|
105
|
+
export function buildHeadingMap(
|
|
106
|
+
fileContents: Map<string, string>
|
|
107
|
+
): Map<string, Set<string>> {
|
|
108
|
+
const map = new Map<string, Set<string>>();
|
|
109
|
+
for (const [filePath, content] of fileContents) {
|
|
110
|
+
if (hasApiFrontmatter(content)) continue;
|
|
111
|
+
const headings = extractHeadings(content);
|
|
112
|
+
const slugs = new Set(headings.map((h) => h.id));
|
|
113
|
+
const pagePath = filePath.replace(/\.mdx$/, '');
|
|
114
|
+
map.set(pagePath, slugs);
|
|
115
|
+
}
|
|
116
|
+
return map;
|
|
117
|
+
}
|
package/vendored/lib/mdx.ts
CHANGED