Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion QuickMD/QuickMD/MarkdownRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,85 @@ struct MarkdownRenderer: Sendable {
func render(_ markdown: String) -> AttributedString {
var result = AttributedString()

for line in markdown.components(separatedBy: "\n") {
for line in Self.joinSoftBreaks(markdown.components(separatedBy: "\n")) {
result.append(renderLine(line))
result.append(AttributedString("\n"))
}

return result
}

// MARK: - Soft Breaks

/// CommonMark renders a single newline inside a paragraph as a space (a
/// soft break), not a line break. The renderer is line-based, so this
/// pre-pass joins consecutive paragraph lines into one logical line before
/// they reach `renderLine`. A list item also absorbs the plain lines that
/// follow it (lazy continuation), so wrapped item text stays inside the
/// item and gets its hanging indent instead of dropping to the left
/// margin as a bogus paragraph. A hard break — two or more trailing
/// spaces, or a trailing backslash — still ends the visual line, and
/// headers, rules, and blanks always keep their own line.
static func joinSoftBreaks(_ lines: [String]) -> [String] {
var joined: [String] = []
var openParagraph = false // last joined line can absorb a continuation

for line in lines {
let kind = classify(line)
guard kind != .structural else {
joined.append(line)
openParagraph = false
continue
}

let hardBreak = endsWithHardBreak(line)
var text = line
while text.last == " " || text.last == "\t" { text.removeLast() }
if hardBreak && text.hasSuffix("\\") { text.removeLast() }

if kind == .paragraph && openParagraph {
joined[joined.count - 1] += " " + text.trimmingCharacters(in: .whitespaces)
} else {
joined.append(text)
}
openParagraph = !hardBreak
}

return joined
}

private enum LineKind {
/// Blank, header, or horizontal rule — never joins with anything.
case structural
/// Bullet/ordered/task item — always starts its own joined line, but
/// leaves its paragraph open so following plain lines merge into the
/// item (CommonMark lazy continuation).
case listItem
/// Regular paragraph text — continues an open paragraph, or opens one.
case paragraph
}

/// Must mirror the dispatch in `renderLine`, or a structural line could
/// get glued into the paragraph before it.
private static func classify(_ line: String) -> LineKind {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty { return .structural }
let nsRange = NSRange(line.startIndex..., in: line)
if headerRegex.firstMatch(in: line, range: nsRange) != nil { return .structural }
if trimmed.range(of: MarkdownTheme.horizontalRulePattern, options: .regularExpression) != nil { return .structural }
if taskListRegex.firstMatch(in: line, range: nsRange) != nil { return .listItem }
if ["- ", "* ", "+ "].contains(where: { trimmed.hasPrefix($0) }) { return .listItem }
if trimmed.range(of: #"^(\d+)\.\s"#, options: .regularExpression) != nil { return .listItem }
return .paragraph
}

/// Two-plus trailing spaces, or an odd run of trailing backslashes (an
/// even run is escaped literal backslashes, e.g. `foo\\`).
private static func endsWithHardBreak(_ line: String) -> Bool {
if line.hasSuffix(" ") { return true }
return line.reversed().prefix(while: { $0 == "\\" }).count % 2 == 1
}

// MARK: - Line Rendering

private func renderLine(_ line: String) -> AttributedString {
Expand Down
4 changes: 3 additions & 1 deletion QuickMD/QuickMD/Views/AlertBlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ struct AlertBlockView: View {

/// Inline-render each body line and join with newlines — one attributed
/// string for the whole alert body (same approach as BlockquoteView).
/// Soft breaks are joined first so a single newline inside a paragraph
/// reads as a space (CommonMark).
private func renderedContent() -> AttributedString {
var result = AttributedString()
let lines = content.components(separatedBy: "\n")
let lines = MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n"))
for (index, line) in lines.enumerated() {
if line.trimmingCharacters(in: .whitespaces).isEmpty {
result.append(AttributedString(" "))
Expand Down
5 changes: 3 additions & 2 deletions QuickMD/QuickMD/Views/BlockquoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,11 @@ struct BlockquoteView: View {
}

/// Inline-render each quote line and join with newlines — one attributed
/// string for the whole quote body.
/// string for the whole quote body. Soft breaks are joined first so a
/// single newline inside a quote paragraph reads as a space (CommonMark).
private func renderedContent() -> AttributedString {
var result = AttributedString()
let lines = content.components(separatedBy: "\n")
let lines = MarkdownRenderer.joinSoftBreaks(content.components(separatedBy: "\n"))
for (index, line) in lines.enumerated() {
if line.trimmingCharacters(in: .whitespaces).isEmpty {
result.append(AttributedString(" "))
Expand Down
77 changes: 77 additions & 0 deletions QuickMD/QuickMDTests/RendererTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,83 @@ final class RendererTests: XCTestCase {
XCTAssertTrue(out.contains(" • nested"))
}

// MARK: - Soft line breaks

/// CommonMark: a single newline inside a paragraph is a soft break,
/// rendered as a space — not a visible line break.
func testSingleNewlineJoinsParagraphLines() {
let out = String(renderer.render("one\ntwo").characters)
XCTAssertTrue(out.contains("one two"), "soft break rendered as line break: \(out)")
}

func testBlankLineStillSeparatesParagraphs() {
let out = String(renderer.render("one\n\ntwo").characters)
XCTAssertFalse(out.contains("one two"))
}

func testTrailingDoubleSpaceIsHardBreak() {
let out = String(renderer.render("one \ntwo").characters)
XCTAssertTrue(out.contains("one\ntwo"))
}

func testTrailingBackslashIsHardBreak() {
let out = String(renderer.render("one\\\ntwo").characters)
XCTAssertTrue(out.contains("one\ntwo"))
XCTAssertFalse(out.contains("\\"), "hard-break backslash must be consumed")
}

func testEscapedTrailingBackslashIsNotHardBreak() {
// `one\\` is a literal backslash, so the newline stays a soft break.
let out = String(renderer.render("one\\\\\ntwo").characters)
XCTAssertTrue(out.contains("one\\ two"))
}

func testStructuralLinesAreNotGluedIntoParagraph() {
XCTAssertFalse(String(renderer.render("para\n# Title").characters).contains("para #"))
XCTAssertFalse(String(renderer.render("para\n- item").characters).contains("para •"))
XCTAssertFalse(String(renderer.render("para\n1. item").characters).contains("para 1."))
}

func testListItemsKeepTheirOwnLines() {
let out = String(renderer.render("- one\n- two").characters)
XCTAssertTrue(out.contains("• one\n"))
XCTAssertTrue(out.contains("• two"))
}

func testContinuationLineLeadingIndentIsDropped() {
let out = String(renderer.render("one\n two").characters)
XCTAssertTrue(out.contains("one two"))
}

/// The bug: wrapped item text on its own (indented) source lines fell out
/// of the item and rendered as a flush-left paragraph. Lazy continuation
/// pulls it back into the item, where the hanging indent applies.
func testListItemAbsorbsWrappedContinuationLines() {
let out = String(renderer.render("- first part,\n wrapped middle,\n wrapped tail").characters)
XCTAssertTrue(out.contains("• first part, wrapped middle, wrapped tail"))
}

func testOrderedItemAbsorbsWrappedContinuationLines() {
let out = String(renderer.render("1. first part\n wrapped tail").characters)
XCTAssertTrue(out.contains("1. first part wrapped tail"))
}

func testTaskItemAbsorbsWrappedContinuationLines() {
let out = String(renderer.render("- [ ] first part\n wrapped tail").characters)
XCTAssertTrue(out.contains("☐ first part wrapped tail"))
}

func testNextListItemIsNotAbsorbedAsContinuation() {
let out = String(renderer.render("- one\n- two").characters)
XCTAssertTrue(out.contains("• one\n"))
XCTAssertTrue(out.contains("• two"))
}

func testBlankLineEndsListItemParagraph() {
let out = String(renderer.render("- item\n\npara").characters)
XCTAssertFalse(out.contains("item para"))
}

// MARK: - Footnote references

func testFootnoteReferenceRendersSuperscript() {
Expand Down
Loading