import Foundation import Markdown // MARK: - HTMLRenderer // // Renders the *same* swift-markdown `Document` the editor parses into an HTML // body string. This is the Read-mode / Print-export counterpart to // `SpanCollector` (which produces editor attribute spans): one parser, one set // of element semantics, two back-ends. It mirrors SpanCollector's element // coverage so Read mode shows exactly what Edit mode highlights. // // The renderer is intentionally **pure** — AST → string, no AppKit. Assets that // need AppKit (callout icons, math glyphs) are emitted as placeholder elements // that `DocumentHTML` fills in a second pass, so this type stays unit-testable // with plain string assertions. // // Non-GFM inline constructs (==highlight==, $math$, [[wikilink]], %%comment%%) // are detected by reusing the exact regex passes in // `SyntaxHighlighter+CustomParsers` — no second source of truth. struct HTMLRenderer: MarkupVisitor { typealias Result = String /// Private URL scheme for `[[wikilink]]` hrefs. The read view's navigation /// policy intercepts this scheme and routes the (percent-decoded) target /// through the app's document graph instead of navigating the webview. static let wikiScheme = "x-edmund-wiki" /// Private URL scheme for relative/internal regular markdown links /// (`[text](other.md)`). Routed like wikilinks; external links (http/https/ /// mailto) and in-page `#fragment` anchors keep their real hrefs. static let linkScheme = "x-edmund-link" /// The markdown this instance is rendering. Held so block-level constructs /// (callouts) can recover their *raw* source text by range, the way the /// editor's styling layer does. private let source: String private let sourceLines: [String] private let options: ReadRenderOptions /// Footnote definitions collected while walking the document (see /// `visitParagraph`), rendered as a section at the bottom of the page /// instead of in place. Order is document order of the *definitions*. private var footnotes: [(id: String, bodyHTML: String)] = [] /// Tightness of each list currently being walked (stack: nested lists). /// See `isTight(_:)`. private var listIsTight: [Bool] = [] private init(source: String, options: ReadRenderOptions) { self.source = source self.sourceLines = source.components(separatedBy: "\n") self.options = options } /// Parses `markdown` and returns the rendered HTML body (no ``/`` /// wrapper — `DocumentHTML` adds that). static func render(markdown: String, options: ReadRenderOptions = .default) -> String { var r = HTMLRenderer(source: markdown, options: options) let doc = Document(parsing: markdown, options: [.disableSmartOpts]) let body = r.visit(doc) return body + r.renderFootnotesSection() } /// `[^id]: body` definitions render at the bottom of the page as a `
` + /// ordered list, each entry linking back to its in-text reference — the /// Obsidian-style footnote layout (see misc/backlog.md's Markdown Footnotes /// entry). Not rendered at all if the document had no footnote definitions. private func renderFootnotesSection() -> String { guard !footnotes.isEmpty else { return "" } var out = "
    " for (id, bodyHTML) in footnotes { let safeID = Self.attr(id) out += "
  1. \(bodyHTML) " + "
  2. " } out += "
" return out } /// Top-level block iteration. When `preserveBlankLines` is on, a *run* of /// blank source lines between two blocks emits one `.blank-line` spacer for /// every blank line beyond the first — i.e. standard Markdown keeps a single /// blank line as the normal block separator and only renders the 2nd, 3rd, … /// blank lines as extra vertical space. /// /// REFERENCE (future "rigorous" Read mode): to mimic Edit mode's layout /// exactly, emit a spacer for EVERY blank line (`spacers = blanks`, not /// `blanks - 1`). That preserves the author's spacing literally but fights /// the HTML/CSS box model (blocks already carry their own margins), so it's /// parked until Read mode commits to a styled-source rather than a rendered- /// document model. See the discussion in the handoff notes. /// /// QUIRK: a block's `range.upperBound.line` is NOT reliably its last content /// line — cmark folds trailing blank lines into some block ranges (lists in /// particular), so a list followed by a blank line then a paragraph reports /// the list ending on the blank line. We therefore clamp each block's end /// back to its last non-blank source line; the blank run between blocks A and /// B is then `B.firstLine - clamp(A.end) - 1`. mutating func visitDocument(_ document: Document) -> String { guard options.preserveBlankLines else { return renderChildren(of: document) } var out = "" var prevEndLine: Int? for child in document.children { if let prevEndLine, let range = child.range { let blanks = range.lowerBound.line - prevEndLine - 1 if blanks > 1 { out += String(repeating: "
", count: blanks - 1) } } out += visit(child) if let range = child.range { prevEndLine = lastContentLine(atOrBefore: range.upperBound.line) } } return out } /// The last source line at or before `line` (1-indexed) that has non-blank /// content. Used to undo cmark folding trailing blank lines into a block. private func lastContentLine(atOrBefore line: Int) -> Int { var l = min(line, sourceLines.count) while l >= 1, sourceLines[l - 1].trimmingCharacters(in: .whitespaces).isEmpty { l -= 1 } return l } // MARK: Default / children mutating func defaultVisit(_ markup: Markup) -> String { renderChildren(of: markup) } private mutating func renderChildren(of markup: Markup) -> String { var out = "" for child in markup.children { out += visit(child) } return out } // MARK: Block-level mutating func visitParagraph(_ paragraph: Paragraph) -> String { // A paragraph that is wholly `$$…$$` is a display-math block. Reuse the // editor's detector so Read mode and Edit mode agree on what's math. // Detect on the *raw* source, not `plainText`: swift-markdown has already // applied Markdown backslash-unescaping to a Text node's `.string` // (`\\`→`\`, `\$`→`$`), which corrupts LaTeX row separators and commands // — so a `\begin{aligned}…\\…\end{aligned}` block would be mangled. The // editor's styling reads from raw source by range for the same reason. let raw = sourceText(paragraph) ?? Self.plainText(of: paragraph) var dm: [SyntaxHighlighter.Span] = [] SyntaxHighlighter.parseDisplayMath(raw, into: &dm) if let span = dm.first(where: { if case .math(true) = $0.kind { return true }; return false }) { let tex = (raw as NSString).substring(with: span.contentRange) return "
" } // A `[^id]: body` paragraph (the marker starts the paragraph's first // Text child) is a footnote definition, not visible content — collect it // for the bottom-of-page footnotes section instead of rendering in place. let children = Array(paragraph.children) if let first = children.first as? Text, let (id, markerLength) = Self.footnoteDefinitionMarker(in: first.string) { var bodyHTML = Self.renderInline(String(first.string.dropFirst(markerLength))) for child in children.dropFirst() { bodyHTML += visit(child) } footnotes.append((id: id, bodyHTML: bodyHTML)) return "" } return "

\(renderChildren(of: paragraph))

" } mutating func visitHeading(_ heading: Heading) -> String { let level = min(max(heading.level, 1), 6) return "\(renderChildren(of: heading))" } mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> String { // Per-token syntax coloring reuses the editor's `CodeHighlighter`, so // Edit mode and Read mode color the same tokens identically (the actual // colors live in CSS, from the shared `CodeSyntaxPalette` via HTMLTheme). let lang = codeBlock.language.map { " class=\"language-\(Self.attr($0))\"" } ?? "" // QUIRK: U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are valid // Unicode line-ending characters that appear in macOS-pasted text (e.g. // from Notes or Safari). In HTML they are NOT newline characters — inside // a
 block they render as spaces or nothing, concatenating lines that
        // should appear on separate rows. Normalize to plain U+000A before escaping.
        let raw = codeBlock.code
            .replacingOccurrences(of: "\u{2028}", with: "\n")
            .replacingOccurrences(of: "\u{2029}", with: "\n")
        // swift-markdown includes a trailing newline on the block's code.
        let code = raw.hasSuffix("\n") ? String(raw.dropLast()) : raw
        return "
\(Self.highlightCode(code, language: codeBlock.language))
" } /// CSS class for a code token kind (consumed by `HTMLTheme`'s `.tok-*` rules). private static func tokenClass(_ type: CodeHighlighter.TokenType) -> String { switch type { case .keyword: return "tok-keyword" case .type: return "tok-type" case .string: return "tok-string" case .number: return "tok-number" case .comment: return "tok-comment" case .function: return "tok-function" } } /// Escapes `code` and wraps each `CodeHighlighter` token in a colored /// ``. Gaps between tokens stay plain (escaped) text and /// inherit the plain `pre code` color, mirroring the editor's "plain first, /// tokens paint over" model. static func highlightCode(_ code: String, language: String?) -> String { let tokens = CodeHighlighter.tokenize(code, language: language) guard !tokens.isEmpty else { return escape(code) } let ns = code as NSString var out = "" var cursor = 0 for token in tokens { let r = token.range guard r.location >= cursor, r.upperBound <= ns.length else { continue } if r.location > cursor { out += escape(ns.substring(with: NSRange(location: cursor, length: r.location - cursor))) } out += "\(escape(ns.substring(with: r)))" cursor = r.upperBound } if cursor < ns.length { out += escape(ns.substring(with: NSRange(location: cursor, length: ns.length - cursor))) } return out } mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> String { "
" } mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> String { // Detect a GFM callout (`> [!type] …`) on the first line, the same way // the editor does (Callout.parseMarker over the de-quoted first line). if let inner = deQuoted(blockQuote) { let firstLine = String(inner.prefix(while: { $0 != "\n" })) if let marker = Callout.parseMarker(firstLine), let style = Callout.style(for: marker.type) { return renderCallout(marker: marker, style: style, firstLine: firstLine, blockQuote: blockQuote) } } return "
\(renderChildren(of: blockQuote))
" } /// GFM §5.3: a list is LOOSE iff any two adjacent blocks inside it — between /// items, or between blocks within one item — are separated by a blank source /// line. swift-markdown doesn't expose cmark's tight flag; recover it from /// source-line gaps, clamping each block's end past cmark's folded trailing /// blanks (same trick as visitDocument). private func isTight(_ list: Markup) -> Bool { var prevEnd: Int? = nil for item in list.children { guard let r = item.range else { continue } if let p = prevEnd, r.lowerBound.line - p > 1 { return false } var innerPrev: Int? = nil for block in item.children { guard let br = block.range else { continue } if let ip = innerPrev, br.lowerBound.line - ip > 1 { return false } innerPrev = lastContentLine(atOrBefore: br.upperBound.line) } prevEnd = lastContentLine(atOrBefore: r.upperBound.line) } return true } mutating func visitUnorderedList(_ list: UnorderedList) -> String { listIsTight.append(isTight(list)) defer { listIsTight.removeLast() } return "" } mutating func visitOrderedList(_ list: OrderedList) -> String { listIsTight.append(isTight(list)) defer { listIsTight.removeLast() } let start = list.startIndex == 1 ? "" : " start=\"\(list.startIndex)\"" return "\(renderChildren(of: list))" } mutating func visitListItem(_ listItem: ListItem) -> String { if let checkbox = listItem.checkbox { let checked = checkbox == .checked // Composed Lucide SVG (not an SF Symbol, which can't ship in exported // PDFs) mirroring the editor's look; CSS supplies the accent/dim color. let mark = "" + "\(LucideIcons.checkboxSVG(checked: checked))" let checkedClass = checked ? " task--checked" : "" return "
  • \(mark)\(renderListItemContents(listItem))
  • " } return "
  • \(renderListItemContents(listItem))
  • " } /// Item contents; in a tight list, each direct Paragraph child loses its ///

    wrapper (visit-then-strip, so visitParagraph's math/footnote /// special cases still run). private mutating func renderListItemContents(_ item: ListItem) -> String { guard listIsTight.last == true else { return renderChildren(of: item) } var out = "" for child in item.children { var html = visit(child) if child is Paragraph, html.hasPrefix("

    "), html.hasSuffix("

    ") { html = String(html.dropFirst(3).dropLast(4)) } out += html } return out } mutating func visitTable(_ table: Table) -> String { let aligns = table.columnAlignments func cellStyle(_ col: Int) -> String { guard col < aligns.count, let a = aligns[col] else { return "" } switch a { case .left: return " style=\"text-align:left\"" case .center: return " style=\"text-align:center\"" case .right: return " style=\"text-align:right\"" } } var html = "
    " for (col, cell) in table.head.cells.enumerated() { html += "\(renderChildren(of: cell))" } html += "" for row in table.body.rows { html += "" for (col, cell) in row.cells.enumerated() { html += "\(renderChildren(of: cell))" } html += "" } html += "
    " return html } // MARK: Inline mutating func visitText(_ text: Text) -> String { Self.renderInline(text.string, rawSource: sourceText(text)) } mutating func visitEmphasis(_ emphasis: Emphasis) -> String { "\(renderChildren(of: emphasis))" } mutating func visitStrong(_ strong: Strong) -> String { "\(renderChildren(of: strong))" } mutating func visitStrikethrough(_ s: Strikethrough) -> String { "\(renderChildren(of: s))" } mutating func visitInlineCode(_ code: InlineCode) -> String { "\(Self.escape(code.code))" } mutating func visitLineBreak(_ lineBreak: LineBreak) -> String { "
    \n" } mutating func visitSoftBreak(_ softBreak: SoftBreak) -> String { "\n" } mutating func visitLink(_ link: Link) -> String { let dest = link.destination ?? "" let inner = renderChildren(of: link) let title = link.title.map { " title=\"\(Self.attr($0))\"" } ?? "" // In-page `#fragment` anchors and external links (http/https/mailto, or // any explicit scheme) keep their real href — the nav policy lets the // anchor scroll and hands external schemes to the browser. A relative / // internal destination is wrapped in the private link scheme so it routes // through the app's document graph reliably. if dest.hasPrefix("#") || Self.hasExternalScheme(dest) { return "\(inner)" } let encoded = dest.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? dest return "\(inner)" } /// Whether a link destination carries an explicit URL scheme (`http:`, /// `mailto:`, `file:`, …) and so should be treated as external/absolute /// rather than a relative path into the document's directory. private static func hasExternalScheme(_ dest: String) -> Bool { guard let colon = dest.firstIndex(of: ":") else { return false } let scheme = dest[dest.startIndex.. String { // Emit a placeholder carrying the raw source; `DocumentHTML` resolves and // inlines it in a second pass (it needs the document directory + the // remote-image policy, which the pure renderer doesn't have). No `src` // here ⇒ if the asset pass can't resolve it, the alt text shows. let alt = Self.attr(Self.plainText(of: image)) let src = Self.attr(image.source ?? "") return "\"\(alt)\"" } // Inline HTML (§6.10): full GFM raw-HTML passthrough, filtered through // tagfilter (§6.11) + hardening (§G — see ARCHITECTURE §10). Block HTML // gets the same filter (below). mutating func visitInlineHTML(_ inlineHTML: InlineHTML) -> String { Self.sanitizeInlineHTML(inlineHTML.rawHTML) } mutating func visitHTMLBlock(_ html: HTMLBlock) -> String { // A block-level `` is invisible, like in a browser. let trimmed = html.rawHTML.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.hasPrefix("") { return "" } if isSingleTag(trimmed, named: "img"), let img = Self.imgPlaceholder(trimmed) { return "

    \(img)

    " } // GFM passthrough (§4.6): tagfilter + hardening, then rewrite interior // `` tags to asset-pass placeholders (same baseURL-nil reason // as inline; also the remote-image-policy enforcement point). No

    // wrapper, no escaping. filterRawHTML runs FIRST: the placeholders carry // only class/data-src/alt/width/height attrs, which the hardening // regexes can't touch. return Self.rewriteImgs(in: Self.filterRawHTML(html.rawHTML)) } /// Full §6.10 open-tag grammar for `img` (quoted values may contain `>`). private static let imgTagRegex = try! NSRegularExpression( pattern: #"`]+|'[^']*'|"[^"]*"))?)*\s*/?>"#, options: [.caseInsensitive]) /// Replaces every `` that has a usable src with the md-image /// placeholder; src-less imgs pass through untouched (they simply won't load). static func rewriteImgs(in html: String) -> String { let ns = html as NSString let out = NSMutableString(string: html) for m in imgTagRegex.matches(in: html, range: NSRange(location: 0, length: ns.length)).reversed() { if let placeholder = imgPlaceholder(ns.substring(with: m.range)) { out.replaceCharacters(in: m.range, with: placeholder) } } return out as String } /// True when `raw` is exactly one `` tag (no trailing content — /// the anchored tag regex must consume the whole string). private func isSingleTag(_ raw: String, named name: String) -> Bool { let ns = raw as NSString guard let m = Self.inlineTagRegex.firstMatch( in: raw, range: NSRange(location: 0, length: ns.length)) else { return false } return ns.substring(with: m.range(at: 1)).isEmpty && ns.substring(with: m.range(at: 2)).lowercased() == name } private static let inlineTagRegex = try! NSRegularExpression(pattern: #"^<(/?)([A-Za-z][A-Za-z0-9]*)[^>]*>$"#) // MARK: - GFM tagfilter (§6.11) + hardening /// Tagfilter (§6.11): the leading `<` of the nine disallowed tag names /// (open or closing, case-insensitive) becomes `<`. Lookahead only — /// nothing else is consumed. private static let tagfilterRegex = try! NSRegularExpression( pattern: #"<(?=/?(?:title|textarea|style|xmp|iframe|noembed|noframes|script|plaintext)(?:[\s/>]|$))"#, options: [.caseInsensitive]) /// Hardening (beyond spec): strip `on*` event-handler attributes. /// ponytail: plain-text regex, not an HTML parser — a literal ` onclick="x"` /// in text between tags is also stripped; harmless for a hardening pass. private static let eventAttrRegex = try! NSRegularExpression( pattern: #"\son[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+)"#, options: [.caseInsensitive]) /// Hardening: neutralize javascript:/vbscript: schemes in URL-carrying /// attributes (the scheme is deleted; the rest becomes a harmless relative URL). private static let scriptURLRegex = try! NSRegularExpression( pattern: #"(\s(?:href|src|action|formaction|xlink:href|data)\s*=\s*["']?\s*)(?:javascript|vbscript)\s*:"#, options: [.caseInsensitive]) /// GFM raw-HTML output filter: tagfilter + Edmund's hardening. Defense-in-depth /// on top of the read webview's JS-off + CSP script-src 'none' + baseURL nil. static func filterRawHTML(_ raw: String) -> String { func sub(_ s: String, _ rx: NSRegularExpression, _ template: String) -> String { rx.stringByReplacingMatches(in: s, range: NSRange(location: 0, length: (s as NSString).length), withTemplate: template) } var out = sub(raw, tagfilterRegex, "<") out = sub(out, eventAttrRegex, "") out = sub(out, scriptURLRegex, "$1") return out } /// GFM raw-HTML passthrough (§6.10) with tagfilter (§6.11) + hardening. /// Comments stay invisible. A lone `` becomes the asset-pass /// placeholder — REQUIRED, not just policy: the page loads with `baseURL: nil`, /// so a raw relative `` could never resolve; the placeholder routes /// it through DocumentHTML.fillImages (data-URI inlining + remote-image policy, /// declared width/height carried through). Everything else passes through /// filtered. Whitelisted formatting tags now keep their (hardened) attributes. static func sanitizeInlineHTML(_ raw: String) -> String { if raw.hasPrefix("