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
@@ -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>"))
}
}