import Foundation import Yams /// One `index.md`: YAML frontmatter between `---` delimiters, then the Markdown body. /// /// The document keeps the file's raw text and edits it by line span, so anything the caller /// does not touch is byte-identical on the way out — including unknown keys, their order, /// comments, blank lines, and the body. YAML is used to *validate and read*, never to write. public struct FrontmatterDocument: Sendable, Equatable { /// A contiguous run of frontmatter lines. `key == nil` is a preamble/orphan run of /// comments or blank lines. `tail` is the trailing comment/blank lines the run picked up — /// held separately so `set`/`remove` never disturb them. private struct Span: Sendable, Equatable { var key: String? var value: String var tail: String var text: String { value + tail } } private struct KeyedValue: Sendable, Equatable { var key: String var value: YAMLValue } private var openingDelimiter: String private var closingDelimiter: String private var spans: [Span] private var values: [KeyedValue] /// Everything after the closing delimiter line, verbatim. public var body: String /// An empty document, for files the app is creating rather than rewriting. public init(body: String = "") { openingDelimiter = "---\n" closingDelimiter = "---\n" spans = [] values = [] self.body = body } private init(openingDelimiter: String, closingDelimiter: String, spans: [Span], values: [KeyedValue], body: String) { self.openingDelimiter = openingDelimiter self.closingDelimiter = closingDelimiter self.spans = spans self.values = values self.body = body } // MARK: - Parsing public static func parse(_ text: String) throws(FrontmatterError) -> FrontmatterDocument { let lines = Self.lines(of: text) guard let first = lines.first, Self.isDelimiter(first) else { throw .missingOpeningDelimiter } guard let closingIndex = lines.dropFirst().firstIndex(where: Self.isDelimiter) else { throw .missingClosingDelimiter } let yamlLines = Array(lines[1 ..< closingIndex]) let node: Node? do { node = try Yams.compose(yaml: yamlLines.joined()) } catch let error as YamlError { throw .unparseableYAML(message: "\(error)", line: Self.fileLine(of: error)) } catch { throw .unparseableYAML(message: "\(error)", line: nil) } let mapping: Node.Mapping switch node { case .none: mapping = Node.Mapping([]) case let .some(root): if let rootMapping = root.mapping { mapping = rootMapping } else if Resolver.default.resolveTag(of: root) == .null { mapping = Node.Mapping([]) } else { throw .frontmatterNotAMapping } } let keys = mapping.map { $0.key.string } let values = zip(keys, mapping).compactMap { key, pair -> KeyedValue? in key.map { KeyedValue(key: $0, value: YAMLValue(pair.value)) } } return FrontmatterDocument( openingDelimiter: first, closingDelimiter: lines[closingIndex], spans: Self.makeSpans(lines: yamlLines, keys: keys), values: values, body: lines[(closingIndex + 1)...].joined() ) } public func serialized() -> String { openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body } // MARK: - Generic access /// Top-level keys in document order. public var keys: [String] { values.map(\.key) } public var fields: [FrontmatterField] { values.map { FrontmatterField(key: $0.key, value: $0.value, rawValue: rawValue(for: $0.key) ?? $0.value.description) } } /// Every key the schema does not own — reserved names (`labels`, `due`, `template`, …) and /// agent overlays alike. Preserved verbatim and never interpreted. public var unknownFields: [FrontmatterField] { fields.filter { !FrontmatterKeys.schemaOwned.contains($0.key) } } public func contains(_ key: String) -> Bool { values.contains { $0.key == key } } public func value(for key: String) -> YAMLValue? { values.first { $0.key == key }?.value } /// The key's value text exactly as written, minus the `key:` header and surrounding whitespace. public func rawValue(for key: String) -> String? { spans.first { $0.key == key }.map { Self.valueText(of: $0.value, key: key) } } // MARK: - Surgical edits /// Rewrites only this key's value lines. A new key is appended before the closing delimiter. public mutating func set(_ key: String, to value: FrontmatterValue) { if let index = spans.firstIndex(where: { $0.key == key }) { let keyText = Self.keyText(of: spans[index].value, key: key) spans[index].value = "\(keyText): \(value.yamlText)\n" } else { spans.append(Span(key: key, value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\n", tail: "")) } let parsed = KeyedValue(key: key, value: value.parsedValue) if let index = values.firstIndex(where: { $0.key == key }) { values[index] = parsed } else { values.append(parsed) } } /// Deletes the key's value lines. Trailing comments and blank lines survive — they read as /// belonging to whatever follows, and losing user text is worse than an orphaned comment. public mutating func remove(_ key: String) { if let index = spans.firstIndex(where: { $0.key == key }) { if spans[index].tail.isEmpty { spans.remove(at: index) } else { spans[index] = Span(key: nil, value: "", tail: spans[index].tail) } } values.removeAll { $0.key == key } } // MARK: - Line splitting private static func lines(of text: String) -> [String] { let parts = text.components(separatedBy: "\n") var lines = parts.dropLast().map { $0 + "\n" } if let last = parts.last, !last.isEmpty { lines.append(last) } return lines } private static func isDelimiter(_ line: String) -> Bool { var trimmed = Substring(line) while let last = trimmed.last, last.isWhitespace { trimmed = trimmed.dropLast() } return trimmed == "---" } /// Splits the YAML block into per-key spans. A line only starts a span when it opens the key /// the parser expects next, so an unindented value continuation (multi-line flow collections) /// can never be mistaken for a key. private static func makeSpans(lines: [String], keys: [String?]) -> [Span] { var spans: [Span] = [] var current: Span? var preamble = "" var expected = 0 func flush() { if let current { spans.append(current) } else if !preamble.isEmpty { spans.append(Span(key: nil, value: "", tail: preamble)) } } for line in lines { if expected < keys.count, let key = keys[expected], lineStartsKey(line, key: key) { flush() preamble = "" current = Span(key: key, value: line, tail: "") expected += 1 } else if var span = current { if isTailLine(line) { span.tail += line } else { span.value += span.tail + line span.tail = "" } current = span } else { preamble += line } } flush() return spans } private static func isTailLine(_ line: String) -> Bool { line.hasPrefix("#") || line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private static func keyCandidates(_ key: String) -> [String] { [key, "\"\(key)\"", "'\(key)'"] } private static func lineStartsKey(_ line: String, key: String) -> Bool { for candidate in keyCandidates(key) { let prefix = candidate + ":" guard line.hasPrefix(prefix) else { continue } let rest = line.dropFirst(prefix.count) if rest.isEmpty || rest.first?.isWhitespace == true { return true } } return false } private static func keyText(of spanValue: String, key: String) -> String { keyCandidates(key).first { spanValue.hasPrefix($0 + ":") } ?? FrontmatterValue.emitScalar(key) } private static func valueText(of spanValue: String, key: String) -> String { let header = keyText(of: spanValue, key: key) + ":" guard spanValue.hasPrefix(header) else { return spanValue.trimmingCharacters(in: .whitespacesAndNewlines) } return spanValue.dropFirst(header.count).trimmingCharacters(in: .whitespacesAndNewlines) } private static func fileLine(of error: YamlError) -> Int? { // +1 for the opening delimiter line, which is not part of the YAML handed to Yams. switch error { case let .scanner(_, _, mark, _), let .parser(_, _, mark, _), let .composer(_, _, mark, _): mark.line + 1 case let .duplicatedKeysInMapping(_, context): context.mark.line + 1 default: nil } } } /// The keys the schema owns (01-storage-format.md § Frontmatter). Everything else is an /// unknown key: round-tripped untouched, never interpreted. public enum FrontmatterKeys { public static let schema = "schema" public static let title = "title" public static let order = "order" public static let width = "width" public static let created = "created" public static let modified = "modified" public static let modifiedBy = "modified-by" public static let deleted = "deleted" public static let background = "background" public static let icon = "icon" public static let iconColor = "iconColor" public static let schemaOwned: Set = [ schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor ] }