jamdesk 1.0.12 → 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/lib/deps.js +3 -3
- package/package.json +2 -2
- package/vendored/app/layout.tsx +9 -119
- package/vendored/lib/analytics-script.ts +147 -0
- package/vendored/lib/heading-extractor.ts +117 -0
- package/vendored/lib/mdx.ts +1 -1
- package/vendored/scripts/validate-links.cjs +216 -48
package/dist/lib/deps.js
CHANGED
|
@@ -54,7 +54,7 @@ const REQUIRED_DEPS = {
|
|
|
54
54
|
'remark-math': '^6.0.0',
|
|
55
55
|
'remark-smartypants': '^3.0.2',
|
|
56
56
|
// Math/LaTeX rendering
|
|
57
|
-
'katex': '^0.16.
|
|
57
|
+
'katex': '^0.16.33',
|
|
58
58
|
// Diagrams
|
|
59
59
|
'mermaid': '^11.12.2',
|
|
60
60
|
// YAML parsing (for OpenAPI specs)
|
|
@@ -70,8 +70,8 @@ const REQUIRED_DEPS = {
|
|
|
70
70
|
'unist-util-visit': '^5.0.0',
|
|
71
71
|
'hast': '^1.0.0',
|
|
72
72
|
// CSS
|
|
73
|
-
'tailwindcss': '^4.2.
|
|
74
|
-
'@tailwindcss/postcss': '^4.2.
|
|
73
|
+
'tailwindcss': '^4.2.1',
|
|
74
|
+
'@tailwindcss/postcss': '^4.2.1',
|
|
75
75
|
'@tailwindcss/typography': '^0.5.10',
|
|
76
76
|
'postcss': '^8.4.32',
|
|
77
77
|
'autoprefixer': '^10.4.24',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.13",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
},
|
|
94
94
|
"dependencies": {
|
|
95
95
|
"@apidevtools/swagger-parser": "^12.1.0",
|
|
96
|
-
"@inquirer/prompts": "^8.
|
|
96
|
+
"@inquirer/prompts": "^8.3.0",
|
|
97
97
|
"ajv": "^8.17.1",
|
|
98
98
|
"chalk": "^5.3.0",
|
|
99
99
|
"commander": "^14.0.3",
|
package/vendored/app/layout.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import path from 'path';
|
|
|
16
16
|
import type { DocsConfig, Favicon, FontConfig } from '@/lib/docs-types';
|
|
17
17
|
import { ASSET_PREFIX, transformConfigImagePath } from '@/lib/docs-types';
|
|
18
18
|
import { LinkPrefixProvider } from '@/lib/link-prefix-context';
|
|
19
|
+
import { getAnalyticsScript } from '@/lib/analytics-script';
|
|
19
20
|
|
|
20
21
|
// Pre-load fonts - Next.js will tree-shake unused ones
|
|
21
22
|
const inter = Inter({
|
|
@@ -223,9 +224,11 @@ export default async function RootLayout({
|
|
|
223
224
|
}) {
|
|
224
225
|
// Get config - from R2 in ISR mode, from filesystem in static mode
|
|
225
226
|
let config: DocsConfig;
|
|
227
|
+
let resolvedProjectSlug: string | null = null;
|
|
226
228
|
if (isIsrMode()) {
|
|
227
229
|
const headersList = await headers();
|
|
228
230
|
const projectSlug = getProjectFromRequest(headersList);
|
|
231
|
+
resolvedProjectSlug = projectSlug;
|
|
229
232
|
const hostAtDocs = getHostAtDocs(headersList);
|
|
230
233
|
if (projectSlug) {
|
|
231
234
|
try {
|
|
@@ -279,9 +282,10 @@ export default async function RootLayout({
|
|
|
279
282
|
// Link prefix for hostAtDocs: when true, MDX component links need /docs prefix
|
|
280
283
|
const linkPrefix = config.hostAtDocs ? '/docs' : '';
|
|
281
284
|
|
|
282
|
-
// Jamdesk Analytics -
|
|
283
|
-
|
|
284
|
-
|
|
285
|
+
// Jamdesk Analytics - enabled by default, opt-out via analytics.enabled: false
|
|
286
|
+
const analyticsScript = config.analytics?.enabled !== false
|
|
287
|
+
? getAnalyticsScript(resolvedProjectSlug)
|
|
288
|
+
: null;
|
|
285
289
|
|
|
286
290
|
return (
|
|
287
291
|
<html lang="en" suppressHydrationWarning>
|
|
@@ -420,122 +424,8 @@ export default async function RootLayout({
|
|
|
420
424
|
<style dangerouslySetInnerHTML={{ __html: customCss }} />
|
|
421
425
|
)}
|
|
422
426
|
{/* Jamdesk Analytics - tracks page views for analytics dashboard */}
|
|
423
|
-
{
|
|
424
|
-
<script
|
|
425
|
-
dangerouslySetInnerHTML={{
|
|
426
|
-
__html: `
|
|
427
|
-
(function() {
|
|
428
|
-
// Extract project slug from hostname (e.g., "acme" from "acme.jamdesk.com")
|
|
429
|
-
var h = location.hostname;
|
|
430
|
-
var parts = h.split('.');
|
|
431
|
-
var slug = null;
|
|
432
|
-
|
|
433
|
-
// Check for *.jamdesk.com, *.jamdesk.dev, or *.jamdesk.app subdomains
|
|
434
|
-
if ((h.endsWith('.jamdesk.com') || h.endsWith('.jamdesk.dev') || h.endsWith('.jamdesk.app')) && parts.length >= 3) {
|
|
435
|
-
slug = parts[0];
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Skip tracking for localhost or if no valid slug
|
|
439
|
-
if (!slug || h.indexOf('localhost') !== -1) {
|
|
440
|
-
console.log('[Jamdesk Analytics] Skipped: no valid project slug');
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// Generate session ID (persisted for 30 minutes of inactivity)
|
|
445
|
-
var SESSION_KEY = 'jd_sid';
|
|
446
|
-
var SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
|
|
447
|
-
|
|
448
|
-
function getSessionId() {
|
|
449
|
-
var stored = sessionStorage.getItem(SESSION_KEY);
|
|
450
|
-
var data = stored ? JSON.parse(stored) : null;
|
|
451
|
-
var now = Date.now();
|
|
452
|
-
|
|
453
|
-
if (data && (now - data.lastActivity) < SESSION_TIMEOUT) {
|
|
454
|
-
data.lastActivity = now;
|
|
455
|
-
sessionStorage.setItem(SESSION_KEY, JSON.stringify(data));
|
|
456
|
-
return data.id;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// New session
|
|
460
|
-
var newId = Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
|
461
|
-
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ id: newId, lastActivity: now }));
|
|
462
|
-
return newId;
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// Get timezone for country fallback
|
|
466
|
-
function getTimezone() {
|
|
467
|
-
try {
|
|
468
|
-
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
469
|
-
} catch (e) {
|
|
470
|
-
return null;
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
// Track page view
|
|
475
|
-
function trackPageView() {
|
|
476
|
-
var data = {
|
|
477
|
-
projectSlug: slug,
|
|
478
|
-
path: location.pathname,
|
|
479
|
-
referrer: document.referrer || null,
|
|
480
|
-
userAgent: navigator.userAgent,
|
|
481
|
-
sessionId: getSessionId(),
|
|
482
|
-
type: 'pageview',
|
|
483
|
-
eventId: slug + '-' + location.pathname + '-' + Date.now(),
|
|
484
|
-
timezone: getTimezone()
|
|
485
|
-
};
|
|
486
|
-
|
|
487
|
-
// Send to analytics endpoint (proxied via first-party domain to avoid ad blockers)
|
|
488
|
-
fetch('/_jd/ev', {
|
|
489
|
-
method: 'POST',
|
|
490
|
-
headers: { 'Content-Type': 'application/json' },
|
|
491
|
-
body: JSON.stringify(data),
|
|
492
|
-
keepalive: true
|
|
493
|
-
}).catch(function() { /* Ignore errors */ });
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
// Track current path to avoid duplicate tracking
|
|
497
|
-
var lastTrackedPath = location.pathname;
|
|
498
|
-
|
|
499
|
-
// Track page view (with deduplication)
|
|
500
|
-
function trackIfNewPath() {
|
|
501
|
-
var currentPath = location.pathname;
|
|
502
|
-
if (currentPath !== lastTrackedPath) {
|
|
503
|
-
lastTrackedPath = currentPath;
|
|
504
|
-
trackPageView();
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Track initial page view
|
|
509
|
-
trackPageView();
|
|
510
|
-
|
|
511
|
-
// Track SPA navigation (History API)
|
|
512
|
-
var origPushState = history.pushState;
|
|
513
|
-
history.pushState = function() {
|
|
514
|
-
origPushState.apply(this, arguments);
|
|
515
|
-
setTimeout(trackIfNewPath, 0);
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
var origReplaceState = history.replaceState;
|
|
519
|
-
history.replaceState = function() {
|
|
520
|
-
origReplaceState.apply(this, arguments);
|
|
521
|
-
setTimeout(trackIfNewPath, 0);
|
|
522
|
-
};
|
|
523
|
-
|
|
524
|
-
window.addEventListener('popstate', function() {
|
|
525
|
-
setTimeout(trackIfNewPath, 0);
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
// Fallback: Poll for URL changes (catches Next.js soft navigation)
|
|
529
|
-
setInterval(function() {
|
|
530
|
-
if (location.pathname !== lastTrackedPath) {
|
|
531
|
-
lastTrackedPath = location.pathname;
|
|
532
|
-
trackPageView();
|
|
533
|
-
}
|
|
534
|
-
}, 1000);
|
|
535
|
-
})();
|
|
536
|
-
`,
|
|
537
|
-
}}
|
|
538
|
-
/>
|
|
427
|
+
{analyticsScript && (
|
|
428
|
+
<script dangerouslySetInnerHTML={{ __html: analyticsScript }} />
|
|
539
429
|
)}
|
|
540
430
|
</head>
|
|
541
431
|
<body className={fontClassName} data-theme={themeName || 'jam'}>
|
|
@@ -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,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
|
@@ -65,10 +65,10 @@ function getProjectDir() {
|
|
|
65
65
|
* - MDX/JSX href props: href="/docs/path" or href="./relative"
|
|
66
66
|
*/
|
|
67
67
|
const LINK_PATTERNS = [
|
|
68
|
-
// Markdown links: [text](href) - capture
|
|
69
|
-
/\[(?:[^\]]*)\]\(([^)
|
|
68
|
+
// Markdown links: [text](href) - capture href including optional #fragment
|
|
69
|
+
/\[(?:[^\]]*)\]\(([^)\s]+)\)/g,
|
|
70
70
|
// JSX href attribute: href="..." or href='...'
|
|
71
|
-
/href=["']([^"'
|
|
71
|
+
/href=["']([^"'\s]+)["']/g,
|
|
72
72
|
];
|
|
73
73
|
|
|
74
74
|
/**
|
|
@@ -82,8 +82,8 @@ function shouldSkipLink(href) {
|
|
|
82
82
|
if (/^mailto:/.test(href)) return true;
|
|
83
83
|
if (/^tel:/.test(href)) return true;
|
|
84
84
|
|
|
85
|
-
// Skip anchor-only links
|
|
86
|
-
if (href
|
|
85
|
+
// Skip empty anchor (#) - fragment-only links (#heading) are validated separately
|
|
86
|
+
if (href === '#') return true;
|
|
87
87
|
|
|
88
88
|
// Skip image/asset paths (handles dimension syntax like =500x, =x200, =300x200)
|
|
89
89
|
// Strips any trailing dimension suffix before checking extension
|
|
@@ -96,6 +96,22 @@ function shouldSkipLink(href) {
|
|
|
96
96
|
return false;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Split href into path and fragment parts.
|
|
101
|
+
* '#heading' → { path: null, fragment: 'heading' }
|
|
102
|
+
* '/page#heading' → { path: '/page', fragment: 'heading' }
|
|
103
|
+
* '/page' → { path: '/page', fragment: null }
|
|
104
|
+
* '#' → { path: null, fragment: null } (empty fragment)
|
|
105
|
+
*/
|
|
106
|
+
function splitFragment(href) {
|
|
107
|
+
const hashIndex = href.indexOf('#');
|
|
108
|
+
if (hashIndex === -1) return { path: href, fragment: null };
|
|
109
|
+
return {
|
|
110
|
+
path: hashIndex === 0 ? null : href.substring(0, hashIndex),
|
|
111
|
+
fragment: href.substring(hashIndex + 1) || null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
99
115
|
/**
|
|
100
116
|
* Resolve a link to a file path
|
|
101
117
|
* @param {string} href - The link href
|
|
@@ -191,46 +207,192 @@ function isInCodeBlock(lineNumber, ranges) {
|
|
|
191
207
|
return ranges.some(([start, end]) => lineNumber >= start && lineNumber <= end);
|
|
192
208
|
}
|
|
193
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Generate URL-friendly slug. Must match TableOfContents.tsx:207-209.
|
|
212
|
+
*/
|
|
213
|
+
function generateSlug(text) {
|
|
214
|
+
return text
|
|
215
|
+
.toLowerCase()
|
|
216
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
217
|
+
.replace(/^-+|-+$/g, '');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract heading slugs from MDX content.
|
|
222
|
+
* Includes markdown headings and <Update label="..."> component anchors.
|
|
223
|
+
* Skips content inside fenced code blocks.
|
|
224
|
+
*
|
|
225
|
+
* Uses single-pass fence tracking (matches heading-extractor.ts pattern).
|
|
226
|
+
*/
|
|
227
|
+
function extractHeadingSlugs(content) {
|
|
228
|
+
const slugs = new Set();
|
|
229
|
+
const lines = content.split('\n');
|
|
230
|
+
let inCodeBlock = false;
|
|
231
|
+
let fencePattern = '';
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < lines.length; i++) {
|
|
234
|
+
const line = lines[i];
|
|
235
|
+
const fenceMatch = line.match(/^(`{3,}|~{3,})/);
|
|
236
|
+
|
|
237
|
+
if (fenceMatch) {
|
|
238
|
+
if (!inCodeBlock) {
|
|
239
|
+
inCodeBlock = true;
|
|
240
|
+
fencePattern = fenceMatch[1];
|
|
241
|
+
continue;
|
|
242
|
+
} else if (line.startsWith(fencePattern)) {
|
|
243
|
+
inCodeBlock = false;
|
|
244
|
+
fencePattern = '';
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (inCodeBlock) continue;
|
|
250
|
+
|
|
251
|
+
const headingMatch = line.match(/^#{1,6}\s+(.+)$/);
|
|
252
|
+
if (headingMatch) {
|
|
253
|
+
const slug = generateSlug(headingMatch[1].trim());
|
|
254
|
+
if (slug) slugs.add(slug);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const updateMatch = line.match(/<Update\s+label=["']([^"']+)["']/);
|
|
258
|
+
if (updateMatch) {
|
|
259
|
+
const slug = generateSlug(updateMatch[1]);
|
|
260
|
+
if (slug) slugs.add(slug);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return slugs;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Check if MDX content has api: or openapi: in its frontmatter.
|
|
269
|
+
* API endpoint pages generate dynamic anchor IDs at render time (#param-*, etc.)
|
|
270
|
+
* that aren't in the MDX source, so fragment validation is skipped for them.
|
|
271
|
+
*/
|
|
272
|
+
function hasApiFrontmatter(content) {
|
|
273
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
274
|
+
if (!match) return false;
|
|
275
|
+
return /^(openapi|api):/m.test(match[1]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Build heading map for all navigation pages by reading files from disk.
|
|
280
|
+
* Used by CLI path (no cached fileContents available).
|
|
281
|
+
* Handles directory index pages: if "api" is in navigation but only api/index.mdx
|
|
282
|
+
* exists, stores headings under both "api" (navigation key) and "api/index"
|
|
283
|
+
* (resolved key used by getTargetHeadingSlugs).
|
|
284
|
+
* Skips API endpoint pages (they generate dynamic anchors at render time).
|
|
285
|
+
*/
|
|
286
|
+
function buildHeadingMapFromFiles(contentDir, navigationPages) {
|
|
287
|
+
const map = new Map();
|
|
288
|
+
for (const pagePath of navigationPages) {
|
|
289
|
+
const mdxPath = path.join(contentDir, pagePath + '.mdx');
|
|
290
|
+
const indexPath = path.join(contentDir, pagePath, 'index.mdx');
|
|
291
|
+
if (fs.existsSync(mdxPath)) {
|
|
292
|
+
const content = fs.readFileSync(mdxPath, 'utf8');
|
|
293
|
+
if (hasApiFrontmatter(content)) continue;
|
|
294
|
+
map.set(pagePath, extractHeadingSlugs(content));
|
|
295
|
+
} else if (fs.existsSync(indexPath)) {
|
|
296
|
+
const content = fs.readFileSync(indexPath, 'utf8');
|
|
297
|
+
if (hasApiFrontmatter(content)) continue;
|
|
298
|
+
const slugs = extractHeadingSlugs(content);
|
|
299
|
+
map.set(pagePath, slugs); // nav key: "api"
|
|
300
|
+
map.set(pagePath + '/index', slugs); // resolved key: "api/index"
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return map;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Look up heading slugs for a resolved target path.
|
|
308
|
+
* Derives the page path key from the resolved filesystem path.
|
|
309
|
+
* Falls back to pagePath/index for directory index pages.
|
|
310
|
+
*/
|
|
311
|
+
function getTargetHeadingSlugs(targetPath, contentDir, headingMap) {
|
|
312
|
+
let resolvedFile = null;
|
|
313
|
+
if (fs.existsSync(targetPath) && fs.statSync(targetPath).isFile()) {
|
|
314
|
+
resolvedFile = targetPath;
|
|
315
|
+
} else if (fs.existsSync(targetPath + '.mdx')) {
|
|
316
|
+
resolvedFile = targetPath + '.mdx';
|
|
317
|
+
} else if (fs.existsSync(path.join(targetPath, 'index.mdx'))) {
|
|
318
|
+
resolvedFile = path.join(targetPath, 'index.mdx');
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!resolvedFile) return null;
|
|
322
|
+
|
|
323
|
+
const pagePath = path.relative(contentDir, resolvedFile).replace(/\.mdx$/, '');
|
|
324
|
+
return headingMap.get(pagePath) || null;
|
|
325
|
+
}
|
|
326
|
+
|
|
194
327
|
/**
|
|
195
328
|
* Find all links in an MDX file and validate them
|
|
196
329
|
*/
|
|
197
|
-
function validateFile(filePath, contentDir, results) {
|
|
330
|
+
function validateFile(filePath, contentDir, results, headingMap) {
|
|
198
331
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
199
332
|
const relativePath = path.relative(contentDir, filePath);
|
|
200
|
-
|
|
201
|
-
// Get code block ranges to skip links inside them
|
|
333
|
+
const currentPagePath = relativePath.replace(/\.mdx$/, '');
|
|
202
334
|
const codeBlockRanges = getCodeBlockRanges(content);
|
|
203
335
|
|
|
204
336
|
for (const pattern of LINK_PATTERNS) {
|
|
205
|
-
// Reset regex state
|
|
206
337
|
pattern.lastIndex = 0;
|
|
207
|
-
|
|
208
338
|
let match;
|
|
209
339
|
while ((match = pattern.exec(content)) !== null) {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
// Calculate line number first (needed for code block check)
|
|
340
|
+
const rawHref = match[1];
|
|
213
341
|
const upToMatch = content.substring(0, match.index);
|
|
214
342
|
const lineNumber = upToMatch.split('\n').length;
|
|
215
343
|
|
|
216
|
-
// Skip links inside code blocks
|
|
217
344
|
if (isInCodeBlock(lineNumber, codeBlockRanges)) continue;
|
|
218
345
|
|
|
219
|
-
//
|
|
220
|
-
|
|
346
|
+
// Split href into path and fragment BEFORE any other processing
|
|
347
|
+
const { path: linkPath, fragment } = splitFragment(rawHref);
|
|
348
|
+
|
|
349
|
+
// Same-page fragment only (e.g., #heading)
|
|
350
|
+
// currentSlugs is null for API pages (skipped during heading map construction)
|
|
351
|
+
if (!linkPath && fragment) {
|
|
352
|
+
const currentSlugs = headingMap.get(currentPagePath);
|
|
353
|
+
if (currentSlugs && !currentSlugs.has(fragment)) {
|
|
354
|
+
results.push({
|
|
355
|
+
type: 'broken_link',
|
|
356
|
+
file: relativePath,
|
|
357
|
+
line: lineNumber,
|
|
358
|
+
link: rawHref,
|
|
359
|
+
message: 'Fragment #' + fragment + ' not found in headings',
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Has path component — validate path first
|
|
366
|
+
if (!linkPath) continue;
|
|
221
367
|
|
|
222
|
-
|
|
223
|
-
|
|
368
|
+
if (shouldSkipLink(linkPath)) continue;
|
|
369
|
+
|
|
370
|
+
const targetPath = resolveLink(linkPath, filePath, contentDir);
|
|
224
371
|
if (!targetPath) continue;
|
|
225
372
|
|
|
226
|
-
// Check if target exists
|
|
227
373
|
if (!targetExists(targetPath)) {
|
|
374
|
+
// Page missing — report without fragment message
|
|
228
375
|
results.push({
|
|
229
376
|
type: 'broken_link',
|
|
230
377
|
file: relativePath,
|
|
231
378
|
line: lineNumber,
|
|
232
|
-
link:
|
|
379
|
+
link: rawHref,
|
|
233
380
|
});
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Page exists + has fragment — validate fragment
|
|
385
|
+
if (fragment) {
|
|
386
|
+
const targetSlugs = getTargetHeadingSlugs(targetPath, contentDir, headingMap);
|
|
387
|
+
if (targetSlugs && !targetSlugs.has(fragment)) {
|
|
388
|
+
results.push({
|
|
389
|
+
type: 'broken_link',
|
|
390
|
+
file: relativePath,
|
|
391
|
+
line: lineNumber,
|
|
392
|
+
link: rawHref,
|
|
393
|
+
message: 'Fragment #' + fragment + ' not found in headings',
|
|
394
|
+
});
|
|
395
|
+
}
|
|
234
396
|
}
|
|
235
397
|
}
|
|
236
398
|
}
|
|
@@ -338,7 +500,8 @@ function getNavigationPages(contentDir) {
|
|
|
338
500
|
function validateNavigation(contentDir, navigationPages, results) {
|
|
339
501
|
for (const pagePath of navigationPages) {
|
|
340
502
|
const mdxPath = path.join(contentDir, pagePath + '.mdx');
|
|
341
|
-
|
|
503
|
+
const indexPath = path.join(contentDir, pagePath, 'index.mdx');
|
|
504
|
+
if (!fs.existsSync(mdxPath) && !fs.existsSync(indexPath)) {
|
|
342
505
|
results.push({
|
|
343
506
|
type: 'broken_link',
|
|
344
507
|
file: 'docs.json',
|
|
@@ -354,7 +517,8 @@ function validateNavigation(contentDir, navigationPages, results) {
|
|
|
354
517
|
* Only validates pages that are part of docs.json navigation.
|
|
355
518
|
* Files that exist but aren't in the navigation are ignored.
|
|
356
519
|
*/
|
|
357
|
-
function validateProject() {
|
|
520
|
+
function validateProject(options) {
|
|
521
|
+
options = options || {};
|
|
358
522
|
const contentDir = getProjectDir();
|
|
359
523
|
const results = [];
|
|
360
524
|
|
|
@@ -365,45 +529,49 @@ function validateProject() {
|
|
|
365
529
|
return results;
|
|
366
530
|
}
|
|
367
531
|
|
|
368
|
-
// Get all pages from docs.json navigation
|
|
369
532
|
const navigationPages = getNavigationPages(contentDir);
|
|
370
|
-
|
|
371
|
-
// Validate that all navigation pages exist
|
|
372
533
|
validateNavigation(contentDir, navigationPages, results);
|
|
373
534
|
|
|
374
|
-
//
|
|
375
|
-
|
|
535
|
+
// Build heading map: use provided one or build from files
|
|
536
|
+
const headingMap = options.headingMap || buildHeadingMapFromFiles(contentDir, navigationPages);
|
|
537
|
+
|
|
376
538
|
for (const pagePath of navigationPages) {
|
|
377
539
|
const mdxPath = path.join(contentDir, pagePath + '.mdx');
|
|
540
|
+
const indexPath = path.join(contentDir, pagePath, 'index.mdx');
|
|
378
541
|
if (fs.existsSync(mdxPath)) {
|
|
379
|
-
validateFile(mdxPath, contentDir, results);
|
|
542
|
+
validateFile(mdxPath, contentDir, results, headingMap);
|
|
543
|
+
} else if (fs.existsSync(indexPath)) {
|
|
544
|
+
validateFile(indexPath, contentDir, results, headingMap);
|
|
380
545
|
}
|
|
381
546
|
}
|
|
382
547
|
|
|
383
548
|
return results;
|
|
384
549
|
}
|
|
385
550
|
|
|
386
|
-
// Main execution
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
} else if (warnings.length > 0) {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
551
|
+
// Main execution (CLI mode only)
|
|
552
|
+
if (require.main === module) {
|
|
553
|
+
const warnings = validateProject();
|
|
554
|
+
|
|
555
|
+
if (jsonOutput) {
|
|
556
|
+
console.log(JSON.stringify(warnings, null, 2));
|
|
557
|
+
} else if (warnings.length > 0) {
|
|
558
|
+
console.log(`\n⚠️ Found ${warnings.length} broken internal link(s):\n`);
|
|
559
|
+
|
|
560
|
+
for (const w of warnings) {
|
|
561
|
+
const location = w.line ? `${w.file}:${w.line}` : w.file;
|
|
562
|
+
console.log(` ${location}`);
|
|
563
|
+
if (w.message) {
|
|
564
|
+
console.log(` ${w.message}: ${w.link}`);
|
|
565
|
+
} else {
|
|
566
|
+
console.log(` Missing page: ${w.link}`);
|
|
567
|
+
}
|
|
568
|
+
console.log('');
|
|
569
|
+
}
|
|
402
570
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
571
|
+
console.log(' Please check for typos, create missing pages, or fix broken anchors.\n');
|
|
572
|
+
} else {
|
|
573
|
+
console.log('✓ No broken internal links found');
|
|
574
|
+
}
|
|
407
575
|
}
|
|
408
576
|
|
|
409
577
|
// Export for programmatic use
|