import Foundation import Markdown // MARK: - BodyMarkup /// A card body parsed into the shapes Preview renders — **the whole of the Markdown subset /// 05-card-window.md ▸ Preview settles, and nothing else**. /// /// ### Why a model at all, rather than parser → attributed string /// /// Three of Preview's rules are decisions, not drawing, and every one of them is invisible in a /// pile of `NSAttributedString` attributes: /// /// - **HTML is literal text.** `x` in a body is code-styled characters, never a rendered /// bold — "never interpreted — no web view, per 00-vision.md's no-web-tech stance". That is a /// *classification*, and `BodyInline.html` is where it is made once. /// - **An image is local or it is not.** A relative path resolves against the card's own folder and /// renders inline; anything carrying a URL scheme is **never fetched** — Preview does no /// networking — and renders as a quiet placeholder chip. `BodyTarget` is that fork, and it is the /// same fork a link takes (browser vs. default app), which is why one type serves both. /// - **A task checkbox knows where it came from.** Clicking one flips *exactly that character* in /// the source, every other byte untouched, so the model has to carry the source offset the write /// will aim at (`BodyTask.markerOffset`) — a render that lost it could only re-serialize the /// whole body, which is precisely what the storage contract forbids. /// /// Keeping those three in a value type also makes them testable without a window, which is the /// other half of the reason: `BodyMarkupTests` asserts the mapping construct by construct, and the /// renderer beneath it is then only ever wrong about *typography*. /// /// ### Offsets are UTF-8 byte offsets into the body /// /// Not `String.Index`, not character counts. Two reasons, and they agree: /// swift-markdown's `SourceLocation.column` is itself "the number of bytes in UTF-8 encoding from /// the start of the line", so byte offsets are the units the parser already speaks; and the write /// this model feeds is a **byte** edit (`BodyMarkup.flippingTaskMarker`), so anything else would /// have to be converted at the one point where being off by one corrupts a file. /// /// Offsets are into the **body** — the text after the frontmatter's closing delimiter, which is /// exactly `FrontmatterDocument.body` — never into the whole `index.md`. public struct BodyMarkup: Equatable, Sendable { /// The body's top-level blocks, in document order. public let blocks: [BodyBlock] public init(blocks: [BodyBlock]) { self.blocks = blocks } /// Whether there is nothing to preview — **the mode rule's input** (05 ▸ Mode grammar: a card /// opens in Preview "unless its body is empty, which opens straight into Edit"). Whitespace /// counts as empty: a body of one newline previews as a blank page, and sending the user to a /// blank *preview* of a blank body is the ceremony the rule exists to remove. public static func isEmpty(_ body: String) -> Bool { body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } /// Parses a card body. /// /// **Total** — there is no error case. A body is whatever the user (or an agent, or a hand /// edit) put there; CommonMark has no parse failures, and a file that reached this point /// already passed the loader's strict UTF-8 and frontmatter gates. Anything the subset does not /// model degrades to its plain text rather than disappearing. /// /// **Smart typography is off** (`.disableSmartOpts`). Preview renders the bytes on disk: a /// `--` that silently became an en dash would be a preview of a document the file does not /// contain, and would also put the rendered text out of step with ⌘F over it. public static func parse(_ body: String) -> BodyMarkup { let document = Document(parsing: body, options: [.disableSmartOpts]) let builder = Builder(body: body) return BodyMarkup(blocks: builder.blocks(of: document)) } } // MARK: - Blocks /// A span of the body, in UTF-8 byte offsets — `start..` is five code-styled characters. case html(String) /// A hard break (two trailing spaces, or a backslash). case lineBreak /// An ordinary newline inside a paragraph. case softBreak case link(target: BodyTarget, inlines: [BodyInline]) case image(BodyImage) } /// An image reference. `alt` is the bracket text — the placeholder chip's label for an image /// Preview will not fetch, and the accessibility description for one it will. public struct BodyImage: Equatable, Sendable { public let target: BodyTarget public let alt: String public let title: String? public init(target: BodyTarget, alt: String, title: String? = nil) { self.target = target self.alt = alt self.title = title } } /// Where a link or an image points — **the one classification both need**, and the reason it is /// one type: a link and an image ask the same question of a destination (does it carry a URL /// scheme?) and act on the two answers differently. /// /// - `.absolute` — the destination has a scheme: `https:`, `mailto:`, `file:`, `data:`. A **link** /// opens it with the system (external URLs open in the browser); an **image** is *never fetched* /// — Preview does no networking, sandbox-quiet and files-first — and renders as a quiet /// placeholder chip carrying its alt text or the URL (05 ▸ Preview). /// - `.relative` — no scheme: a path resolved against the card's own folder. This is the supported /// image story (`![](attachments/shot.png)` renders inline) and, for links, the "open the target /// file with its default app" path. /// /// An empty destination is `.relative("")`: it names nothing, resolves to nothing, and both /// surfaces already have to cope with a path that does not exist on disk. public enum BodyTarget: Equatable, Sendable { case absolute(String) case relative(String) /// The classifier itself. A scheme is what `URLComponents` finds *and* what a human would call /// one: `URL(string:)` alone would happily read `attachments/shot.png` as a relative URL and /// report no scheme, which is the answer we want, but it also tolerates shapes that differ /// between OS versions — asking for the scheme explicitly keeps the question narrow. /// /// A Windows-style `C:\path` is *not* treated as a scheme: a single-letter scheme is /// vanishingly unlikely to be a real URL and overwhelmingly likely to be a path. public static func classify(_ destination: String?) -> BodyTarget { let text = destination ?? "" guard let scheme = URLComponents(string: text)?.scheme, scheme.count > 1 else { return .relative(text) } return .absolute(text) } /// The destination as written, whichever case it took. public var text: String { switch self { case let .absolute(text), let .relative(text): text } } /// The URL this destination names, given the card's own folder — the **one** place a body's /// text becomes something the system can be asked to open or read. /// /// - `.absolute` is handed to `URL(string:)` as written. A malformed one is `nil`, which is /// the honest answer: a link that is not a URL opens nothing. /// - `.relative` resolves against `cardFolder` — 05-card-window.md's rule for both images /// ("resolved against the card's own folder") and links ("relative links open the target file /// with its default app … resolved against the card folder, like images"). Percent-encoding /// is undone first, because `attachments/my%20shot.png` names a file with a space in it. /// - A **rooted** path (`/Users/…`) is taken as the absolute file path it plainly is rather /// than being glued onto the card folder, which would name a file nobody meant. /// /// `nil` for an empty destination and for a relative one with no card folder to stand on. /// Deliberately **not** confined to the card's folder: `../sibling/notes.md` is a link a /// files-first app has no business silently refusing, and the app opens files with the system /// rather than reading them into itself. func resolve(inCardFolder cardFolder: URL?) -> URL? { switch self { case let .absolute(text): return URL(string: text) case let .relative(text): guard !text.isEmpty else { return nil } let decoded = text.removingPercentEncoding ?? text if decoded.hasPrefix("/") { return URL(fileURLWithPath: decoded).standardizedFileURL } guard let cardFolder else { return nil } return URL(fileURLWithPath: decoded, relativeTo: cardFolder).standardizedFileURL } } } // MARK: - Building the model /// The swift-markdown → `BodyMarkup` conversion, kept private so the model above is the only /// vocabulary anything else sees. A `Markup` tree is a reference-flavoured API with a `_data` /// escape hatch on every node; letting it past this file would put a second, richer, mutable /// representation of a card body into the app for no gain. private struct Builder { /// The body's bytes, and where each 1-based line starts in them — the two facts every /// `SourceLocation` → byte-offset conversion needs, computed once for the whole parse. private let utf8: [UInt8] private let lineStarts: [Int] init(body: String) { utf8 = Array(body.utf8) lineStarts = Self.lineStarts(of: utf8) } /// Byte offsets at which each line begins, `lineStarts[0]` being line 1. /// /// Every ending is honoured — `\n`, `\r\n`, and a lone `\r` — because a card body is whatever /// its author's editor writes and "line endings are preserved per line, never normalized" /// (01-storage-format.md ▸ Fractal layout ▸ Rules). A CRLF file's `\r` sits at the end of the /// line it terminates, which is exactly where cmark's columns leave it. private static func lineStarts(of utf8: [UInt8]) -> [Int] { var starts = [0] var index = 0 while index < utf8.count { if utf8[index] == 0x0A { starts.append(index + 1) } else if utf8[index] == 0x0D { let isCRLF = index + 1 < utf8.count && utf8[index + 1] == 0x0A starts.append(index + (isCRLF ? 2 : 1)) if isCRLF { index += 1 } } index += 1 } return starts } /// A parser location as a byte offset into the body, or `nil` when it names a line that is not /// there — which a well-formed parse never produces, and a defensive `nil` is cheaper than a /// crash if it ever did. private func offset(of location: SourceLocation) -> Int? { let line = location.line - 1 guard line >= 0, line < lineStarts.count else { return nil } let offset = lineStarts[line] + max(0, location.column - 1) return offset <= utf8.count ? offset : utf8.count } private func span(of markup: Markup) -> BodySpan? { guard let range = markup.range, let start = offset(of: range.lowerBound), let end = offset(of: range.upperBound) else { return nil } return BodySpan(start: start, end: max(start, end)) } // MARK: Blocks func blocks(of parent: Markup) -> [BodyBlock] { parent.children.compactMap(block(_:)) } private func block(_ markup: Markup) -> BodyBlock? { switch markup { case let heading as Heading: .heading(level: heading.level, inlines: inlines(of: heading), range: span(of: heading)) case let paragraph as Paragraph: .paragraph(inlines: inlines(of: paragraph), range: span(of: paragraph)) case let code as CodeBlock: .code(code: code.code, language: code.language, range: span(of: code)) case let html as HTMLBlock: .html(raw: html.rawHTML, range: span(of: html)) case let rule as ThematicBreak: .thematicBreak(range: span(of: rule)) case let quote as BlockQuote: .quote(blocks: blocks(of: quote), range: span(of: quote)) case let list as UnorderedList: .list(BodyList(isOrdered: false, start: 1, items: items(of: list)), range: span(of: list)) case let list as OrderedList: .list(BodyList(isOrdered: true, start: Int(list.startIndex), items: items(of: list)), range: span(of: list)) case let table as Table: .table(self.table(table), range: span(of: table)) case let container as BlockContainer: // Nothing else in the subset nests, so anything that lands here is a shape the parser // produced and Preview does not model (a block directive, a custom block). Rendering // its children keeps the user's words on screen instead of swallowing them. .quote(blocks: blocks(of: container), range: span(of: container)) default: // A leaf outside the subset degrades to its own plain text, same reasoning. markup.childCount == 0 ? nil : .paragraph(inlines: inlines(of: markup), range: span(of: markup)) } } private func items(of list: ListItemContainer) -> [BodyListItem] { list.listItems.map { item in BodyListItem(task: task(of: item), blocks: blocks(of: item)) } } /// The item's checkbox, located in the source. /// /// cmark hands over the *state* and the item's start; the marker's own offset is found by /// scanning forward from that start for the first `[ ]`/`[x]`/`[X]` on the item's opening line /// (`BodyMarkup.taskMarkerOffset`). Scanning rather than arithmetic because the distance from /// the item's start to its bracket is not fixed — `-`, `*`, `+`, `1.`, `12)` and any amount of /// indentation all lead the same checkbox. private func task(of item: ListItem) -> BodyTask? { guard let checkbox = item.checkbox else { return nil } let isChecked = checkbox == .checked guard let range = item.range, let start = offset(of: range.lowerBound) else { return BodyTask(isChecked: isChecked, markerOffset: nil) } return BodyTask( isChecked: isChecked, markerOffset: BodyMarkup.taskMarkerOffset(in: utf8, scanningFrom: start) ) } private func table(_ table: Table) -> BodyTable { let header = cells(of: table.head) let rows: [[BodyTableCell]] = table.body.rows.map { cells(of: $0) } let width = max(table.maxColumnCount, max(header.count, rows.map(\.count).max() ?? 0)) var alignments = table.columnAlignments.map(Self.alignment(_:)) if alignments.count < width { alignments.append(contentsOf: Array(repeating: BodyTable.Alignment.unspecified, count: width - alignments.count)) } else if alignments.count > width { alignments = Array(alignments.prefix(width)) } return BodyTable(alignments: alignments, header: header, rows: rows) } private static func alignment(_ alignment: Table.ColumnAlignment?) -> BodyTable.Alignment { switch alignment { case .none: .unspecified case .some(.left): .leading case .some(.center): .center case .some(.right): .trailing } } private func cells(of container: any TableCellContainer) -> [BodyTableCell] { container.cells.map { cell in BodyTableCell(inlines: inlines(of: cell), colspan: Int(cell.colspan)) } } // MARK: Inlines private func inlines(of parent: Markup) -> [BodyInline] { parent.children.compactMap(inline(_:)) } private func inline(_ markup: Markup) -> BodyInline? { switch markup { case let text as Markdown.Text: .text(text.string) case let emphasis as Emphasis: .emphasis(inlines(of: emphasis)) case let strong as Strong: .strong(inlines(of: strong)) case let struck as Strikethrough: .strikethrough(inlines(of: struck)) case let code as InlineCode: .code(code.code) case let html as InlineHTML: // The rule, in one line: what the author typed is what the reader sees. .html(html.rawHTML) case is LineBreak: .lineBreak case is SoftBreak: .softBreak case let image as Image: .image(BodyImage( target: BodyTarget.classify(image.source), alt: image.plainText, title: image.title )) case let link as Link: .link(target: BodyTarget.classify(link.destination), inlines: inlines(of: link)) case let plain as PlainTextConvertibleMarkup: // Anything else the parser can still say in words (a symbol link, a custom inline). .text(plain.plainText) default: nil } } } // MARK: - The task marker, found and flipped /// The two halves of the checkbox contract that are **pure byte work** — finding the marker, and /// flipping it — kept together and kept out of the renderer, because the write side /// (`BoardWriter.toggleTaskMarker`) needs the second one and must not import a Markdown parser to /// get it. public extension BodyMarkup { /// Whether a byte is a checkbox marker, and what it means: `" "` unchecked, `x`/`X` checked. static func markerState(_ byte: UInt8) -> Bool? { switch byte { case 0x20: false case 0x78, 0x58: true default: nil } } /// The offset of the character between the first `[…]` checkbox brackets at or after `start`, /// **on that line only** — the scan `BodyTask.markerOffset` is built from. /// /// Line-scoped because a task item's checkbox is by definition the first thing on the item's /// own line; letting the scan run past the newline would let a later line's literal `[x]` be /// mistaken for this item's box. static func taskMarkerOffset(in utf8: [UInt8], scanningFrom start: Int) -> Int? { var index = max(0, start) while index + 2 < utf8.count { let byte = utf8[index] if byte == 0x0A || byte == 0x0D { return nil } if byte == 0x5B, markerState(utf8[index + 1]) != nil, utf8[index + 2] == 0x5D { return index + 1 } index += 1 } return nil } /// `body` with the checkbox at `offset` flipped — **one byte changed, and nothing else** — or /// `nil` when the bytes there are not the checkbox the caller was shown. /// /// The `expecting` guard is the reason this returns an optional rather than a `String`. A /// Preview click carries the state the *user saw*; between the render and the write the file /// may have been rewritten by an agent, a hand edit, or a pull. Flipping blindly at a stale /// offset would put an `x` in the middle of whatever now lives there. So the write refuses /// unless three things still hold: the brackets are where they were, what sits between them is /// a marker, and it reads the way the user saw it. /// /// Rebuilding through `String(decoding:as:)` is lossless here by construction: the bytes came /// from a `String`, and the one byte replaced is ASCII in both directions. static func flippingTaskMarker(in body: String, at offset: Int, expecting checked: Bool) -> String? { var bytes = Array(body.utf8) guard offset > 0, offset + 1 < bytes.count, bytes[offset - 1] == 0x5B, bytes[offset + 1] == 0x5D, let state = markerState(bytes[offset]), state == checked else { return nil } bytes[offset] = checked ? 0x20 : 0x78 return String(decoding: bytes, as: UTF8.self) } }