1+ import yaml from 'js-yaml' ;
2+
3+ /**
4+ * Parses a Markdown navigation list (as generated by yamlToMarkdown) back into a JS object suitable for YAML serialization.
5+ * Handles:
6+ * - Headings (## ...)
7+ * - Paragraphs after heading as sidebar comments
8+ * - List items (- label, - _property_: value, - _tags_:, - _headings_:)
9+ * - Indentation for hierarchy
10+ * - Scalar properties (string, number, boolean)
11+ * - Tags arrays
12+ * - Headings arrays
13+ *
14+ * @param {string } markdown - The Markdown string to parse.
15+ * @returns {object } The reconstructed JS object (e.g., { sidebars: [...] })
16+ */
17+ export function markdownToYamlObject ( markdown ) {
18+ const lines = markdown . split ( '\n' ) ;
19+ const sidebars = [ ] ;
20+ let currentSidebar = null ;
21+ let parentsStack = [ ] ;
22+ let currentItems = null ;
23+
24+ // Helper to get indentation level (2 spaces per level)
25+ function getIndentLevel ( line ) {
26+ const match = line . match ( / ^ ( \s * ) / ) ;
27+ return match ? Math . floor ( ( match [ 1 ] . length ) / 2 ) : 0 ;
28+ }
29+
30+ // Helper to parse a scalar property line: - _property_: value
31+ function parseScalarProperty ( line ) {
32+ const match = line . match ( / ^ - _ ( [ a - z A - Z 0 - 9 _ ] + ) _ : ( .+ ) $ / ) ;
33+ if ( match ) {
34+ let value = match [ 2 ] . trim ( ) ;
35+ // Try to parse booleans and numbers
36+ if ( value === 'true' ) return [ match [ 1 ] , true ] ;
37+ if ( value === 'false' ) return [ match [ 1 ] , false ] ;
38+ if ( ! isNaN ( Number ( value ) ) ) return [ match [ 1 ] , Number ( value ) ] ;
39+ return [ match [ 1 ] , value ] ;
40+ }
41+ return null ;
42+ }
43+
44+ // Helper to parse a label line: - Label or - [Label](href)
45+ function parseLabel ( line ) {
46+ // Markdown link
47+ const linkMatch = line . match ( / ^ - \[ ( [ ^ \] ] + ) \] \( ( [ ^ ) ] + ) \) $ / ) ;
48+ if ( linkMatch ) {
49+ return { label : linkMatch [ 1 ] , href : linkMatch [ 2 ] } ;
50+ }
51+ // Plain label
52+ const labelMatch = line . match ( / ^ - ( .+ ) $ / ) ;
53+ if ( labelMatch ) {
54+ return { label : labelMatch [ 1 ] } ;
55+ }
56+ return null ;
57+ }
58+
59+ let i = 0 ;
60+ while ( i < lines . length ) {
61+ let line = lines [ i ] ;
62+ if ( line . trim ( ) === '' ) {
63+ i ++ ;
64+ continue ;
65+ }
66+ // Sidebar heading
67+ if ( line . startsWith ( '## ' ) ) {
68+ if ( currentSidebar ) {
69+ sidebars . push ( currentSidebar ) ;
70+ }
71+ currentSidebar = { label : line . replace ( / ^ # # / , '' ) . trim ( ) } ;
72+ currentItems = [ ] ;
73+ currentSidebar . items = currentItems ;
74+ parentsStack = [ { items : currentItems , obj : currentSidebar , level : 0 } ] ;
75+ i ++ ;
76+
77+ // Collect paragraphs as comments until first list item or next heading
78+ let comments = [ ] ;
79+ while ( i < lines . length ) {
80+ const nextLine = lines [ i ] ;
81+ if (
82+ nextLine . trim ( ) === '' ||
83+ nextLine . startsWith ( '- ' ) ||
84+ nextLine . startsWith ( '## ' )
85+ ) {
86+ break ;
87+ }
88+ comments . push ( nextLine . trim ( ) ) ;
89+ i ++ ;
90+ }
91+ if ( comments . length > 0 ) {
92+ currentSidebar . __comments = comments ;
93+ }
94+ continue ;
95+ }
96+
97+ // List item or property
98+ const indentLevel = getIndentLevel ( line ) ;
99+ const trimmed = line . trim ( ) ;
100+
101+ // _tags_ block
102+ if ( trimmed . startsWith ( '- _tags_:' ) ) {
103+ // Collect all subsequent indented lines as tags
104+ let tags = [ ] ;
105+ i ++ ;
106+ while ( i < lines . length ) {
107+ const tagLine = lines [ i ] ;
108+ if ( tagLine . trim ( ) . startsWith ( '- ' ) ) {
109+ tags . push ( tagLine . trim ( ) . replace ( / ^ - / , '' ) ) ;
110+ i ++ ;
111+ } else if ( tagLine . trim ( ) === '' ) {
112+ i ++ ;
113+ } else {
114+ break ;
115+ }
116+ }
117+ // Attach tags to the last item in the current parent
118+ let parent = parentsStack [ parentsStack . length - 1 ] ;
119+ if ( parent && parent . items && parent . items . length > 0 ) {
120+ parent . items [ parent . items . length - 1 ] . tags = tags ;
121+ }
122+ continue ;
123+ }
124+
125+ // _headings_ block
126+ if ( trimmed . startsWith ( '- _headings_:' ) ) {
127+ // All subsequent lines at higher indent are headings
128+ let headings = [ ] ;
129+ i ++ ;
130+ while ( i < lines . length ) {
131+ const headingLine = lines [ i ] ;
132+ if ( headingLine . trim ( ) . startsWith ( '- ' ) ) {
133+ // Recursively parse heading items
134+ const headingIndent = getIndentLevel ( headingLine ) ;
135+ const headingObj = parseLabel ( headingLine . trim ( ) ) ;
136+ if ( headingObj ) {
137+ headings . push ( headingObj ) ;
138+ // Check for scalar properties or tags under this heading
139+ let j = i + 1 ;
140+ while ( j < lines . length ) {
141+ const propLine = lines [ j ] ;
142+ if ( getIndentLevel ( propLine ) === headingIndent + 1 && propLine . trim ( ) . startsWith ( '- _' ) ) {
143+ // Scalar property
144+ const prop = parseScalarProperty ( propLine . trim ( ) ) ;
145+ if ( prop ) {
146+ headingObj [ prop [ 0 ] ] = prop [ 1 ] ;
147+ }
148+ j ++ ;
149+ } else if ( getIndentLevel ( propLine ) === headingIndent + 1 && propLine . trim ( ) . startsWith ( '- _tags_:' ) ) {
150+ // Tags under heading
151+ let tags = [ ] ;
152+ j ++ ;
153+ while ( j < lines . length ) {
154+ const tagLine = lines [ j ] ;
155+ if ( tagLine . trim ( ) . startsWith ( '- ' ) ) {
156+ tags . push ( tagLine . trim ( ) . replace ( / ^ - / , '' ) ) ;
157+ j ++ ;
158+ } else if ( tagLine . trim ( ) === '' ) {
159+ j ++ ;
160+ } else {
161+ break ;
162+ }
163+ }
164+ headingObj . tags = tags ;
165+ } else {
166+ break ;
167+ }
168+ }
169+ }
170+ i ++ ;
171+ } else if ( headingLine . trim ( ) === '' ) {
172+ i ++ ;
173+ } else {
174+ break ;
175+ }
176+ }
177+ // Attach headings to the last item in the current parent
178+ let parent = parentsStack [ parentsStack . length - 1 ] ;
179+ if ( parent && parent . items && parent . items . length > 0 ) {
180+ parent . items [ parent . items . length - 1 ] . headings = headings ;
181+ }
182+ continue ;
183+ }
184+
185+ // Scalar property
186+ if ( trimmed . startsWith ( '- _' ) && trimmed . includes ( ':' ) ) {
187+ const prop = parseScalarProperty ( trimmed ) ;
188+ if ( prop ) {
189+ // Attach to the last item in the current parent
190+ let parent = parentsStack [ parentsStack . length - 1 ] ;
191+ if ( parent && parent . items && parent . items . length > 0 ) {
192+ parent . items [ parent . items . length - 1 ] [ prop [ 0 ] ] = prop [ 1 ] ;
193+ } else if ( currentSidebar ) {
194+ // Attach to sidebar if not in items
195+ currentSidebar [ prop [ 0 ] ] = prop [ 1 ] ;
196+ }
197+ }
198+ i ++ ;
199+ continue ;
200+ }
201+
202+ // List item (label or link)
203+ if ( trimmed . startsWith ( '- ' ) ) {
204+ const obj = parseLabel ( trimmed ) ;
205+ // Find the correct parent based on indentation
206+ while ( parentsStack . length > 0 && parentsStack [ parentsStack . length - 1 ] . level >= indentLevel ) {
207+ parentsStack . pop ( ) ;
208+ }
209+ let parent = parentsStack [ parentsStack . length - 1 ] ;
210+ if ( parent && parent . items ) {
211+ parent . items . push ( obj ) ;
212+ }
213+ // Prepare for possible children
214+ obj . items = [ ] ;
215+ parentsStack . push ( { items : obj . items , obj, level : indentLevel } ) ;
216+ i ++ ;
217+ continue ;
218+ }
219+
220+ // Fallback
221+ i ++ ;
222+ }
223+
224+ // Push the last sidebar
225+ if ( currentSidebar ) {
226+ // Remove empty items arrays
227+ function clean ( obj ) {
228+ if ( Array . isArray ( obj . items ) && obj . items . length === 0 ) delete obj . items ;
229+ if ( Array . isArray ( obj . headings ) && obj . headings . length === 0 ) delete obj . headings ;
230+ if ( Array . isArray ( obj . tags ) && obj . tags . length === 0 ) delete obj . tags ;
231+ for ( const k in obj ) {
232+ if ( typeof obj [ k ] === 'object' ) clean ( obj [ k ] ) ;
233+ }
234+ }
235+ clean ( currentSidebar ) ;
236+ sidebars . push ( currentSidebar ) ;
237+ }
238+
239+ return { sidebars } ;
240+ }
241+
242+ /**
243+ * Converts Markdown navigation list (as generated by yamlToMarkdown) back to YAML string.
244+ * Sidebar-level comments (paragraphs after heading) are rendered as YAML comments.
245+ * Prepends YAML comments for markdown file location and timestamp.
246+ * @param {string } markdown - The Markdown string to parse.
247+ * @param {object } [options] - Optional metadata: { markdownFilePath: string }
248+ * @returns {string } YAML string.
249+ */
250+ export function convertMarkdownToYaml ( markdown , options = { } ) {
251+ const obj = markdownToYamlObject ( markdown ) ;
252+
253+ // Custom YAML dump to inject comments
254+ function dumpWithComments ( obj ) {
255+ // Only handle sidebars with possible __comments
256+ if ( ! obj . sidebars ) return yaml . dump ( obj , { noRefs : true , lineWidth : 120 } ) ;
257+
258+ let output = '' ;
259+
260+ // Metadata comments
261+ if ( options . markdownFilePath ) {
262+ output += `# Source Markdown: ${ options . markdownFilePath } \n` ;
263+ }
264+ output += `# Generated: ${ new Date ( ) . toISOString ( ) } \n` ;
265+
266+ output += 'sidebars:\n' ;
267+ for ( const sidebar of obj . sidebars ) {
268+ // Sidebar-level comments
269+ if ( sidebar . __comments && Array . isArray ( sidebar . __comments ) ) {
270+ for ( const comment of sidebar . __comments ) {
271+ output += ` # ${ comment } \n` ;
272+ }
273+ }
274+ // Dump the sidebar object, omitting __comments
275+ const { __comments, ...sidebarNoComments } = sidebar ;
276+ // Indent YAML output by 2 spaces
277+ let sidebarYaml = yaml . dump ( [ sidebarNoComments ] , { noRefs : true , lineWidth : 120 } ) ;
278+ // Remove the leading "- " and indent by 2 spaces
279+ sidebarYaml = sidebarYaml . replace ( / ^ - / , ' - ' ) ;
280+ sidebarYaml = sidebarYaml . replace ( / \n - / g, '\n -' ) ;
281+ output += sidebarYaml ;
282+ }
283+ return output ;
284+ }
285+
286+ return dumpWithComments ( obj ) ;
287+ }
0 commit comments