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 `\(renderChildren(of: paragraph))
" } mutating func visitHeading(_ heading: Heading) -> String { let level = min(max(heading.level, 1), 6) return " 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 "\(renderChildren(of: list))
"
}
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 "
"
}
// 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("