// // ExerciseInfo.swift // Workouts // // Copyright 2026 Rouslan Zenetl. All Rights Reserved. // import Foundation /// Parsed form of an exercise's authored `info.md` (see `Exercise Library/SYSTEM.md`): /// a one-paragraph summary, the Category/Type/Targets metadata bullets, then the /// instructional sections (Setup, Execution, Cues, Common Mistakes, Progression…). /// The library's markdown is uniform, so this parses the known shape rather than /// general Markdown — SwiftUI's inline-Markdown `Text` can't render headers/lists, /// and the app wants the structure (target chips, numbered steps) anyway. struct ExerciseInfo { /// The intro paragraph(s) between the `# Title` and the metadata bullets. var summary: String var category: String? var type: String? /// The comma-separated `**Targets:**` values, split for chip rendering. var targets: [String] var sections: [Section] /// Whether the authored `Type:` identifies this as a machine exercise — the /// library convention is that every machine entry's type starts "Machine-based". /// Drives the machine-settings sections' visibility before any settings exist. var isMachineBased: Bool { type?.localizedCaseInsensitiveContains("machine") == true } struct Section { var title: String var items: [Item] } /// One block within a section, preserving the authored list flavor: numbered /// steps render with their ordinal, bullets with a dot, paragraphs plain. enum Item { case step(String) case bullet(String) case paragraph(String) var text: String { switch self { case .step(let s), .bullet(let s), .paragraph(let s): s } } } } extension ExerciseInfo { /// Parse the library's `info.md` shape. Hard-wrapped lines are rejoined: a line /// that doesn't start a new block (heading, list marker, blank) continues the /// previous one, matching how Markdown renders soft breaks. static func parse(markdown: String) -> ExerciseInfo { var summaryLines: [String] = [] var category: String? var type: String? var targets: [String] = [] var sections: [Section] = [] var currentSection: Section? var currentItem: Item? func metadataValue(_ line: String, key: String) -> String? { let prefix = "- **\(key):**" guard line.hasPrefix(prefix) else { return nil } return String(line.dropFirst(prefix.count)).trimmingCharacters(in: .whitespaces) } func flushItem() { guard let item = currentItem else { return } currentItem = nil if currentSection != nil { currentSection?.items.append(item) } else if case .paragraph(let text) = item { summaryLines.append(text) } } func flushSection() { flushItem() if let section = currentSection { sections.append(section) } currentSection = nil } func append(to item: Item?, _ text: String) -> Item? { switch item { case .step(let s): .step(s + " " + text) case .bullet(let s): .bullet(s + " " + text) case .paragraph(let s): .paragraph(s + " " + text) case nil: .paragraph(text) } } for rawLine in markdown.components(separatedBy: .newlines) { let line = rawLine.trimmingCharacters(in: .whitespaces) if line.isEmpty { flushItem() } else if line.hasPrefix("## ") { flushSection() currentSection = Section(title: String(line.dropFirst(3)), items: []) } else if line.hasPrefix("# ") { continue // the document title duplicates the exercise name } else if currentSection == nil, let value = metadataValue(line, key: "Category") { flushItem() category = value } else if currentSection == nil, let value = metadataValue(line, key: "Type") { flushItem() type = value } else if currentSection == nil, let value = metadataValue(line, key: "Targets") { flushItem() targets = value.components(separatedBy: ",") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } else if let range = line.range(of: #"^\d+\.\s+"#, options: .regularExpression) { flushItem() currentItem = .step(String(line[range.upperBound...])) } else if line.hasPrefix("- ") { flushItem() currentItem = .bullet(String(line.dropFirst(2))) } else { // Continuation of a hard-wrapped block (or the start of a paragraph). currentItem = append(to: currentItem, line) } } flushSection() return ExerciseInfo( summary: summaryLines.joined(separator: "\n\n"), category: category, type: type, targets: targets, sections: sections ) } } /// Finds and parses the bundled `.info.md` exported next to the /// motion rigs by `render.py --export`. enum ExerciseInfoLibrary { /// The parsed info for `exerciseName`, or `nil` when none is bundled. static func info(for exerciseName: String) -> ExerciseInfo? { guard let url = Bundle.main.url(forResource: exerciseName, withExtension: "info.md"), let markdown = try? String(contentsOf: url, encoding: .utf8) else { return nil } return ExerciseInfo.parse(markdown: markdown) } }