From 5a4ce586dea0a2cf7ddbccf1ec516a809b993fda Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Wed, 1 Jul 2026 14:50:28 +0200 Subject: [PATCH] feat(ios): add inline code rendering and theme Style inline code spans with monospaced font, tint/background from theme, and EnrichedMarkdownInlineCode attribute for color preservation. Co-authored-by: Cursor --- .../Rendering/FontHelpers.swift | 15 ++++ .../Rendering/RendererFactory.swift | 2 + .../Rendering/Renderers/CodeRenderer.swift | 38 ++++++++++ .../Theme/DefaultMarkdownTheme.swift | 5 ++ .../Theme/Elements/Code.swift | 44 +++++++++++ .../Theme/MarkdownStyleConfig.swift | 12 ++- .../EnrichedMarkdownTests/RendererTests.swift | 76 +++++++++++++++++++ 7 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/CodeRenderer.swift create mode 100644 packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Code.swift diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift index d9285b49f..4dfe9e9db 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/FontHelpers.swift @@ -26,4 +26,19 @@ enum FontHelpers { static func cachedFont(from blockStyle: BlockStyle?) -> UIFont? { blockStyle?.font } + + static func ensureMonospaced(_ font: UIFont?, configFont: UIFont?) -> UIFont? { + if let configFont { + return configFont + } + guard let font else { + return UIFont.monospacedSystemFont( + ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, + weight: .regular + ) + } + + let weight: UIFont.Weight = font.fontDescriptor.symbolicTraits.contains(.traitBold) ? .bold : .regular + return UIFont.monospacedSystemFont(ofSize: font.pointSize, weight: weight) + } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift index ed424c4da..957ec1476 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RendererFactory.swift @@ -45,6 +45,8 @@ final class RendererFactory { return LinkRenderer(factory: self, config: config) case .lineBreak: return LineBreakRenderer() + case .code: + return CodeRenderer(factory: self, config: config) default: return childrenOnlyRenderer } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/CodeRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/CodeRenderer.swift new file mode 100644 index 000000000..e25b8404b --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/CodeRenderer.swift @@ -0,0 +1,38 @@ +import UIKit + +final class CodeRenderer: NodeRenderer { + private let factory: RendererFactory + private let config: MarkdownStyleConfig + + init(factory: RendererFactory, config: MarkdownStyleConfig) { + self.factory = factory + self.config = config + } + + func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) { + let start = output.length + factory.renderChildren(of: node, into: output, context: context) + + let range = RenderContext.rangeForRenderedContent(in: output, start: start) + guard range.length > 0 else { return } + + let codeStyle = config.code + + output.enumerateAttributes(in: range, options: []) { attributes, subrange, _ in + let currentFont = (attributes[.font] as? UIFont) ?? FontHelpers.cachedFont(from: context.getBlockStyle()) + if let resolvedFont = FontHelpers.ensureMonospaced(currentFont, configFont: codeStyle.font) { + output.addAttribute(.font, value: resolvedFont, range: subrange) + } + + if let codeColor = codeStyle.foregroundColor { + output.addAttribute(.foregroundColor, value: codeColor, range: subrange) + } + + if let backgroundColor = codeStyle.backgroundColor { + output.addAttribute(.backgroundColor, value: backgroundColor, range: subrange) + } + + output.addAttribute(MarkdownAttribute.inlineCode, value: true, range: subrange) + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift index cc1a5086e..37fb47f9d 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/DefaultMarkdownTheme.swift @@ -51,6 +51,11 @@ enum DefaultMarkdownTheme { Strong() Emphasis() + + Code() + .fontDesign(.monospaced) + .foregroundStyle(Semantic.secondary) + .background(Semantic.quaternary) } } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Code.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Code.swift new file mode 100644 index 000000000..066e8ced9 --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/Elements/Code.swift @@ -0,0 +1,44 @@ +import SwiftUI + +public struct Code: MarkdownThemeElement { + public var fontSpec: ThemeFontSpec? + public var fontWeight: Font.Weight? + public var fontDesign: Font.Design? + public var foregroundColorSpec: ThemeColorSpec? + public var backgroundColorSpec: ThemeColorSpec? + public var marginTop: CGFloat? + public var marginBottom: CGFloat? + public var lineHeight: CGFloat? + public var textAlignment: TextAlignment? + + public init() { + fontDesign = .monospaced + } + + public func backgroundStyle(_ color: Color) -> Self { + var copy = self + copy.backgroundColorSpec = ThemeColorModifiers.spec(from: color) + return copy + } + + public func backgroundStyle(_ semantic: ThemeColorSpec.SemanticColor) -> Self { + var copy = self + copy.backgroundColorSpec = ThemeColorModifiers.spec(from: semantic) + return copy + } + + public func background(_ color: Color) -> Self { + backgroundStyle(color) + } + + public func background(_ semantic: ThemeColorSpec.SemanticColor) -> Self { + backgroundStyle(semantic) + } + + public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) { + applyElementStyle(to: &config.code, traitCollection: traitCollection) + if let backgroundColorSpec { + config.code.backgroundColor = backgroundColorSpec.resolve(traitCollection: traitCollection) + } + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift index 990684bec..a591c42c5 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Theme/MarkdownStyleConfig.swift @@ -3,6 +3,7 @@ import UIKit public struct ElementStyle: Equatable, Sendable { public var font: UIFont? public var foregroundColor: UIColor? + public var backgroundColor: UIColor? public var marginTop: CGFloat? public var marginBottom: CGFloat? public var lineHeight: CGFloat? @@ -12,6 +13,7 @@ public struct ElementStyle: Equatable, Sendable { public init( font: UIFont? = nil, foregroundColor: UIColor? = nil, + backgroundColor: UIColor? = nil, marginTop: CGFloat? = nil, marginBottom: CGFloat? = nil, lineHeight: CGFloat? = nil, @@ -20,6 +22,7 @@ public struct ElementStyle: Equatable, Sendable { ) { self.font = font self.foregroundColor = foregroundColor + self.backgroundColor = backgroundColor self.marginTop = marginTop self.marginBottom = marginBottom self.lineHeight = lineHeight @@ -30,6 +33,7 @@ public struct ElementStyle: Equatable, Sendable { public mutating func merge(_ other: ElementStyle) { if let font = other.font { self.font = font } if let foregroundColor = other.foregroundColor { self.foregroundColor = foregroundColor } + if let backgroundColor = other.backgroundColor { self.backgroundColor = backgroundColor } if let marginTop = other.marginTop { self.marginTop = marginTop } if let marginBottom = other.marginBottom { self.marginBottom = marginBottom } if let lineHeight = other.lineHeight { self.lineHeight = lineHeight } @@ -49,6 +53,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { public var link: ElementStyle public var strong: ElementStyle public var emphasis: ElementStyle + public var code: ElementStyle public init( paragraph: ElementStyle = ElementStyle(), @@ -60,7 +65,8 @@ public struct MarkdownStyleConfig: Equatable, Sendable { heading6: ElementStyle = ElementStyle(), link: ElementStyle = ElementStyle(), strong: ElementStyle = ElementStyle(), - emphasis: ElementStyle = ElementStyle() + emphasis: ElementStyle = ElementStyle(), + code: ElementStyle = ElementStyle() ) { self.paragraph = paragraph self.heading1 = heading1 @@ -72,6 +78,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { self.link = link self.strong = strong self.emphasis = emphasis + self.code = code } public mutating func merge(_ other: MarkdownStyleConfig) { @@ -85,6 +92,7 @@ public struct MarkdownStyleConfig: Equatable, Sendable { link.merge(other.link) strong.merge(other.strong) emphasis.merge(other.emphasis) + code.merge(other.code) } public func headingStyle(for level: Int) -> ElementStyle { @@ -110,3 +118,5 @@ public struct MarkdownStyleConfig: Equatable, Sendable { default: heading1 = style } } + +} diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift index 8f0ec4c93..0127782b3 100644 --- a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/RendererTests.swift @@ -315,6 +315,82 @@ final class RendererTests: XCTestCase { let attributes = result.attributes(at: separatorRange.location, effectiveRange: &effectiveRange) XCTAssertNotNil(attributes[.font] as? UIFont) } + + + func testInlineCodeUsesMonospacedFont() { + let result = MarkdownRenderer.render("Use `code` here", config: config) + XCTAssertTrue(result.string.contains("code")) + + var foundMonospaced = false + result.enumerateAttribute(.font, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let font = value as? UIFont else { return } + if result.string[range] == "code" { + XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + foundMonospaced = true + } + } + XCTAssertTrue(foundMonospaced) + } + + + func testInlineCodeHasInlineCodeAttribute() { + let result = MarkdownRenderer.render("`code`", config: config) + + var found = false + result.enumerateAttribute(MarkdownAttribute.inlineCode, in: NSRange(location: 0, length: result.length)) { value, range, _ in + if result.string[range] == "code", value as? Bool == true { + found = true + } + } + XCTAssertTrue(found) + } + + + func testInlineCodeAppliesBackgroundColor() { + let result = MarkdownRenderer.render("`code`", config: config) + + var foundBackground = false + result.enumerateAttribute(.backgroundColor, in: NSRange(location: 0, length: result.length)) { value, range, _ in + if result.string[range] == "code", value != nil { + foundBackground = true + } + } + XCTAssertTrue(foundBackground) + } + + + func testInlineCodeThemeOverrideAppliesColor() { + var customConfig = config! + customConfig.code.foregroundColor = .systemPink + + let result = MarkdownRenderer.render("`code`", config: customConfig) + + var foundPink = false + result.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let color = value as? UIColor else { return } + if result.string[range] == "code" { + XCTAssertEqual(color, UIColor.systemPink) + foundPink = true + } + } + XCTAssertTrue(foundPink) + } + + + func testBoldInlineCodePreservesBoldWeight() { + let result = MarkdownRenderer.render("**`code`**", config: config) + + var foundBoldMonospaced = false + result.enumerateAttribute(.font, in: NSRange(location: 0, length: result.length)) { value, range, _ in + guard let font = value as? UIFont else { return } + if result.string[range] == "code" { + XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitMonoSpace)) + XCTAssertTrue(font.fontDescriptor.symbolicTraits.contains(.traitBold)) + foundBoldMonospaced = true + } + } + XCTAssertTrue(foundBoldMonospaced) + } } private extension String {