diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 525c523..d4851ec 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -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)! diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index 4c46388..57c4c1b 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -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() } diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift index 1af147f..ef7ecf7 100644 --- a/QuickMD/QuickMDTests/ParserTests.swift +++ b/QuickMD/QuickMDTests/ParserTests.swift @@ -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() {