11import path from "node:path" ;
22import { glob } from "glob" ;
3+ import type { RootContent } from "hast" ;
34import type { LucideIcon } from "lucide-react" ;
5+ import type { Heading , PhrasingContent , Text } from "mdast" ;
6+ import { fromMarkdown } from "mdast-util-from-markdown" ;
7+ import { mdxFromMarkdown } from "mdast-util-mdx" ;
8+ import type { MdxJsxTextElement } from "mdast-util-mdx-jsx" ;
9+ import { mdxjs } from "micromark-extension-mdxjs" ;
410import { readFrontmatter } from "../../lib/util/readFrontmatter.js" ;
511import type { ConfigWithMeta } from "../loader.js" ;
612import type {
@@ -29,7 +35,12 @@ type FinalNavigationCategoryLinkDoc = Extract<
2935
3036export type NavigationDoc = ReplaceFields <
3137 FinalNavigationDoc ,
32- { label : string ; categoryLabel ?: string ; path : string } & ResolvedIcon
38+ {
39+ label : string ;
40+ categoryLabel ?: string ;
41+ path : string ;
42+ rich ?: RootContent [ ] ;
43+ } & ResolvedIcon
3344> ;
3445
3546export type NavigationLink = ReplaceFields < InputNavigationLink , ResolvedIcon > ;
@@ -68,6 +79,49 @@ export type Navigation = NavigationItem[];
6879const extractTitleFromContent = ( content : string ) : string | undefined =>
6980 content . match ( / ^ \s * # \s ( .* ) $ / m) ?. at ( 1 ) ;
7081
82+ // MDX extends PhrasingContent with JSX elements
83+ type MdxPhrasingContent = PhrasingContent | MdxJsxTextElement ;
84+
85+ const isMdxJsxElement = ( node : MdxPhrasingContent ) : node is MdxJsxTextElement =>
86+ node . type === "mdxJsxTextElement" ;
87+
88+ const isTextNode = ( node : MdxPhrasingContent ) : node is Text =>
89+ node . type === "text" ;
90+
91+ // Extract rich H1 heading content from MDX. Returns AST nodes only when H1 contains JSX elements.
92+ const extractRichH1 = ( content : string ) => {
93+ try {
94+ const mdast = fromMarkdown ( content , {
95+ extensions : [ mdxjs ( ) ] ,
96+ mdastExtensions : [ mdxFromMarkdown ( ) ] ,
97+ // biome-ignore lint/suspicious/noExplicitAny: mdast-util-from-markdown has type incompatibilities between versions
98+ } as any ) ;
99+
100+ const h1 = mdast . children . find (
101+ ( node ) : node is Heading => node . type === "heading" && node . depth === 1 ,
102+ ) ;
103+
104+ if ( ! h1 ) return undefined ;
105+
106+ const hasJsx = h1 . children . some ( isMdxJsxElement ) ;
107+
108+ // Extract plain text label
109+ const plainLabel = h1 . children
110+ . filter ( isTextNode )
111+ . map ( ( node ) => node . value )
112+ . join ( "" )
113+ . trim ( ) ;
114+
115+ // MDAST text nodes and MDX JSX nodes are structurally compatible with HAST
116+ // RichText handles both via toJsxRuntime and custom mdxJsx handling
117+ return hasJsx
118+ ? { label : plainLabel , rich : h1 . children as RootContent [ ] }
119+ : { label : plainLabel } ;
120+ } catch {
121+ return undefined ;
122+ }
123+ } ;
124+
71125const isNavigationItem = ( item : unknown ) : item is NavigationItem =>
72126 item !== undefined ;
73127
@@ -119,10 +173,13 @@ export class NavigationResolver {
119173
120174 const { data, content } = await readFrontmatter ( foundMatches ) ;
121175
176+ const richH1 = extractRichH1 ( content ) ;
177+
122178 const label =
123179 data . navigation_label ??
124180 data . sidebar_label ??
125181 data . title ??
182+ richH1 ?. label ??
126183 extractTitleFromContent ( content ) ??
127184 filePath ;
128185
@@ -136,6 +193,7 @@ export class NavigationResolver {
136193 display : data . navigation_display ,
137194 categoryLabel,
138195 path : fileNoExt ,
196+ rich : richH1 ?. rich ,
139197 } satisfies NavigationDoc ;
140198
141199 return doc ;
0 commit comments