Skip to content

Commit 985252f

Browse files
committed
feat: Add markdown-to-YAML conversion utilities for sidebar navigation
1 parent 0a7739a commit 985252f

File tree

2 files changed

+506
-0
lines changed

2 files changed

+506
-0
lines changed

docs/src/utils/markdownToYaml.js

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
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-zA-Z0-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

Comments
 (0)