diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 525c523..f2f57fd 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -5,6 +5,16 @@ 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] + +### Added +- **Links to headings in the same document** (`[Install](#install)`) scroll to that heading, so a hand-written table of contents works. Headings get the ids GitHub gives them; a link written as the heading text (`#Install Guide`) or to the file itself (`README.md#install`) works too. Before, such a link tried to open the document's folder instead (in Finder, or with a permission error in the Mac App Store version). Asked for by [@SteveHigham](https://github.com/SteveHigham) (#43). +- **How deep the Contents sidebar goes:** All Headings, or up to H2, H3 or H4. Right-click the sidebar, or Settings ▸ General; both change the same setting. Copying a section still ends at the next heading, shown or not. Asked for by [@SteveHigham](https://github.com/SteveHigham) (#42). + +### 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. Contributed by [@rinh](https://github.com/rinh) (#44). +- A closing sequence of `#` after a heading (`## Install ##`) is no longer shown as part of the heading, as in CommonMark and on GitHub. + ## [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..59f8fb2 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -202,7 +202,8 @@ struct MarkdownBlockParser: Sendable { let contentRange = Range(match.range(at: 2), in: line) { flushTextBuffer(&textBuffer, to: &blocks, index: &blockIndex, using: activeRenderer) let level = min(max(line[hashRange].count, 1), 6) - let title = String(line[contentRange]).trimmingCharacters(in: .whitespaces) + let title = Self.withoutClosingSequence( + String(line[contentRange]).trimmingCharacters(in: .whitespaces)) blocks.append(.heading(index: blockIndex, level: level, title: title, sourceLine: sourceLine)) blockIndex += 1 i += 1 @@ -452,6 +453,24 @@ struct MarkdownBlockParser: Sendable { return blocks } + // MARK: - Heading Helpers + + /// An ATX heading's optional closing sequence (CommonMark): a run of `#` + /// at the end of the (trimmed) title, after a space or tab, is not text — + /// `## Install ##` is "Install", `## ##` is empty, while `# C#` and + /// `## foo \##` keep theirs. GitHub's heading ids depend on it (#43). + static func withoutClosingSequence(_ title: String) -> String { + var start = title.endIndex + while start > title.startIndex, title[title.index(before: start)] == "#" { + start = title.index(before: start) + } + if start == title.endIndex { return title } + if start == title.startIndex { return "" } + let before = title[title.index(before: start)] + guard before == " " || before == "\t" else { return title } + return title[.. [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 = "" + for ch in trimmed { + if ch != "|" { + cell.append(ch) + } else if cell.last == "\\" { + cell.removeLast() + cell.append("|") + } else { + cells.append(cell) + cell = "" + } } + 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/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index c0066ad..ac665d1 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -1375,6 +1375,13 @@ struct MarkdownView: View { } private func handleLinkActivation(_ url: URL) { + // In-document link (`#install`, #43). Never a file: it used to resolve + // to the document's folder and open it in Finder. + if let fragment = HeadingAnchors.inDocumentFragment(of: url) { + followAnchor(fragment) + return + } + // Web and mail links open normally switch url.scheme?.lowercased() { case "http", "https", "mailto": @@ -1412,6 +1419,13 @@ struct MarkdownView: View { // It's a relative path, resolve it against the document's directory finalURL = documentDir.appendingPathComponent(url.path) } + // This very document with a fragment (`README.md#install` inside + // README.md) is the same jump as `#install` + if let fragment = url.fragment(percentEncoded: false), + finalURL.standardizedFileURL.path == documentURL.standardizedFileURL.path { + followAnchor(fragment) + return + } } // Open the resolved file URL @@ -1427,6 +1441,22 @@ struct MarkdownView: View { } } + /// Scrolls to the heading an in-document link names, as a ToC click does + /// (`HeadingAnchors.target`: `#` and an unclaimed `#top` are the top); an + /// id no heading has beeps. + private func followAnchor(_ fragment: String) { + let renderer = MarkdownRenderer(theme: theme, fontScale: CGFloat(renderedFontScale)) + let headings = HeadingAnchors.headings(of: cachedBlocks, renderer: renderer) + switch HeadingAnchors.target(forFragment: fragment, in: headings) { + case .heading(let id): + selectHeading(id) + case .top where !cachedBlocks.isEmpty: + requestScroll(to: cachedBlocks[0].id, anchor: .top, animated: true) + case .top, .none: + NSSound.beep() + } + } + } // MARK: - Preview diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift index a26807d..563d209 100644 --- a/QuickMD/QuickMD/SectionExtractor.swift +++ b/QuickMD/QuickMD/SectionExtractor.swift @@ -13,6 +13,129 @@ struct ToCEntry: Identifiable, Sendable { let sourceLine: Int } +// MARK: - Contents Depth + +/// How deep the Contents sidebar lists headings (#42): one stored value, +/// edited from the sidebar's context menu and from Settings ▸ General. It +/// only filters what the sidebar shows; section copy and in-document links +/// still see every heading. +enum ToCDepth { + static let defaultsKey = "tocMaxHeadingLevel" + /// Lists every heading (the default). + static let all = 6 + static let choices: [(maxLevel: Int, title: String)] = [ + (all, "All Headings"), (2, "Up to H2"), (3, "Up to H3"), (4, "Up to H4"), + ] + + static func visible(_ headings: [ToCEntry], maxLevel: Int) -> [ToCEntry] { + maxLevel >= all ? headings : headings.filter { $0.level <= maxLevel } + } +} + +// MARK: - Heading Anchors + +/// In-document links (`[Install](#install)`, #43) use the ids GitHub gives +/// headings, so a hand-written table of contents works as it does there. +/// Every rule below was checked against GitHub's renderer (2026-10-29). +enum HeadingAnchors { + + /// GitHub's id of one heading, from its RENDERED text (`**data**` is + /// "data"): lowercased; letters, marks, decimal and letter numbers, `_`, + /// joiners and `-` kept, each space a `-`, everything else dropped — + /// `What's new in 1.2?` is `whats-new-in-12`, `C++ & C# notes` is + /// `c--c-notes`. + static func slug(_ text: String) -> String { + var slug = String.UnicodeScalarView() + for scalar in text.lowercased().unicodeScalars { + if scalar == " " { + slug.append("-") + } else if scalar == "-" || isWordScalar(scalar) { + slug.append(scalar) + } + } + return String(slug) + } + + private static func isWordScalar(_ scalar: Unicode.Scalar) -> Bool { + switch scalar.properties.generalCategory { + case .uppercaseLetter, .lowercaseLetter, .titlecaseLetter, .modifierLetter, .otherLetter, + .nonspacingMark, .spacingMark, .enclosingMark, + .decimalNumber, .letterNumber, .connectorPunctuation: + return true + default: + // ZWJ inside an emoji sequence stays, as on GitHub + return scalar.properties.isJoinControl + } + } + + /// The ids of headings in document order: a repeat gets `-1`, `-2`, … + /// counted the way github-slugger counts (`Intro`, `Intro`, `Intro-1`, + /// `Intro` are `intro`, `intro-1`, `intro-1-1`, `intro-2`). + static func ids(for texts: [String]) -> [String] { + var occurrences: [String: Int] = [:] + return texts.map { text in + let base = slug(text) + var id = base + while occurrences[id] != nil { + occurrences[base, default: 0] += 1 + id = "\(base)-\(occurrences[base, default: 0])" + } + occurrences[id] = 0 + return id + } + } + + /// The headings of `blocks` as links see them: block id and the text the + /// heading shows (`renderHeader`'s characters, as `HeadingBlockView`). + static func headings(of blocks: [MarkdownBlock], + renderer: MarkdownRenderer) -> [(id: String, text: String)] { + blocks.compactMap { block in + guard case .heading(let level, let title, _) = block.content else { return nil } + return (block.id, String(renderer.renderHeader(title, level: level).characters)) + } + } + + /// The heading a link fragment points at: GitHub's id exactly, else the + /// fragment's own slug — hand-written `#Install Guide` or `#Install-Guide`. + /// nil when no heading has that id. + static func headingId(forFragment fragment: String, + in headings: [(id: String, text: String)]) -> String? { + let ids = ids(for: headings.map(\.text)) + guard let index = ids.firstIndex(of: fragment) ?? ids.firstIndex(of: slug(fragment)) else { + return nil + } + return headings[index].id + } + + enum Target: Equatable { + case heading(String) + case top + case none + } + + /// Where an in-document link goes, as in a browser: `#` is the top of the + /// document (even when a heading of only an emoji, `## 🚀`, has the empty + /// id), `#top` is the top unless a heading claims that id. + static func target(forFragment fragment: String, + in headings: [(id: String, text: String)]) -> Target { + if fragment.isEmpty { return .top } + if let id = headingId(forFragment: fragment, in: headings) { return .heading(id) } + return fragment.lowercased() == "top" ? .top : .none + } + + /// The percent-decoded fragment of an in-document link (`#install`), nil + /// for any other link. Read from the link as written: a destination + /// `URL(string:)` rejects (non-ASCII or spaces on macOS 13) arrives + /// percent-encoded, its `#` as `%23`. The accepted cost: a file whose + /// name starts with `#`, linked as `%23notes.md`, reads as an anchor. + static func inDocumentFragment(of url: URL) -> String? { + guard url.scheme == nil else { return nil } + let written = url.relativeString.removingPercentEncoding ?? url.relativeString + guard written.hasPrefix("#") else { return nil } + return String(written.dropFirst()) + } +} + // MARK: - Section Extractor /// Extracts the raw markdown of a section: from a heading line to the next diff --git a/QuickMD/QuickMD/Views/SettingsView.swift b/QuickMD/QuickMD/Views/SettingsView.swift index 1895e9d..1f9134c 100644 --- a/QuickMD/QuickMD/Views/SettingsView.swift +++ b/QuickMD/QuickMD/Views/SettingsView.swift @@ -19,12 +19,13 @@ struct SettingsView: View { // MARK: - General -/// Settings → General: behaviour that is not about how the document looks. -/// Today only the opt-in auto-copy of selections (v1.11 S-D10); same grouped -/// Form and window size as the other tabs, so switching tabs does not resize -/// the window. +/// Settings → General: behaviour that is not about how the document looks — +/// the opt-in auto-copy of selections (v1.11 S-D10) and the Contents sidebar's +/// depth (#42, also in the sidebar's context menu); same grouped Form and +/// window size as the other tabs, so switching tabs does not resize the window. struct GeneralSettingsView: View { @AppStorage(SelectionAutoCopy.defaultsKey) private var autoCopySelection: Bool = false + @AppStorage(ToCDepth.defaultsKey) private var tocMaxLevel: Int = ToCDepth.all var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -40,6 +41,22 @@ struct GeneralSettingsView: View { .font(.system(size: 11)) .foregroundColor(.secondary) } + + Section { + Picker("Show", selection: $tocMaxLevel) { + ForEach(ToCDepth.choices, id: \.maxLevel) { choice in + Text(choice.title).tag(choice.maxLevel) + } + } + } header: { + Text("Contents Sidebar") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.secondary) + } footer: { + Text("Also in the sidebar's context menu.") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } } .formStyle(.grouped) } diff --git a/QuickMD/QuickMD/Views/TableOfContentsView.swift b/QuickMD/QuickMD/Views/TableOfContentsView.swift index 779df3b..0ee6c0e 100644 --- a/QuickMD/QuickMD/Views/TableOfContentsView.swift +++ b/QuickMD/QuickMD/Views/TableOfContentsView.swift @@ -11,12 +11,14 @@ struct TableOfContentsView: View { let onCollapse: () -> Void @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedTheme") private var selectedThemeName: String = "Auto" + @AppStorage(ToCDepth.defaultsKey) private var maxLevel: Int = ToCDepth.all private var theme: MarkdownTheme { MarkdownTheme.theme(named: selectedThemeName, colorScheme: colorScheme) } var body: some View { + let shown = ToCDepth.visible(headings, maxLevel: maxLevel) VStack(alignment: .leading, spacing: 0) { // Header HStack(spacing: 6) { @@ -49,7 +51,7 @@ struct TableOfContentsView: View { // Headings list ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - ForEach(headings) { entry in + ForEach(shown) { entry in ToCEntryRow(entry: entry, theme: theme, onSelect: { onSelect(entry.id) }, onCopy: onCopy != nil ? { @@ -58,9 +60,29 @@ struct TableOfContentsView: View { } } .padding(.vertical, 4) + + // Every heading is deeper than the chosen depth + if shown.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("No headings up to H\(maxLevel)") + .foregroundColor(.secondary) + Button("Show All Headings") { maxLevel = ToCDepth.all } + .buttonStyle(.link) + } + .font(.system(size: 11)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + } } } .background(theme.backgroundColor.opacity(0.5)) + .contextMenu { + Picker("Show Headings", selection: $maxLevel) { + ForEach(ToCDepth.choices, id: \.maxLevel) { choice in + Text(choice.title).tag(choice.maxLevel) + } + } + } } } diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift index 1af147f..6c9f24f 100644 --- a/QuickMD/QuickMDTests/ParserTests.swift +++ b/QuickMD/QuickMDTests/ParserTests.swift @@ -42,6 +42,19 @@ final class ParserTests: XCTestCase { XCTAssertEqual(headings[2].line, 3) } + /// CommonMark's closing sequence is not heading text; the same rows on + /// GitHub show "Install", "Issue", "", "C#", "foo ###" and "Tag #42". + func testATXClosingSequenceIsNotPartOfTheTitle() { + // Built line by line: the second keeps its trailing spaces + let md = ["## Install ##", "## Issue # ", "### ###", "# C#", "## foo \\###", "## Tag #42"] + .joined(separator: "\n") + let titles: [String] = parse(md).compactMap { + if case .heading(_, let title, _) = $0.content { return title } + return nil + } + XCTAssertEqual(titles, ["Install", "Issue", "", "C#", "foo \\###", "Tag #42"]) + } + func testSetextHeadings() { let md = """ Title @@ -337,6 +350,33 @@ 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 | + | path\\\\| c | + | `p\\\\|q` | c | + """ + 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"]) + // `\\\\ |`: a space before the pipe, so it splits + XCTAssertEqual(rows[1], ["path\\\\", "after an escaped backslash"]) + XCTAssertEqual(rows[2], ["ends with pipe |", "x"]) + // `\\\\|`: a pipe right after a backslash never splits, as on GitHub + // (checked with its renderer: "path| c" in one cell, code `p\|q`); + // one backslash is dropped and the inline renderer handles the rest + XCTAssertEqual(rows[3], ["path\\| c", ""]) + XCTAssertEqual(rows[4], ["`p\\|q`", "c"]) + } + // MARK: - Blockquotes func testBlockquoteNestingLevels() { diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift index 35cf9e5..beb40c2 100644 --- a/QuickMD/QuickMDTests/SectionExtractorTests.swift +++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift @@ -104,4 +104,109 @@ final class SectionExtractorTests: XCTestCase { let stranger = ToCEntry(id: "heading-999", level: 2, title: "X", sourceLine: 0) XCTAssertNil(SectionExtractor.extractSection(from: md, entry: stranger, headings: toc)) } + + // MARK: - Contents depth (#42) + + func testContentsDepthFiltersByLevel() { + let toc = headings(of: "# A\n## B\n### C\n#### D\n##### E\n###### F") + XCTAssertEqual(ToCDepth.visible(toc, maxLevel: ToCDepth.all).map(\.title), ["A", "B", "C", "D", "E", "F"]) + XCTAssertEqual(ToCDepth.visible(toc, maxLevel: 2).map(\.title), ["A", "B"]) + XCTAssertEqual(ToCDepth.visible(toc, maxLevel: 4).map(\.title), ["A", "B", "C", "D"]) + XCTAssertEqual(ToCDepth.choices.first?.maxLevel, ToCDepth.all, "All Headings is the default and first") + } + + // MARK: - Heading anchors (#43) + + private func anchorHeadings(of markdown: String) -> [(id: String, text: String)] { + let theme = MarkdownTheme.cached(for: .light) + return HeadingAnchors.headings(of: MarkdownBlockParser(theme: theme).parse(markdown), + renderer: MarkdownRenderer(theme: theme)) + } + + /// The ids GitHub's renderer gave the same headings (2026-10-29): markup + /// is gone before the slug, repeats count like github-slugger. + func testHeadingIdsMatchGitHub() { + let md = """ + ## Some Header + ## What's new in 1.2? + ## C++ & C# notes + ## API: `fetch()` returns **data** + ## See [the docs](https://example.com) here + ## Zażółć gęślą jaźń + ## Hello World + ## foo_bar-baz + ## Émoji 🎉 party + ## Intro + ## Intro + ## Intro-1 + ## Intro + ## 1. Getting Started + ## A — B – C + ## Ünïcödé Straße + ## Trailing spaces ## + ## Path/to\\file.md + ## “Quoted” ‘text’ + ## 100% done (really!) + ## x² Ⅳ ½ 3 + ## Love ❤️ it + ## Family 👨‍👩‍👧 here + ## foo \\### + ## Ω Σ Δ + ## 中文 标题 + ## İstanbul + """ + let ids = HeadingAnchors.ids(for: anchorHeadings(of: md).map(\.text)) + XCTAssertEqual(ids, [ + "some-header", "whats-new-in-12", "c--c-notes", "api-fetch-returns-data", + "see-the-docs-here", "zażółć-gęślą-jaźń", "hello--world", "foo_bar-baz", + "émoji--party", "intro", "intro-1", "intro-1-1", "intro-2", "1-getting-started", + "a--b--c", "ünïcödé-straße", "trailing-spaces", "pathtofilemd", "quoted-text", + "100-done-really", "x-\u{2173}--3", "love-\u{FE0F}-it", "family-\u{200D}\u{200D}-here", + "foo-", "ω-σ-δ", "中文-标题", "i\u{307}stanbul", + ]) + } + + func testFragmentFindsItsHeading() { + let headings = anchorHeadings(of: """ + # Guide + ## Install Guide + ## Intro + ## Intro + """) + let id = { HeadingAnchors.headingId(forFragment: $0, in: headings) } + XCTAssertEqual(id("install-guide"), headings[1].id) + // Hand-written: the fragment's own slug + XCTAssertEqual(id("Install Guide"), headings[1].id) + XCTAssertEqual(id("Install-Guide"), headings[1].id) + // The second "Intro" is `intro-1` + XCTAssertEqual(id("intro"), headings[2].id) + XCTAssertEqual(id("intro-1"), headings[3].id) + XCTAssertNil(id("missing")) + XCTAssertNil(id("")) + } + + func testEmptyFragmentAndTopAreTheTopOfTheDocument() { + // `## 🚀` slugs to "", the id `#` would otherwise match (review 2026-10-29) + let emoji = anchorHeadings(of: "# Guide\n## 🚀\n## Usage") + XCTAssertEqual(HeadingAnchors.target(forFragment: "", in: emoji), .top) + XCTAssertEqual(HeadingAnchors.target(forFragment: "top", in: emoji), .top) + XCTAssertEqual(HeadingAnchors.target(forFragment: "usage", in: emoji), .heading(emoji[2].id)) + XCTAssertEqual(HeadingAnchors.target(forFragment: "nope", in: emoji), .none) + // A heading that claims `top` wins, as an element with that id does in a browser + let claimed = anchorHeadings(of: "# Intro\n## Top") + XCTAssertEqual(HeadingAnchors.target(forFragment: "top", in: claimed), .heading(claimed[1].id)) + } + + func testInDocumentFragmentOfALink() { + let fragment = { HeadingAnchors.inDocumentFragment(of: URL(string: $0)!) } + XCTAssertEqual(fragment("#install"), "install") + XCTAssertEqual(fragment("#"), "") + XCTAssertEqual(fragment("#za%C5%BC%C3%B3%C5%82%C4%87"), "zażółć") + // The renderer's fallback for a destination URL(string:) rejects + XCTAssertEqual(fragment("%23some%20header"), "some header") + XCTAssertNil(fragment("other.md#install")) + XCTAssertNil(fragment("https://example.com/#install")) + XCTAssertNil(fragment("file:///tmp/a.md#install")) + XCTAssertNil(fragment("docs/install")) + } } diff --git a/QuickMD/QuickMDTests/TableTextTests.swift b/QuickMD/QuickMDTests/TableTextTests.swift index 893059e..e980769 100644 --- a/QuickMD/QuickMDTests/TableTextTests.swift +++ b/QuickMD/QuickMDTests/TableTextTests.swift @@ -126,6 +126,20 @@ final class TableTextTests: XCTestCase { "the last cell's \\n is the last character") } + /// PR #44: what escaped pipes DRAW, as GitHub's renderer shows the same + /// rows (checked 2026-10-29): `a | b`, code `x | y`, `path| c` in one + /// cell, code `p\|q`. + func testEscapedPipesDrawAsGitHubRendersThem() { + let t = table(""" + | A | B | + |---|---| + | a \\| b | `x \\| y` | + | path\\\\| c | + | `p\\\\|q` | c | + """) + XCTAssertEqual(build(t).string.string, "A\nB\na | b\nx | y\npath| c\n\np\\|q\nc\n") + } + func testRaggedRowsArePaddedWithEmptyCells() { let built = TableTextBuilder.build(headers: ["A", "B"], rows: [["1"], ["2", "3", "4"]], alignments: [.leading, .trailing], theme: theme, fontScale: 1)