Build Preview mode rendering
The card body's resting state: swift-markdown (pinned 0.8.0, smart typography off — Preview renders the bytes on disk) parsed into a pure BodyMarkup model with UTF-8 source offsets, rendered on one hosted TextKit 1 NSTextView — chosen because find-in-text is NSTextFinder, checkbox clicks reuse AppKit character hit-testing, links are .link attributes, and NSTextTable's automatic layout is exactly the columns-sized-to-contents rule. The GFM subset renders per 05; HTML stays verbatim code-styled text; relative images resolve against the card folder while remote URLs are never fetched, drawing a quiet chip instead. Task checkboxes are live: a click flips exactly one byte through a fresh-read, refuse-uneditable, stamp, atomic-replace write — the app's only offset-addressed write, so a moved target refuses as staleTarget and what the user saw decides the direction, netting one toggle on a double-click. Empty bodies open in Edit per CardBodyMode's opening rule, applied once; the Edit surface itself stays an honest read-only stub until its card. FindCommand prefers the card body's find over board search when a card window is focused. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
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.** `<b>x</b>` 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..<end`, the parser's own extent for the
|
||||
/// construct.
|
||||
///
|
||||
/// Carried on blocks and on task markers, and deliberately **not** on inlines: the only consumer
|
||||
/// that needs an offset to be exact is the checkbox flip, which is a list item's, and per-inline
|
||||
/// ranges from cmark are an approximation nothing here would be entitled to write through. Block
|
||||
/// ranges pay for themselves in the other direction — they are what lets a test say "this heading
|
||||
/// is these bytes" rather than trusting a rendering to look right.
|
||||
public struct BodySpan: Equatable, Sendable {
|
||||
public let start: Int
|
||||
public let end: Int
|
||||
|
||||
public init(start: Int, end: Int) {
|
||||
self.start = start
|
||||
self.end = end
|
||||
}
|
||||
}
|
||||
|
||||
/// One block of a rendered body. The case list *is* the settled subset (05 ▸ Preview): headings,
|
||||
/// paragraphs, fenced and indented code, verbatim HTML, thematic breaks, nested quotes, bullet /
|
||||
/// ordered / task lists, and GFM tables.
|
||||
public enum BodyBlock: Equatable, Sendable {
|
||||
case heading(level: Int, inlines: [BodyInline], range: BodySpan?)
|
||||
case paragraph(inlines: [BodyInline], range: BodySpan?)
|
||||
/// Fenced *and* indented alike — the fence is syntax, and what reaches the reader is code.
|
||||
/// `language` is the fence's info string when it has one, `nil` for an indented block or a
|
||||
/// bare fence; it is never used to highlight (Preview does not colour code), only to label.
|
||||
case code(code: String, language: String?, range: BodySpan?)
|
||||
/// **Verbatim.** The raw HTML as written, rendered as code-styled literal text.
|
||||
case html(raw: String, range: BodySpan?)
|
||||
case thematicBreak(range: BodySpan?)
|
||||
/// Nests: a quote's children are blocks, quotes included.
|
||||
case quote(blocks: [BodyBlock], range: BodySpan?)
|
||||
case list(BodyList, range: BodySpan?)
|
||||
case table(BodyTable, range: BodySpan?)
|
||||
}
|
||||
|
||||
/// A bullet, ordered, or task list. **Task lists are not a separate case**, because GFM does not
|
||||
/// make them one: a task list is a list whose items happen to carry a checkbox, and a list may
|
||||
/// legitimately mix the two.
|
||||
public struct BodyList: Equatable, Sendable {
|
||||
public let isOrdered: Bool
|
||||
/// The first number of an ordered list (`3.` starts at 3); `1` for a bullet list.
|
||||
public let start: Int
|
||||
public let items: [BodyListItem]
|
||||
|
||||
public init(isOrdered: Bool, start: Int, items: [BodyListItem]) {
|
||||
self.isOrdered = isOrdered
|
||||
self.start = start
|
||||
self.items = items
|
||||
}
|
||||
}
|
||||
|
||||
public struct BodyListItem: Equatable, Sendable {
|
||||
/// The item's checkbox, or `nil` for an ordinary list item.
|
||||
public let task: BodyTask?
|
||||
public let blocks: [BodyBlock]
|
||||
|
||||
public init(task: BodyTask?, blocks: [BodyBlock]) {
|
||||
self.task = task
|
||||
self.blocks = blocks
|
||||
}
|
||||
}
|
||||
|
||||
/// A live task-list checkbox: its state, and **where its one character lives in the source**.
|
||||
///
|
||||
/// `markerOffset` is the UTF-8 byte offset of the character *between* the brackets — the space in
|
||||
/// `- [ ]`, the `x` in `- [x]`. That single byte is the entire write a click performs (05 ▸
|
||||
/// Preview: "clicking a `- [ ]` / `- [x]` checkbox flips exactly that marker in the source … every
|
||||
/// other byte of the body is untouched").
|
||||
///
|
||||
/// It is optional because locating it is a *scan*, not a parser guarantee: cmark reports the list
|
||||
/// item's start, and the marker is found by reading forward from there. A body shaped so the scan
|
||||
/// comes up empty still renders its checkbox — inert, rather than absent, since the box is
|
||||
/// content the user wrote and hiding it would be the bigger lie.
|
||||
public struct BodyTask: Equatable, Sendable {
|
||||
public let isChecked: Bool
|
||||
public let markerOffset: Int?
|
||||
|
||||
public init(isChecked: Bool, markerOffset: Int?) {
|
||||
self.isChecked = isChecked
|
||||
self.markerOffset = markerOffset
|
||||
}
|
||||
}
|
||||
|
||||
/// A GFM table: per-column alignment, a header row, and body rows.
|
||||
public struct BodyTable: Equatable, Sendable {
|
||||
|
||||
public enum Alignment: Equatable, Sendable {
|
||||
/// No `:` on either side — the column follows the reader's default, which is leading.
|
||||
case unspecified
|
||||
case leading
|
||||
case center
|
||||
case trailing
|
||||
}
|
||||
|
||||
/// One entry per column, padded to `columnCount` — a delimiter row shorter than the widest row
|
||||
/// leaves the extra columns `.unspecified` rather than making the array a different length
|
||||
/// from the cells it describes.
|
||||
public let alignments: [Alignment]
|
||||
public let header: [BodyTableCell]
|
||||
public let rows: [[BodyTableCell]]
|
||||
|
||||
public var columnCount: Int { alignments.count }
|
||||
|
||||
public init(alignments: [Alignment], header: [BodyTableCell], rows: [[BodyTableCell]]) {
|
||||
self.alignments = alignments
|
||||
self.header = header
|
||||
self.rows = rows
|
||||
}
|
||||
}
|
||||
|
||||
public struct BodyTableCell: Equatable, Sendable {
|
||||
public let inlines: [BodyInline]
|
||||
/// GFM's cell span. Preserved because cmark reports it; a renderer that ignores it merely
|
||||
/// draws a narrower cell, never a wrong one.
|
||||
public let colspan: Int
|
||||
|
||||
public init(inlines: [BodyInline], colspan: Int = 1) {
|
||||
self.inlines = inlines
|
||||
self.colspan = colspan
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Inlines
|
||||
|
||||
/// One inline run. Nesting is by array rather than by `indirect`, which is enough: every container
|
||||
/// case holds *several* children, so the recursion is already behind a heap allocation.
|
||||
public enum BodyInline: Equatable, Sendable {
|
||||
case text(String)
|
||||
case emphasis([BodyInline])
|
||||
case strong([BodyInline])
|
||||
case strikethrough([BodyInline])
|
||||
case code(String)
|
||||
/// **Verbatim**, exactly like `BodyBlock.html`: `<br>` 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 (`` 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user