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 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.28',
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.0',
74
- '@tailwindcss/postcss': '^4.2.0',
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.12",
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.1.0",
96
+ "@inquirer/prompts": "^8.3.0",
97
97
  "ajv": "^8.17.1",
98
98
  "chalk": "^5.3.0",
99
99
  "commander": "^14.0.3",
@@ -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 - built-in tracking for all documentation sites
283
- // Tracks page views and sends to dashboard analytics endpoint
284
- const analyticsEnabled = config.analytics?.enabled !== false; // Default true
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
- {analyticsEnabled && (
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
+ }
@@ -68,7 +68,7 @@ export function generateTableOfContents(content: string): { id: string; text: st
68
68
  const id = text
69
69
  .toLowerCase()
70
70
  .replace(/[^a-z0-9]+/g, '-')
71
- .replace(/(^-|-$)/g, '');
71
+ .replace(/^-+|-+$/g, '');
72
72
 
73
73
  headings.push({ id, text, level });
74
74
  }
@@ -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 the href
69
- /\[(?:[^\]]*)\]\(([^)#\s][^)#]*)\)/g,
68
+ // Markdown links: [text](href) - capture href including optional #fragment
69
+ /\[(?:[^\]]*)\]\(([^)\s]+)\)/g,
70
70
  // JSX href attribute: href="..." or href='...'
71
- /href=["']([^"'#\s][^"'#]*)["']/g,
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.startsWith('#')) return true;
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 href = match[1];
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
- // Skip links we shouldn't validate
220
- if (shouldSkipLink(href)) continue;
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
- // Resolve the link to a file path
223
- const targetPath = resolveLink(href, filePath, contentDir);
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: href,
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
- if (!fs.existsSync(mdxPath)) {
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
- // Only validate links within files that are part of the navigation
375
- // This ignores orphan files (like ar/ translations not in docs.json)
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
- const warnings = validateProject();
388
-
389
- if (jsonOutput) {
390
- // Output JSON for programmatic use
391
- console.log(JSON.stringify(warnings, null, 2));
392
- } else if (warnings.length > 0) {
393
- // Human-readable output
394
- console.log(`\n⚠️ Found ${warnings.length} broken internal link(s):\n`);
395
-
396
- for (const w of warnings) {
397
- const location = w.line ? `${w.file}:${w.line}` : w.file;
398
- console.log(` ${location}`);
399
- console.log(` Missing page: ${w.link}`);
400
- console.log('');
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
- console.log(' These pages are referenced but do not exist.');
404
- console.log(' Please check for typos or create the missing pages.\n');
405
- } else {
406
- console.log('✓ No broken internal links found');
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