// parse.mbt - Markdown parsing with async file I/O
///|
/// Parse a markdown file and generate manifest
pub async fn parse_file(input_path : String, output_dir : String) -> Bool {
println("Parsing file: \{input_path}")
// Read the input file
let content = read_file(input_path)
// Split into pages (simplified - split by ---)
let pages = split_pages_simple(content)
println(" Found \{pages.length()} pages")
// Create output directories
create_dir_all(output_dir)
create_dir_all(audio_dir(output_dir))
// Process each page
let slides : Array[SlideInfo] = []
let videos : Array[VideoSegment] = []
for i, page in pages {
let slide_id = pad_slide_id(i + 1)
// Extract video markers and their SSMLs (if any)
let (page_videos, page_without_videos) = extract_videos(page)
// Extract slide SSML from the page (without video content)
let ssml = extract_ssml_simple(page_without_videos)
let has_audio = !ssml.is_empty()
let ssml_hash = if has_audio { hash_content(ssml) } else { "" }
slides.push({ id: slide_id, ssml_hash, has_audio, })
// Save slide SSML if present
if has_audio {
let ssml_file = slide_ssml_path(output_dir, slide_id)
write_file(ssml_file, ssml)
println(" [+] slide-\{slide_id}: Saved SSML (\{ssml.length()} chars)")
} else {
println(" [ ] slide-\{slide_id}: No audio")
}
// Handle video insertions for this slide
for video_idx, video_info in page_videos {
let has_video_audio = !video_info.ssml.is_empty()
let video_ssml_hash = if has_video_audio {
hash_content(video_info.ssml)
} else {
""
}
videos.push({
after_slide: slide_id,
video_path: video_info.path,
ssml_hash: video_ssml_hash,
has_audio: has_video_audio,
})
// Save video SSML if present (always use indexed naming)
if has_video_audio {
let video_ssml_file = video_ssml_path_indexed(
output_dir,
slide_id,
video_idx + 1,
)
write_file(video_ssml_file, video_info.ssml)
println(
" [VIDEO] Insert video after slide-\{slide_id}: \{video_info.path} (with audio, \{video_info.ssml.length()} chars)",
)
} else {
println(
" [VIDEO] Insert video after slide-\{slide_id}: \{video_info.path}",
)
}
}
}
// Create manifest
let manifest : Manifest = { slides, videos, }
// Save manifest as JSON
let manifest_file = manifest_path(output_dir)
let manifest_json = Json::stringify(manifest.to_json())
write_file(manifest_file, manifest_json)
println(" [+] Saved manifest.json")
println("\nParsing complete!")
println(" Output: \{output_dir}")
true
}
///|
/// Simple page splitting by --- delimiter and headingDivider
fn split_pages_simple(content : String) -> Array[String] {
let result : Array[String] = []
let lines : Array[String] = []
for view in content.split("\n") {
lines.push(view.to_owned())
}
// Check if headingDivider: 1 is in frontmatter
let mut has_heading_divider = false
let mut in_frontmatter = true
let mut frontmatter_count = 0
for line in lines {
if line.trim(char_set=" \t") == "---" {
frontmatter_count += 1
if frontmatter_count == 2 {
break
}
}
if frontmatter_count == 1 && line.contains("headingDivider: 1") {
has_heading_divider = true
}
}
let current_page : Array[String] = []
in_frontmatter = true
frontmatter_count = 0
for line in lines {
// Handle frontmatter - skip it entirely when using headingDivider
if line.trim(char_set=" \t") == "---" {
if in_frontmatter {
frontmatter_count += 1
if frontmatter_count == 2 {
in_frontmatter = false
}
// Don't add frontmatter lines when using headingDivider
if !has_heading_divider {
current_page.push(line)
}
continue
} else {
// Page separator (only used when NOT using headingDivider)
if current_page.length() > 0 {
result.push(current_page.join("\n"))
current_page.clear()
}
continue
}
}
// Skip frontmatter content when using headingDivider
if in_frontmatter && has_heading_divider {
continue
}
// If headingDivider: 1, split on "# " headings
if !in_frontmatter && has_heading_divider && line.has_prefix("# ") {
// Only save previous page if it has substantial content (not just whitespace)
if current_page.length() > 0 {
let has_content = current_page
.iter()
.any(fn(l) { !l.trim(char_set=" \t\n").is_empty() })
if has_content {
result.push(current_page.join("\n"))
}
current_page.clear()
}
}
current_page.push(line)
}
// Add last page
if current_page.length() > 0 {
result.push(current_page.join("\n"))
}
result
}
///|
/// Extract SSML from page content
fn extract_ssml_simple(page : String) -> String {
let mut result = ""
let mut in_ssml = false
let lines : Array[String] = []
for view in page.split("\n") {
lines.push(view.to_owned())
}
for line in lines {
let trimmed = line.trim(char_set=" \t")
if trimmed == "" && in_ssml {
in_ssml = false
continue
}
if in_ssml {
result = result + line + "\n"
}
}
result.trim(char_set=" \t\n").to_owned()
}
///|
/// Video info extracted from a page
priv struct VideoInfo {
path : String
ssml : String
}
///|
/// Extract video markers and optional SSMLs from page
/// Returns (videos, page_without_videos)
fn extract_videos(page : String) -> (Array[VideoInfo], String) {
// Look for:
// - (no SSML)
// - (with SSML)
let lines : Array[String] = []
for view in page.split("\n") {
lines.push(view.to_owned())
}
let videos : Array[VideoInfo] = []
let mut current_video_path = ""
let mut current_video_ssml = ""
let mut in_video_ssml = false
let result_lines : Array[String] = []
let mut i = 0
while i < lines.length() {
let line = lines[i]
let trimmed = line.trim(char_set=" \t")
// Check for video marker with SSML:
if trimmed.has_prefix("") {
// Extract video path:
let start = "".length()
guard start <= end else {
i += 1
continue
}
let path_str = trimmed[start:end].to_owned()
let video_path = path_str.trim(char_set=" \t").to_owned()
videos.push({ path: video_path, ssml: "", })
i += 1
continue
}
// If in video SSML block, collect until -->
if in_video_ssml {
if trimmed == "-->" {
// End of SSML block - save video
videos.push({
path: current_video_path,
ssml: current_video_ssml.trim(char_set=" \t\n").to_owned(),
})
current_video_path = ""
current_video_ssml = ""
in_video_ssml = false
i += 1
continue
}
current_video_ssml = current_video_ssml + line + "\n"
i += 1
continue
}
// Keep lines that aren't video-related
result_lines.push(line)
i += 1
}
let page_without_videos = result_lines.join("\n")
(videos, page_without_videos)
}
///|
/// Get course name from input file path
pub fn get_course_name_from_path(input_file : String) -> String {
// Get the filename without path
let file_name = basename(input_file)
// Remove .mbt.md or .md extension
let mut base_name = file_name
if base_name.has_suffix(".mbt.md") {
let len = base_name.length()
base_name = base_name[0:len - 7].to_owned()
} else if base_name.has_suffix(".md") {
let len = base_name.length()
base_name = base_name[0:len - 3].to_owned()
}
// Use the base filename as the target directory name
// Examples:
// lec7-1.mbt.md -> target/lec7-1/
// lec7-2.mbt.md -> target/lec7-2/
// slides.md -> target/slides/
base_name
}
///|
extend Manifest with ToJson::{to_json}