Skip to content
Merged
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
5 changes: 5 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ All notable changes to QuickMD will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed
- Tables: an escaped pipe (`\|`) no longer splits a cell. It shows as a literal `|`, inside inline code too (`` `--part 1\|2` `` reads `--part 1|2`), as GitHub renders it; before, the row gained a column and the code span broke apart.

## [1.13.0] - 2026-10-22

Tables you can select, reload on demand, and tabs that behave in full screen. Thanks to [@sporto](https://github.com/sporto) (#33), [@rinh](https://github.com/rinh) (#37) and [@onemannoplan](https://github.com/onemannoplan) (#36)!
Expand Down
45 changes: 31 additions & 14 deletions QuickMD/QuickMD/MarkdownBlockParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -461,25 +461,42 @@ struct MarkdownBlockParser: Sendable {
}

/// Parse a table row into array of cell strings
/// Handles leading/trailing pipes and filters out empty cells from malformed rows
/// Handles leading/trailing pipes and filters out empty cells from malformed rows.
/// An escaped pipe (`\|`) does not split the row: as in GFM it becomes a
/// literal `|` in the cell, inside code spans too (`` `a\|b` `` shows `a|b`).
/// `\\|` is an escaped backslash followed by a real column pipe.
private func parseTableRow(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

// Remove leading pipe if present
if trimmed.hasPrefix("|") {
trimmed = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)

var cells: [String] = []
var cell = ""
var escaping = false
for ch in trimmed {
if escaping {
// Other escapes stay for the inline renderer to resolve
if ch != "|" { cell.append("\\") }
cell.append(ch)
escaping = false
} else if ch == "\\" {
escaping = true
} else if ch == "|" {
cells.append(cell)
cell = ""
} else {
cell.append(ch)
}
}
if escaping { cell.append("\\") }
cells.append(cell)

// Remove trailing pipe if present
if trimmed.hasSuffix("|") {
trimmed = String(trimmed.dropLast()).trimmingCharacters(in: .whitespaces)
// A leading pipe opens the row rather than ending an empty first cell
if trimmed.hasPrefix("|") {
cells.removeFirst()
}

// Split and trim each cell, filter out completely empty cells that result from trailing/leading pipes
let cells = trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }

// Only filter trailing empty cells (preserve intentional empty cells in middle)
var result = cells
// Only filter trailing empty cells (preserve intentional empty cells in middle).
// This also drops the empty remainder after a trailing pipe.
var result = cells.map { $0.trimmingCharacters(in: .whitespaces) }
while result.last?.isEmpty == true {
result.removeLast()
}
Expand Down
20 changes: 20 additions & 0 deletions QuickMD/QuickMDTests/ParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,26 @@ final class ParserTests: XCTestCase {
XCTAssertEqual(rows[1], ["**Toaster**", "Two slots, one crumb tray"])
}

func testEscapedPipeStaysInsideItsCell() {
let md = """
| Command | Purpose |
| --- | --- |
| `collect [--part 1\\|2]` | a \\| b |
| path\\\\ | after an escaped backslash |
| ends with pipe \\| | x |
"""
let blocks = parse(md)
guard case .table(let headers, let rows, _) = blocks[0].content else {
return XCTFail("expected table")
}
XCTAssertEqual(headers, ["Command", "Purpose"])
// `\\|` is a literal pipe, also inside a code span (GFM)
XCTAssertEqual(rows[0], ["`collect [--part 1|2]`", "a | b"])
// `\\\\|`: the backslash is escaped, the pipe still splits
XCTAssertEqual(rows[1], ["path\\\\", "after an escaped backslash"])
XCTAssertEqual(rows[2], ["ends with pipe |", "x"])
}

// MARK: - Blockquotes

func testBlockquoteNestingLevels() {
Expand Down
Loading