From abd134f852b2ee5ac628570541e2105819ab0b9f Mon Sep 17 00:00:00 2001 From: rinh Date: Tue, 27 Oct 2026 16:37:41 +0800 Subject: [PATCH 1/6] fix(tables): an escaped pipe stays inside its cell parseTableRow split on every `|`, so `\|` (GFM's way to put a pipe in a cell) started a new column: `` `--part 1\|2` `` became two cells, the row gained a column and the code span broke apart. Rows are now scanned character by character: only an unescaped `|` separates cells, and `\|` becomes a literal `|` in the cell, inside code spans too, as GFM specifies. `\\|` is an escaped backslash followed by a real separator. Other escapes are left for the inline renderer as before. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 5 +++ QuickMD/QuickMD/MarkdownBlockParser.swift | 45 ++++++++++++++++------- QuickMD/QuickMDTests/ParserTests.swift | 20 ++++++++++ 3 files changed, 56 insertions(+), 14 deletions(-) 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() { From 17e376b00ac6815442dc545c0f752d1d7b9c8b56 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 29 Oct 2026 07:41:37 +0100 Subject: [PATCH 2/6] fix(tables): a pipe right after a backslash never splits, as on GitHub Follow-up to #44. GitHub (cmark-gfm `unescape_pipes`) treats every `\|` as an escaped pipe, even after another backslash: `path\\| c` is one cell "path| c" and `` `p\\|q` `` is code `p\|q`. The scanner from #44 read `\\|` as an escaped backslash plus a column pipe, which split both rows and broke the code span. Now a pipe right after a backslash drops that one backslash and stays in the cell; everything else is left to the inline renderer. The rows were checked against GitHub's renderer. Tests: two more parser rows and a drawn-string test for the table view. Co-Authored-By: Claude Opus 5.5 --- QuickMD/QuickMD/MarkdownBlockParser.swift | 21 +++++++++------------ QuickMD/QuickMDTests/ParserTests.swift | 9 ++++++++- QuickMD/QuickMDTests/TableTextTests.swift | 14 ++++++++++++++ 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index 57c4c1b..df99893 100644 --- a/QuickMD/QuickMD/MarkdownBlockParser.swift +++ b/QuickMD/QuickMD/MarkdownBlockParser.swift @@ -464,29 +464,26 @@ struct MarkdownBlockParser: Sendable { /// 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. + /// Like GitHub (cmark-gfm `unescape_pipes`), a pipe right after a backslash + /// never splits, even after `\\`: only that one backslash is dropped, so + /// `\\|` leaves `\|` for the inline renderer (`|` in text, `\|` in code). + /// Other escapes stay in the text for the inline renderer. private func parseTableRow(_ line: String) -> [String] { 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("\\") } + if ch != "|" { cell.append(ch) - escaping = false - } else if ch == "\\" { - escaping = true - } else if ch == "|" { + } else if cell.last == "\\" { + cell.removeLast() + cell.append("|") + } else { cells.append(cell) cell = "" - } else { - cell.append(ch) } } - if escaping { cell.append("\\") } cells.append(cell) // A leading pipe opens the row rather than ending an empty first cell diff --git a/QuickMD/QuickMDTests/ParserTests.swift b/QuickMD/QuickMDTests/ParserTests.swift index ef7ecf7..db59f01 100644 --- a/QuickMD/QuickMDTests/ParserTests.swift +++ b/QuickMD/QuickMDTests/ParserTests.swift @@ -344,6 +344,8 @@ final class ParserTests: XCTestCase { | `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 { @@ -352,9 +354,14 @@ final class ParserTests: XCTestCase { 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 + // `\\\\ |`: 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 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) From 70b87f0ae8d3daf512ee71a8e361d1f72d8d9237 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 29 Oct 2026 07:47:49 +0100 Subject: [PATCH 3/6] fix(parser): an ATX heading's closing #s are not part of its title `## Install ##` showed "Install ##" in the document and the Contents sidebar. CommonMark (and GitHub) drop a closing run of `#` that follows a space or tab; `# C#` and an escaped `\###` keep theirs. GitHub's heading ids (#43) are built from the title, so this also keeps them equal. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 3 ++- QuickMD/QuickMD/MarkdownBlockParser.swift | 21 ++++++++++++++++++++- QuickMD/QuickMDTests/ParserTests.swift | 13 +++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index d4851ec..8d5ec3b 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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. +- 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 diff --git a/QuickMD/QuickMD/MarkdownBlockParser.swift b/QuickMD/QuickMD/MarkdownBlockParser.swift index df99893..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[.. Date: Thu, 29 Oct 2026 07:48:09 +0100 Subject: [PATCH 4/6] feat(links): a link to a heading in the same document scrolls to it (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[Install](#install)` had no scheme and no path, so handleLinkActivation resolved it to the document's folder and opened Finder. Now an in-document fragment scrolls to its heading the way a ToC click does. HeadingAnchors gives headings GitHub's ids, from the rendered heading text (markup gone): lowercased, only letters, marks, decimal and letter numbers, `_`, joiners and `-` kept, spaces to `-`, repeats counted like github-slugger (`intro`, `intro-1`, `intro-1-1`, `intro-2`). The expected ids in the tests come from GitHub's renderer. A fragment that is not an id is tried as its own slug (`#Install Guide`); `README.md#install` inside README.md is the same jump; `#` and `#top` go to the top; an unknown id beeps. The fragment is read from the link as written, so a destination URL(string:) rejected on macOS 13 (arrives as `%23…`) still works. Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 3 + QuickMD/QuickMD/MarkdownView.swift | 29 +++++++ QuickMD/QuickMD/SectionExtractor.swift | 87 +++++++++++++++++++ .../QuickMDTests/SectionExtractorTests.swift | 83 ++++++++++++++++++ 4 files changed, 202 insertions(+) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index 8d5ec3b..be0d0a3 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 opened the document's folder in Finder. Asked for by [@SteveHigham](https://github.com/SteveHigham) (#43). + ### 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. diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index c0066ad..2c25be9 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,21 @@ struct MarkdownView: View { } } + /// Scrolls to the heading an in-document link names, as a ToC click does. + /// `#` and `#top` with no such heading go to the top, as in a browser; 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) + if let target = HeadingAnchors.headingId(forFragment: fragment, in: headings) { + selectHeading(target) + } else if fragment.isEmpty || fragment.lowercased() == "top", let first = cachedBlocks.first { + requestScroll(to: first.id, anchor: .top, animated: true) + } else { + NSSound.beep() + } + } + } // MARK: - Preview diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift index a26807d..ac02015 100644 --- a/QuickMD/QuickMD/SectionExtractor.swift +++ b/QuickMD/QuickMD/SectionExtractor.swift @@ -13,6 +13,93 @@ struct ToCEntry: Identifiable, Sendable { let sourceLine: Int } +// 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 + } + + /// 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`. + 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/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift index 35cf9e5..1f4313b 100644 --- a/QuickMD/QuickMDTests/SectionExtractorTests.swift +++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift @@ -104,4 +104,87 @@ 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: - 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 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")) + } } From 76700c0c185488e978186a0469f45aa3c1f52479 Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 29 Oct 2026 08:01:37 +0100 Subject: [PATCH 5/6] feat(toc): choose how deep the Contents sidebar goes (#42) All Headings (default), Up to H2, Up to H3 or Up to H4. One stored value (`tocMaxHeadingLevel`), edited from the sidebar's context menu (a "Show Headings" submenu on the header and every row) and from a "Contents Sidebar" row in Settings > General, so the two can never disagree. The filter only changes what the sidebar lists: section copy and in-document links still see every heading. When every heading is deeper than the chosen level, the sidebar says so and offers "Show All Headings". Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 1 + QuickMD/QuickMD/SectionExtractor.swift | 19 ++++++++++++++ QuickMD/QuickMD/Views/SettingsView.swift | 25 ++++++++++++++++--- .../QuickMD/Views/TableOfContentsView.swift | 24 +++++++++++++++++- .../QuickMDTests/SectionExtractorTests.swift | 10 ++++++++ 5 files changed, 74 insertions(+), 5 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index be0d0a3..aa0caa8 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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 opened the document's folder in Finder. 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). diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift index ac02015..524d9f1 100644 --- a/QuickMD/QuickMD/SectionExtractor.swift +++ b/QuickMD/QuickMD/SectionExtractor.swift @@ -13,6 +13,25 @@ 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 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/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift index 1f4313b..9c2d372 100644 --- a/QuickMD/QuickMDTests/SectionExtractorTests.swift +++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift @@ -105,6 +105,16 @@ final class SectionExtractorTests: XCTestCase { 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)] { From 47aa0f1526cc25ed233a7cd1022d863303b1736f Mon Sep 17 00:00:00 2001 From: Bartosz Sroczynski Date: Thu, 29 Oct 2026 08:24:01 +0100 Subject: [PATCH 6/6] fix(links): `#` always goes to the top; review follow-ups (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the integration range: a heading of only an emoji (`## 🚀`) slugs to the empty id, so `[top](#)` scrolled to that heading instead of the top. The decision now lives in HeadingAnchors.target (tested): `#` is the top, `#top` is the top unless a heading claims that id, an unknown id beeps. Also documents the accepted cost of reading `%23…` links as anchors (macOS 13 fallback encoding), and the CHANGELOG now says what the old behaviour was in each build: Finder, or a permission error in the sandboxed App Store build (seen in E2E). Co-Authored-By: Claude Opus 5.5 --- QuickMD/CHANGELOG.md | 2 +- QuickMD/QuickMD/MarkdownView.swift | 15 ++++++++------- QuickMD/QuickMD/SectionExtractor.swift | 19 ++++++++++++++++++- .../QuickMDTests/SectionExtractorTests.swift | 12 ++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/QuickMD/CHANGELOG.md b/QuickMD/CHANGELOG.md index aa0caa8..f2f57fd 100644 --- a/QuickMD/CHANGELOG.md +++ b/QuickMD/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [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 opened the document's folder in Finder. Asked for by [@SteveHigham](https://github.com/SteveHigham) (#43). +- **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 diff --git a/QuickMD/QuickMD/MarkdownView.swift b/QuickMD/QuickMD/MarkdownView.swift index 2c25be9..ac665d1 100644 --- a/QuickMD/QuickMD/MarkdownView.swift +++ b/QuickMD/QuickMD/MarkdownView.swift @@ -1441,17 +1441,18 @@ struct MarkdownView: View { } } - /// Scrolls to the heading an in-document link names, as a ToC click does. - /// `#` and `#top` with no such heading go to the top, as in a browser; an + /// 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) - if let target = HeadingAnchors.headingId(forFragment: fragment, in: headings) { - selectHeading(target) - } else if fragment.isEmpty || fragment.lowercased() == "top", let first = cachedBlocks.first { - requestScroll(to: first.id, anchor: .top, animated: true) - } else { + 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() } } diff --git a/QuickMD/QuickMD/SectionExtractor.swift b/QuickMD/QuickMD/SectionExtractor.swift index 524d9f1..563d209 100644 --- a/QuickMD/QuickMD/SectionExtractor.swift +++ b/QuickMD/QuickMD/SectionExtractor.swift @@ -107,10 +107,27 @@ enum HeadingAnchors { 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`. + /// 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 diff --git a/QuickMD/QuickMDTests/SectionExtractorTests.swift b/QuickMD/QuickMDTests/SectionExtractorTests.swift index 9c2d372..beb40c2 100644 --- a/QuickMD/QuickMDTests/SectionExtractorTests.swift +++ b/QuickMD/QuickMDTests/SectionExtractorTests.swift @@ -185,6 +185,18 @@ final class SectionExtractorTests: XCTestCase { 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")