Add HTML and PDF conversation export

Top item on the roadmap ranking from 2026-07-27 — multi-modal export
alongside the existing Markdown/JSON. New ConversationExportService
consolidates the two previously-duplicated Markdown builders
(ChatViewModel and ConversationListView had separate copies of the
same **User**/**Assistant** + --- format) and adds:

- A hand-rolled Markdown->HTML renderer scoped to what actually shows
  up in chat messages (headers, bold/italic, inline code, fenced code
  blocks, lists, blockquotes, links, horizontal rules) rather than
  full CommonMark/GFM — no existing markdown-to-HTML utility existed
  in the codebase, and swift-markdown-ui is SwiftUI-view-only with no
  HTML-string export API. Content is HTML-escaped before any markdown
  substitution so example code containing "<div>" etc renders as
  visible text, not live markup.
- PDF via an offscreen WKWebView loading that same HTML and calling
  the official createPDF(configuration:) API (macOS 11+) — no new
  project/framework linkage needed, WebKit is a system framework.

Wired into every place Markdown export already existed: File menu
(Export as HTML.../PDF...), /export slash command (now md|html|pdf|json),
and a new Export submenu (Markdown/HTML/PDF) on each conversation row's
context menu in the advanced conversation list, replacing the old
single-format swipe-only export. Help docs and InputBar autocomplete
updated to match.
This commit is contained in:
2026-07-29 07:47:57 +02:00
parent 727fc7d6af
commit 634b83f284
8 changed files with 562 additions and 34 deletions
@@ -260,8 +260,8 @@
<dt>/delete &lt;name&gt;</dt> <dt>/delete &lt;name&gt;</dt>
<dd>Delete a saved conversation</dd> <dd>Delete a saved conversation</dd>
<dt>/export md|json</dt> <dt>/export md|html|pdf|json</dt>
<dd>Export conversation as Markdown or JSON</dd> <dd>Export conversation as Markdown, HTML, PDF, or JSON</dd>
</dl> </dl>
<h3>MCP Commands</h3> <h3>MCP Commands</h3>
@@ -567,10 +567,12 @@
</ol> </ol>
<h3>Exporting Conversations</h3> <h3>Exporting Conversations</h3>
<p>Export to Markdown or JSON format:</p> <p>Export to Markdown, HTML, PDF, or JSON format:</p>
<code class="command">/export md</code> <code class="command">/export md</code>
<code class="command">/export html</code>
<code class="command">/export pdf</code>
<code class="command">/export json</code> <code class="command">/export json</code>
<p class="note">Files are saved to your Downloads folder.</p> <p class="note">Files are saved to your Downloads folder. HTML and PDF export are also available from File → Export as HTML…/PDF…, and per-conversation from the Export submenu in the conversation list's context menu.</p>
</section> </section>
<!-- Git Sync --> <!-- Git Sync -->
@@ -0,0 +1,364 @@
//
// ConversationExportService.swift
// oAI
//
// Shared conversation export: Markdown, HTML, and PDF
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling oAI or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import WebKit
enum ConversationExportService {
// MARK: - Markdown
nonisolated static func markdown(messages: [Message]) -> String {
messages.map { msg in
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
}
// MARK: - HTML
nonisolated static func html(name: String, messages: [Message]) -> String {
let body = messages.map { msg -> String in
let roleLabel = msg.role == .user ? "User" : "Assistant"
let roleClass = msg.role == .user ? "user" : "assistant"
let rendered = renderMarkdownBody(htmlEscape(msg.content))
return """
<div class="message \(roleClass)">
<div class="role">\(roleLabel)</div>
\(rendered)
</div>
"""
}.joined(separator: "\n")
return """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>\(htmlEscape(name))</title>
<style>\(css)</style>
</head>
<body>
<h1>\(htmlEscape(name))</h1>
\(body)
</body>
</html>
"""
}
nonisolated private static let css = """
body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; \
color: #1a1a1a; background: #ffffff; max-width: 820px; margin: 40px auto; padding: 0 24px; \
line-height: 1.5; }
h1 { font-size: 24px; border-bottom: 1px solid #ddd; padding-bottom: 12px; }
.message { margin: 20px 0; padding: 12px 16px; border-radius: 8px; border-left: 4px solid transparent; }
.message.user { background: #f5f7fa; border-left-color: #4a90d9; }
.message.assistant { background: #fafafa; border-left-color: #8a8a8a; }
.role { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; \
color: #666; margin-bottom: 8px; }
.message p { margin: 8px 0; }
.message h1, .message h2, .message h3, .message h4, .message h5, .message h6 { margin: 12px 0 6px; }
.message ul, .message ol { margin: 8px 0; padding-left: 24px; }
.message blockquote { margin: 8px 0; padding: 4px 12px; border-left: 3px solid #ccc; color: #555; }
.message code { font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 0.9em; \
background: #eef0f2; padding: 1px 5px; border-radius: 4px; }
.message pre { background: #1e1e1e; color: #e8e8e8; padding: 12px 14px; border-radius: 6px; \
overflow-x: auto; }
.message pre code { background: none; padding: 0; color: inherit; }
.message hr { border: none; border-top: 1px solid #ddd; margin: 16px 0; }
.message a { color: #4a90d9; }
"""
// MARK: - PDF
static func pdfData(name: String, messages: [Message]) async throws -> Data {
let htmlString = html(name: name, messages: messages)
let webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 816, height: 1056))
let delegate = PDFLoadDelegate()
webView.navigationDelegate = delegate
try await delegate.load(htmlString, in: webView)
return try await withCheckedThrowingContinuation { continuation in
webView.createPDF(configuration: WKPDFConfiguration()) { result in
continuation.resume(with: result)
}
}
}
private final class PDFLoadDelegate: NSObject, WKNavigationDelegate {
private var continuation: CheckedContinuation<Void, Error>?
func load(_ html: String, in webView: WKWebView) async throws {
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
self.continuation = cont
webView.loadHTMLString(html, baseURL: nil)
}
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
continuation?.resume()
continuation = nil
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
}
// MARK: - File writing
nonisolated static func writeToDownloads(_ content: String, filename: String) -> URL? {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let fileURL = downloads.appendingPathComponent(filename)
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
return fileURL
} catch {
return nil
}
}
nonisolated static func writeToDownloads(_ data: Data, filename: String) -> URL? {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let fileURL = downloads.appendingPathComponent(filename)
do {
try data.write(to: fileURL, options: .atomic)
return fileURL
} catch {
return nil
}
}
// MARK: - Markdown HTML rendering (scoped to what chat messages actually contain
// headers, bold/italic, inline code, fenced code blocks, lists, blockquotes, links,
// horizontal rules, paragraphs. Not full CommonMark/GFM no tables.)
nonisolated private static func htmlEscape(_ text: String) -> String {
var result = text
result = result.replacingOccurrences(of: "&", with: "&amp;")
result = result.replacingOccurrences(of: "<", with: "&lt;")
result = result.replacingOccurrences(of: ">", with: "&gt;")
result = result.replacingOccurrences(of: "\"", with: "&quot;")
result = result.replacingOccurrences(of: "'", with: "&#39;")
return result
}
/// Renders already-HTML-escaped markdown text into an HTML body fragment.
nonisolated static func renderMarkdownBody(_ escapedContent: String) -> String {
let lines = escapedContent.components(separatedBy: "\n")
var html = ""
var index = 0
var paragraphLines: [String] = []
var listBuffer: [String] = []
var listTag: String?
func flushParagraph() {
guard !paragraphLines.isEmpty else { return }
let joined = paragraphLines.joined(separator: "<br>\n")
html += "<p>\(renderInline(joined))</p>\n"
paragraphLines = []
}
func flushList() {
guard let tag = listTag, !listBuffer.isEmpty else { return }
html += "<\(tag)>\n"
for item in listBuffer { html += "<li>\(renderInline(item))</li>\n" }
html += "</\(tag)>\n"
listBuffer = []
listTag = nil
}
while index < lines.count {
let line = lines[index].trimmingCharacters(in: .whitespaces)
// Fenced code block
if line.hasPrefix("```") {
flushParagraph(); flushList()
let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces)
var codeLines: [String] = []
index += 1
while index < lines.count, !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
codeLines.append(lines[index])
index += 1
}
let classAttr = lang.isEmpty ? "" : " class=\"language-\(lang)\""
html += "<pre><code\(classAttr)>\(codeLines.joined(separator: "\n"))</code></pre>\n"
if index < lines.count { index += 1 } // skip closing ```
continue
}
// Headers
if let header = headerMatch(line) {
flushParagraph(); flushList()
html += "<h\(header.level)>\(renderInline(header.text))</h\(header.level)>\n"
index += 1
continue
}
// Horizontal rule
if isHorizontalRule(line) {
flushParagraph(); flushList()
html += "<hr>\n"
index += 1
continue
}
// Blockquote (escaped ">" is "&gt;")
if line.hasPrefix("&gt; ") || line == "&gt;" {
flushParagraph(); flushList()
var quoteLines: [String] = []
while index < lines.count {
let quoteLine = lines[index].trimmingCharacters(in: .whitespaces)
if quoteLine.hasPrefix("&gt; ") {
quoteLines.append(String(quoteLine.dropFirst(5)))
} else if quoteLine == "&gt;" {
quoteLines.append("")
} else {
break
}
index += 1
}
html += "<blockquote><p>\(renderInline(quoteLines.joined(separator: "<br>\n")))</p></blockquote>\n"
continue
}
// Unordered list
if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") {
flushParagraph()
if listTag == "ol" { flushList() }
listTag = "ul"
listBuffer.append(String(line.dropFirst(2)))
index += 1
continue
}
// Ordered list
if let text = orderedListMatch(line) {
flushParagraph()
if listTag == "ul" { flushList() }
listTag = "ol"
listBuffer.append(text)
index += 1
continue
}
// Blank line paragraph/list separator
if line.isEmpty {
flushParagraph(); flushList()
index += 1
continue
}
// Plain paragraph text
flushList()
paragraphLines.append(line)
index += 1
}
flushParagraph()
flushList()
return html
}
nonisolated private static func headerMatch(_ line: String) -> (level: Int, text: String)? {
var level = 0
var idx = line.startIndex
while idx < line.endIndex, line[idx] == "#", level < 6 {
level += 1
idx = line.index(after: idx)
}
guard level > 0, idx < line.endIndex, line[idx] == " " else { return nil }
return (level, String(line[line.index(after: idx)...]))
}
nonisolated private static func orderedListMatch(_ line: String) -> String? {
guard let dotRange = line.range(of: ". ") else { return nil }
let prefix = line[line.startIndex..<dotRange.lowerBound]
guard !prefix.isEmpty, prefix.allSatisfy(\.isNumber) else { return nil }
return String(line[dotRange.upperBound...])
}
nonisolated private static func isHorizontalRule(_ line: String) -> Bool {
guard line.count >= 3 else { return false }
return line.allSatisfy { $0 == "-" } || line.allSatisfy { $0 == "*" } || line.allSatisfy { $0 == "_" }
}
/// Applies inline markdown (code, links, bold, italic) to an already-HTML-escaped line.
nonisolated private static func renderInline(_ escapedText: String) -> String {
var text = escapedText
var codeSpans: [String] = []
text = replacingCaptures(text, pattern: #"`([^`]+?)`"#) { match in
let token = "\u{E000}\(codeSpans.count)\u{E000}"
codeSpans.append("<code>\(match)</code>")
return token
}
text = replacingCaptures(text, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#, groups: 2) { groups in
"<a href=\"\(groups[1])\">\(groups[0])</a>"
}
text = replacingCaptures(text, pattern: #"\*\*([^*]+?)\*\*"#) { "<strong>\($0)</strong>" }
text = replacingCaptures(text, pattern: #"__([^_]+?)__"#) { "<strong>\($0)</strong>" }
text = replacingCaptures(text, pattern: #"\*([^*]+?)\*"#) { "<em>\($0)</em>" }
for (i, span) in codeSpans.enumerated() {
text = text.replacingOccurrences(of: "\u{E000}\(i)\u{E000}", with: span)
}
return text
}
nonisolated private static func replacingCaptures(
_ text: String,
pattern: String,
transform: @escaping (String) -> String
) -> String {
guard let regex = try? Regex(pattern) else { return text }
return text.replacing(regex) { match in
transform(match.output.count > 1 ? String(match.output[1].substring ?? "") : "")
}
}
nonisolated private static func replacingCaptures(
_ text: String,
pattern: String,
groups: Int,
transform: @escaping ([String]) -> String
) -> String {
guard let regex = try? Regex(pattern) else { return text }
return text.replacing(regex) { match in
let captured = (1...groups).map { i in
match.output.count > i ? String(match.output[i].substring ?? "") : ""
}
return transform(captured)
}
}
}
+26 -16
View File
@@ -727,7 +727,7 @@ Don't narrate future actions ("Let me...") - just use the tools.
let filename = args.count >= 2 ? args[1] : "conversation.\(format)" let filename = args.count >= 2 ? args[1] : "conversation.\(format)"
exportConversation(format: format, filename: filename) exportConversation(format: format, filename: filename)
} else { } else {
showSystemMessage("Usage: /export md|json <filename>") showSystemMessage("Usage: /export md|html|pdf|json <filename>")
} }
case "/info": case "/info":
@@ -1769,13 +1769,29 @@ Don't narrate future actions ("Let me...") - just use the tools.
return return
} }
if format == "pdf" {
let name = currentConversationName ?? "conversation"
Task { @MainActor in
do {
let data = try await ConversationExportService.pdfData(name: name, messages: chatMessages)
if let url = ConversationExportService.writeToDownloads(data, filename: filename) {
showSystemMessage("Exported to \(url.path)")
} else {
showSystemMessage("Export failed: could not write file")
}
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
}
}
return
}
let content: String let content: String
switch format { switch format {
case "md", "markdown": case "md", "markdown":
content = chatMessages.map { msg in content = ConversationExportService.markdown(messages: chatMessages)
let header = msg.role == .user ? "**User**" : "**Assistant**" case "html":
return "\(header)\n\n\(msg.content)" content = ConversationExportService.html(name: currentConversationName ?? "conversation", messages: chatMessages)
}.joined(separator: "\n\n---\n\n")
case "json": case "json":
let dicts = chatMessages.map { msg -> [String: String] in let dicts = chatMessages.map { msg -> [String: String] in
["role": msg.role.rawValue, "content": msg.content] ["role": msg.role.rawValue, "content": msg.content]
@@ -1788,20 +1804,14 @@ Don't narrate future actions ("Let me...") - just use the tools.
return return
} }
default: default:
showSystemMessage("Unsupported format: \(format). Use md or json.") showSystemMessage("Unsupported format: \(format). Use md, html, pdf, or json.")
return return
} }
// Write to Downloads folder if let url = ConversationExportService.writeToDownloads(content, filename: filename) {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first showSystemMessage("Exported to \(url.path)")
?? FileManager.default.temporaryDirectory } else {
let fileURL = downloads.appendingPathComponent(filename) showSystemMessage("Export failed: could not write file")
do {
try content.write(to: fileURL, atomically: true, encoding: .utf8)
showSystemMessage("Exported to \(fileURL.path)")
} catch {
showSystemMessage("Export failed: \(error.localizedDescription)")
} }
} }
+3 -1
View File
@@ -54,7 +54,7 @@ struct InputBar: View {
"/memory on", "/memory off", "/online on", "/online off", "/memory on", "/memory off", "/online on", "/online off",
"/mcp on", "/mcp off", "/mcp status", "/mcp list", "/mcp on", "/mcp off", "/mcp status", "/mcp list",
"/mcp write on", "/mcp write off", "/mcp write on", "/mcp write off",
"/export md", "/export json", "/export md", "/export html", "/export pdf", "/export json",
] ]
var body: some View { var body: some View {
@@ -291,6 +291,8 @@ struct CommandSuggestionsView: View {
("/load", "Load conversation"), ("/load", "Load conversation"),
("/list", "List saved conversations"), ("/list", "List saved conversations"),
("/export md", "Export as Markdown"), ("/export md", "Export as Markdown"),
("/export html", "Export as HTML"),
("/export pdf", "Export as PDF"),
("/export json", "Export as JSON"), ("/export json", "Export as JSON"),
("/info", "Show model information"), ("/info", "Show model information"),
("/credits", "Check account credits"), ("/credits", "Check account credits"),
+42 -10
View File
@@ -400,6 +400,25 @@ struct ConversationListView: View {
} label: { } label: {
Label("Move to Folder", systemImage: "folder") Label("Move to Folder", systemImage: "folder")
} }
Menu {
Button {
exportConversation(conversation, format: "md")
} label: {
Label("Markdown", systemImage: "doc.text")
}
Button {
exportConversation(conversation, format: "html")
} label: {
Label("HTML", systemImage: "chevron.left.forwardslash.chevron.right")
}
Button {
exportConversation(conversation, format: "pdf")
} label: {
Label("PDF", systemImage: "doc.richtext")
}
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
Button { Button {
renameConversation(conversation) renameConversation(conversation)
} label: { } label: {
@@ -662,21 +681,34 @@ struct ConversationListView: View {
} }
} }
private func exportConversation(_ conversation: Conversation) { private func exportConversation(_ conversation: Conversation, format: String = "md") {
guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id), guard let (_, loadedMessages) = try? DatabaseService.shared.loadConversation(id: conversation.id),
!loadedMessages.isEmpty else { !loadedMessages.isEmpty else {
return return
} }
let content = loadedMessages.map { msg in let baseName = conversation.name.replacingOccurrences(of: " ", with: "_")
let header = msg.role == .user ? "**User**" : "**Assistant**"
return "\(header)\n\n\(msg.content)"
}.joined(separator: "\n\n---\n\n")
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first if format == "pdf" {
?? FileManager.default.temporaryDirectory Task { @MainActor in
let filename = conversation.name.replacingOccurrences(of: " ", with: "_") + ".md" guard let data = try? await ConversationExportService.pdfData(name: conversation.name, messages: loadedMessages) else {
let fileURL = downloads.appendingPathComponent(filename) return
try? content.write(to: fileURL, atomically: true, encoding: .utf8) }
_ = ConversationExportService.writeToDownloads(data, filename: baseName + ".pdf")
}
return
}
let content: String
let filename: String
switch format {
case "html":
content = ConversationExportService.html(name: conversation.name, messages: loadedMessages)
filename = baseName + ".html"
default:
content = ConversationExportService.markdown(messages: loadedMessages)
filename = baseName + ".md"
}
_ = ConversationExportService.writeToDownloads(content, filename: filename)
} }
} }
+3 -3
View File
@@ -134,10 +134,10 @@ private let helpCategories: [CommandCategory] = [
examples: ["/delete old-chat", "/delete test"] examples: ["/delete old-chat", "/delete test"]
), ),
CommandDetail( CommandDetail(
command: "/export md|json", command: "/export md|html|pdf|json",
brief: "Export conversation", brief: "Export conversation",
detail: "Exports the current conversation to a file. Supports Markdown (.md) and JSON (.json) formats. Optionally provide a custom filename.", detail: "Exports the current conversation to a file. Supports Markdown (.md), HTML (.html), PDF (.pdf), and JSON (.json) formats. Optionally provide a custom filename.",
examples: ["/export md", "/export json", "/export md my-chat.md"] examples: ["/export md", "/export html", "/export pdf", "/export json", "/export md my-chat.md"]
), ),
]), ]),
CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [ CommandCategory(name: "MCP (File Access)", icon: "folder.badge.gearshape", commands: [
+14
View File
@@ -124,6 +124,20 @@ struct oAIApp: App {
chatViewModel.exportConversation(format: "md", filename: "\(safe).md") chatViewModel.exportConversation(format: "md", filename: "\(safe).md")
} }
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty) .disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as HTML…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversation(format: "html", filename: "\(safe).html")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
Button("Export as PDF…") {
let name = chatViewModel.currentConversationName ?? "conversation"
let safe = name.components(separatedBy: .whitespacesAndNewlines).joined(separator: "-")
chatViewModel.exportConversation(format: "pdf", filename: "\(safe).pdf")
}
.disabled(chatViewModel.messages.filter { $0.role != .system }.isEmpty)
} }
// Chat menu // Chat menu
@@ -0,0 +1,104 @@
//
// ConversationExportServiceTests.swift
// oAITests
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
import Testing
import Foundation
@testable import oAI
@Suite("ConversationExportService")
struct ConversationExportServiceTests {
// MARK: - Markdown
@Test("Markdown format matches the established User/Assistant + --- convention")
func markdownFormat() {
let messages = [
Message(role: .user, content: "hi"),
Message(role: .assistant, content: "hello there"),
]
let result = ConversationExportService.markdown(messages: messages)
#expect(result == "**User**\n\nhi\n\n---\n\n**Assistant**\n\nhello there")
}
// MARK: - HTML escaping
@Test("HTML-escapes angle brackets and ampersands instead of rendering them as markup")
func escapesHTMLSpecialCharacters() {
let messages = [Message(role: .assistant, content: "<script>alert('x')</script> & more")]
let html = ConversationExportService.html(name: "Test", messages: messages)
#expect(!html.contains("<script>alert"))
#expect(html.contains("&lt;script&gt;"))
#expect(html.contains("&amp;"))
}
@Test("HTML document includes the conversation name as title and heading")
func includesConversationName() {
let messages = [Message(role: .user, content: "hi")]
let html = ConversationExportService.html(name: "My <Chat>", messages: messages)
#expect(html.contains("<title>My &lt;Chat&gt;</title>"))
#expect(html.contains("<h1>My &lt;Chat&gt;</h1>"))
}
// MARK: - Block-level markdown rendering
@Test("Renders a header line as an <h1> tag")
func rendersHeader() {
let body = ConversationExportService.renderMarkdownBody("# Title")
#expect(body.contains("<h1>Title</h1>"))
}
@Test("Renders a fenced code block with a language class, without applying inline formatting inside it")
func rendersFencedCodeBlock() {
let body = ConversationExportService.renderMarkdownBody("```swift\nlet x = **not bold**\n```")
#expect(body.contains("<pre><code class=\"language-swift\">"))
#expect(body.contains("let x = **not bold**"))
#expect(!body.contains("<strong>not bold</strong>"))
}
@Test("Renders an unordered list as <ul><li> items")
func rendersUnorderedList() {
let body = ConversationExportService.renderMarkdownBody("- one\n- two")
#expect(body.contains("<ul>"))
#expect(body.contains("<li>one</li>"))
#expect(body.contains("<li>two</li>"))
}
@Test("Renders an ordered list as <ol><li> items")
func rendersOrderedList() {
let body = ConversationExportService.renderMarkdownBody("1. first\n2. second")
#expect(body.contains("<ol>"))
#expect(body.contains("<li>first</li>"))
#expect(body.contains("<li>second</li>"))
}
@Test("Renders a horizontal rule as <hr>")
func rendersHorizontalRule() {
let body = ConversationExportService.renderMarkdownBody("above\n\n---\n\nbelow")
#expect(body.contains("<hr>"))
}
@Test("Renders a plain paragraph wrapped in <p>")
func rendersParagraph() {
let body = ConversationExportService.renderMarkdownBody("just some text")
#expect(body.contains("<p>just some text</p>"))
}
@Test("Renders inline bold, italic, and inline code within a paragraph")
func rendersInlineFormatting() {
let body = ConversationExportService.renderMarkdownBody("**bold** and *italic* and `code`")
#expect(body.contains("<strong>bold</strong>"))
#expect(body.contains("<em>italic</em>"))
#expect(body.contains("<code>code</code>"))
}
@Test("Does not apply bold/italic formatting to asterisks inside an inline code span")
func inlineCodeProtectsItsContentFromOtherInlineFormatting() {
let body = ConversationExportService.renderMarkdownBody("`**not bold**`")
#expect(body.contains("<code>**not bold**</code>"))
#expect(!body.contains("<strong>"))
}
}