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:
@@ -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("<script>"))
|
||||
#expect(html.contains("&"))
|
||||
}
|
||||
|
||||
@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 <Chat></title>"))
|
||||
#expect(html.contains("<h1>My <Chat></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>"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user