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
10 changes: 10 additions & 0 deletions QuickMD/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
Expand Down
63 changes: 48 additions & 15 deletions QuickMD/QuickMD/MarkdownBlockParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[..<start].trimmingCharacters(in: .whitespaces)
}

// MARK: - Table Helpers

/// Check if line matches table separator pattern (e.g., |---|---|)
Expand All @@ -461,25 +480,39 @@ 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`).
/// 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] {
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()
}
Expand Down
30 changes: 30 additions & 0 deletions QuickMD/QuickMD/MarkdownView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
123 changes: 123 additions & 0 deletions QuickMD/QuickMD/SectionExtractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 21 additions & 4 deletions QuickMD/QuickMD/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
Expand Down
24 changes: 23 additions & 1 deletion QuickMD/QuickMD/Views/TableOfContentsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ? {
Expand All @@ -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)
}
}
}
}
}

Expand Down
Loading
Loading