Add graceful fallback to SwiftUI's native AttributedString markdown rendering when the Textual resource bundle is not found. This prevents the app from crashing with an NSBundle.module assertion failure when code blocks are rendered without the prism-bundle.js resource being properly embedded. Fixes #2002
146 lines
4.7 KiB
Swift
146 lines
4.7 KiB
Swift
import SwiftUI
|
|
import Textual
|
|
|
|
public enum ChatMarkdownVariant: String, CaseIterable, Sendable {
|
|
case standard
|
|
case compact
|
|
}
|
|
|
|
// MARK: - Textual Bundle Availability
|
|
|
|
/// Checks if the Textual syntax highlighting bundle is available.
|
|
/// This must be called BEFORE any Textual types are accessed to avoid a crash
|
|
/// from SPM's generated Bundle.module accessor when the bundle is missing.
|
|
private let textualBundleAvailable: Bool = {
|
|
let bundleNames = ["textual_Textual", "Textual_Textual"]
|
|
guard let resourceURL = Bundle.main.resourceURL else { return false }
|
|
for name in bundleNames {
|
|
let bundleURL = resourceURL.appendingPathComponent("\(name).bundle")
|
|
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}()
|
|
|
|
@MainActor
|
|
struct ChatMarkdownRenderer: View {
|
|
enum Context {
|
|
case user
|
|
case assistant
|
|
}
|
|
|
|
let text: String
|
|
let context: Context
|
|
let variant: ChatMarkdownVariant
|
|
let font: Font
|
|
let textColor: Color
|
|
|
|
var body: some View {
|
|
let processed = ChatMarkdownPreprocessor.preprocess(markdown: self.text)
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if textualBundleAvailable {
|
|
StructuredText(markdown: processed.cleaned)
|
|
.modifier(ChatMarkdownStyle(
|
|
variant: self.variant,
|
|
context: self.context,
|
|
font: self.font,
|
|
textColor: self.textColor))
|
|
} else {
|
|
// Fallback when Textual's resource bundle is missing (avoids crash).
|
|
FallbackMarkdownText(
|
|
text: processed.cleaned,
|
|
font: self.font,
|
|
textColor: self.textColor)
|
|
}
|
|
|
|
if !processed.images.isEmpty {
|
|
InlineImageList(images: processed.images)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ChatMarkdownStyle: ViewModifier {
|
|
let variant: ChatMarkdownVariant
|
|
let context: ChatMarkdownRenderer.Context
|
|
let font: Font
|
|
let textColor: Color
|
|
|
|
func body(content: Content) -> some View {
|
|
Group {
|
|
if self.variant == .compact {
|
|
content.textual.structuredTextStyle(.default)
|
|
} else {
|
|
content.textual.structuredTextStyle(.gitHub)
|
|
}
|
|
}
|
|
.font(self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.textual.inlineStyle(self.inlineStyle)
|
|
.textual.textSelection(.enabled)
|
|
}
|
|
|
|
private var inlineStyle: InlineStyle {
|
|
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
|
|
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
|
|
return InlineStyle()
|
|
.code(.monospaced, .fontScale(codeScale))
|
|
.link(.foregroundColor(linkColor))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct InlineImageList: View {
|
|
let images: [ChatMarkdownPreprocessor.InlineImage]
|
|
|
|
var body: some View {
|
|
ForEach(images, id: \.id) { item in
|
|
if let img = item.image {
|
|
OpenClawPlatformImageFactory.image(img)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.frame(maxHeight: 260)
|
|
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.12), lineWidth: 1))
|
|
} else {
|
|
Text(item.label.isEmpty ? "Image" : item.label)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fallback Markdown Rendering
|
|
|
|
/// Fallback markdown renderer using SwiftUI's native AttributedString.
|
|
/// Used when Textual's resource bundle is missing to avoid crashes.
|
|
@MainActor
|
|
private struct FallbackMarkdownText: View {
|
|
let text: String
|
|
let font: Font
|
|
let textColor: Color
|
|
|
|
var body: some View {
|
|
if let attributed = try? AttributedString(markdown: self.text, options: Self.markdownOptions) {
|
|
Text(attributed)
|
|
.font(self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.textSelection(.enabled)
|
|
} else {
|
|
Text(self.text)
|
|
.font(self.font)
|
|
.foregroundStyle(self.textColor)
|
|
.textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
private static let markdownOptions = AttributedString.MarkdownParsingOptions(
|
|
allowsExtendedAttributes: true,
|
|
interpretedSyntax: .inlineOnlyPreservingWhitespace,
|
|
failurePolicy: .returnPartiallyParsedIfPossible)
|
|
}
|