From 0444922b9d9ca7e6939201a2a7aa25c2dfb5ccaa Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Fri, 24 Jul 2026 16:59:35 +0100 Subject: [PATCH 1/7] feat: enhance native markdown support --- .../Editor/NativeEditorBlockKind.swift | 26 ++- ...ditorMarkdownParser+CommonMarkBlocks.swift | 90 ++++++++ .../NativeEditorMarkdownParser+Escaping.swift | 94 ++++++++ ...tiveEditorMarkdownParser+FrontMatter.swift | 22 ++ ...tiveEditorMarkdownParser+InlineMarks.swift | 53 +++-- ...ativeEditorMarkdownParser+References.swift | 207 ++++++++++++++++++ .../Editor/NativeEditorMarkdownParser.swift | 31 +-- .../NativeEditorRichBlockPreviewView.swift | 15 +- .../NativeEditorTextInputView+iOS.swift | 39 +++- .../NativeEditorTextInputView+macOS.swift | 50 ++++- .../NativeEditorHeadingMarkdownTests.swift | 27 +++ .../NativeEditorMarkdownParityTests.swift | 106 +++++++++ 12 files changed, 702 insertions(+), 58 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift create mode 100644 docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift create mode 100644 docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift diff --git a/docmostly/Features/Editor/NativeEditorBlockKind.swift b/docmostly/Features/Editor/NativeEditorBlockKind.swift index 9949391..85c969f 100644 --- a/docmostly/Features/Editor/NativeEditorBlockKind.swift +++ b/docmostly/Features/Editor/NativeEditorBlockKind.swift @@ -43,10 +43,14 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { var editorFont: Font { switch self { case .heading(let level): - if level == 1 { - return .title.bold() + return switch level { + case 1: .title.bold() + case 2: .title2.bold() + case 3: .title3.bold() + case 4: .headline.bold() + case 5: .subheadline.bold() + default: .footnote.bold() } - return .title2.bold() case .codeBlock: return .body.monospaced() case .paragraph, .bulletListItem, .orderedListItem, .taskListItem, .blockquote: @@ -58,6 +62,22 @@ nonisolated enum NativeEditorBlockKind: Equatable, Sendable { } } + var stronglyEmphasizedEditorFont: Font { + switch self { + case .heading(let level): + return switch level { + case 1: .title.weight(.heavy) + case 2: .title2.weight(.heavy) + case 3: .title3.weight(.heavy) + case 4: .headline.weight(.heavy) + case 5: .subheadline.weight(.heavy) + default: .footnote.weight(.heavy) + } + default: + return editorFont.bold() + } + } + var accessibilityLabel: String { switch self { case .paragraph: diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift new file mode 100644 index 0000000..aabb381 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift @@ -0,0 +1,90 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func commonMarkBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + indentedCodeBlock(in: lines, startingAt: index) ?? + setextHeadingBlock(in: lines, startingAt: index) + } + + private static func indentedCodeBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + guard let firstLine = indentedCodeLine(from: lines[index]) else { return nil } + + var content = [firstLine] + var currentIndex = lines.index(after: index) + while currentIndex < lines.endIndex { + let line = lines[currentIndex] + if line.trimmingCharacters(in: .whitespaces).isEmpty { + content.append("") + } else if let codeLine = indentedCodeLine(from: line) { + content.append(codeLine) + } else { + break + } + currentIndex = lines.index(after: currentIndex) + } + + while content.last?.isEmpty == true { + content.removeLast() + } + let block = NativeEditorBlock( + kind: .codeBlock(language: nil), + text: AttributedString(content.joined(separator: "\n")), + alignment: .left + ) + return (block, currentIndex) + } + + private static func indentedCodeLine(from line: String) -> String? { + var columns = 0 + var index = line.startIndex + while index < line.endIndex, columns < 4 { + switch line[index] { + case " ": columns += 1 + case "\t": columns = 4 + default: return nil + } + index = line.index(after: index) + } + guard columns >= 4 else { return nil } + return String(line[index...]) + } + + private static func setextHeadingBlock( + in lines: [String], + startingAt index: Array.Index + ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let underlineIndex = lines.index(after: index) + let text = lines[index].trimmingCharacters(in: .whitespaces) + guard underlineIndex < lines.endIndex, + text.isEmpty == false, + inputRule(from: text) == nil, + let level = setextHeadingLevel(from: lines[underlineIndex]) else { + return nil + } + + let block = NativeEditorBlock( + kind: .heading(level: level), + text: inlineText(from: text), + alignment: .left + ) + return (block, lines.index(after: underlineIndex)) + } + + private static func setextHeadingLevel(from line: String) -> Int? { + let underline = line.trimmingCharacters(in: .whitespaces) + guard underline.isEmpty == false else { return nil } + if underline.allSatisfy({ $0 == "=" }) { + return 1 + } + if underline.allSatisfy({ $0 == "-" }) { + return 2 + } + return nil + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift new file mode 100644 index 0000000..e2ac0f3 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift @@ -0,0 +1,94 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func escapedMarkdownPlainText(_ text: String) -> String { + let escapableCharacters: Set = ["\\", "`", "*", "_", "[", "]", "<", "~", "!"] + return text.reduce(into: "") { result, character in + if escapableCharacters.contains(character) { + result.append("\\") + } + result.append(character) + } + } + + static func unescapedMarkdownPlainText(_ text: String) -> String { + var result = "" + var index = text.startIndex + while index < text.endIndex { + let character = text[index] + let nextIndex = text.index(after: index) + if character == "\\", nextIndex < text.endIndex, markdownEscapableCharacters.contains(text[nextIndex]) { + result.append(text[nextIndex]) + index = text.index(after: nextIndex) + } else { + result.append(character) + index = nextIndex + } + } + return result + } + + static func escapedBlockLeadingMarkdown(in text: String) -> String { + text.split(separator: "\n", omittingEmptySubsequences: false) + .map(escapingMarkdownBlockPrefix) + .joined(separator: "\n") + } + + static func firstUnescapedRange( + of delimiter: String, + in markdown: Substring, + startingAt startIndex: String.Index? = nil + ) -> Range? { + var searchStart = startIndex ?? markdown.startIndex + while searchStart < markdown.endIndex, + let range = markdown[searchStart...].range(of: delimiter) { + if isEscapedMarkdownCharacter(at: range.lowerBound, in: markdown) == false { + return range + } + searchStart = range.upperBound + } + return nil + } + + static func isEscapedMarkdownCharacter( + at index: String.Index, + in markdown: Substring + ) -> Bool { + var slashCount = 0 + var currentIndex = index + while currentIndex > markdown.startIndex { + let previousIndex = markdown.index(before: currentIndex) + guard markdown[previousIndex] == "\\" else { break } + slashCount += 1 + currentIndex = previousIndex + } + return slashCount.isMultiple(of: 2) == false + } + + private static func escapingMarkdownBlockPrefix(_ line: Substring) -> String { + let text = String(line) + let fixedPrefixes = ["# ", "## ", "### ", "#### ", "##### ", "###### ", "> ", "- ", "+ "] + let exactMarkers = ["---", "***", "___"] + let startsFixedSyntax = fixedPrefixes.contains { text.hasPrefix($0) } + let startsFence = text.hasPrefix("```") || text.hasPrefix("~~~") + let startsOrderedList = startsWithOrderedListMarker(text) + let isExactMarker = exactMarkers.contains(text) + guard startsFixedSyntax || startsFence || startsOrderedList || isExactMarker else { return text } + return "\\\(text)" + } + + private static func startsWithOrderedListMarker(_ text: String) -> Bool { + guard let dotIndex = text.firstIndex(of: "."), + text.distance(from: text.startIndex, to: dotIndex) <= 4, + Int(text[.. = [ + "!", "\"", "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";", + "<", "=", ">", "?", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~" + ] +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift new file mode 100644 index 0000000..f581a61 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift @@ -0,0 +1,22 @@ +import Foundation + +extension NativeEditorMarkdownParser { + static func removingLeadingYAMLFrontMatter(from markdown: String) -> String { + var start = markdown.startIndex + while start < markdown.endIndex, markdown[start].isWhitespace { + start = markdown.index(after: start) + } + + guard start < markdown.endIndex, markdown[start...].hasPrefix("---") else { return markdown } + + let bodyStart = markdown.index(start, offsetBy: 3) + guard let closeRange = markdown[bodyStart...].range(of: "---") else { return markdown } + + var contentStart = closeRange.upperBound + while contentStart < markdown.endIndex, markdown[contentStart].isWhitespace { + contentStart = markdown.index(after: contentStart) + } + + return String(markdown[contentStart...]) + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index b37f3a8..15f3f30 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -26,21 +26,21 @@ extension NativeEditorMarkdownParser { in: remaining, autolinksBareWebURLs: autolinksBareWebURLs ) { - output += AttributedString(String(remaining[.. String { - var output = text + var output = escapedMarkdownPlainText(text) let intent = run.inlinePresentationIntent ?? [] if intent.contains(.code) { - output = codeMarkdown(from: output) + output = codeMarkdown(from: text) } else { if intent.contains(.stronglyEmphasized) { output = "**\(output)**" @@ -56,7 +56,7 @@ extension NativeEditorMarkdownParser { } if let href = run[NativeEditorLinkAttribute.self]?.href ?? run.link?.absoluteString { - output = "[\(escapedMarkdownLinkLabel(output))](\(href))" + output = "[\(output)](\(href))" } return output @@ -101,13 +101,6 @@ extension NativeEditorMarkdownParser { return longestRunLength } - private static func escapedMarkdownLinkLabel(_ text: String) -> String { - text - .replacing("\\", with: "\\\\") - .replacing("[", with: "\\[") - .replacing("]", with: "\\]") - } - private static func inlineMarkdownInputRuleChanged( _ attributedText: AttributedString, from text: String @@ -214,8 +207,15 @@ extension NativeEditorMarkdownParser { in markdown: Substring, startingAt searchStart: String.Index ) -> Range? { - guard searchStart < markdown.endIndex, - let runStart = markdown[searchStart...].firstIndex(of: "`") else { return nil } + guard searchStart < markdown.endIndex else { return nil } + var runStart = searchStart + while runStart < markdown.endIndex { + if markdown[runStart] == "`", isEscapedMarkdownCharacter(at: runStart, in: markdown) == false { + break + } + runStart = markdown.index(after: runStart) + } + guard runStart < markdown.endIndex else { return nil } var runEnd = runStart while runEnd < markdown.endIndex, markdown[runEnd] == "`" { @@ -229,24 +229,31 @@ extension NativeEditorMarkdownParser { var searchStart = markdown.startIndex while searchStart < markdown.endIndex, - let openLabelIndex = markdown[searchStart...].firstIndex(of: "[") { + let openLabelRange = firstUnescapedRange(of: "[", in: markdown, startingAt: searchStart) { + let openLabelIndex = openLabelRange.lowerBound if isImageMarker(before: openLabelIndex, in: markdown) { searchStart = markdown.index(after: openLabelIndex) continue } guard - let closeLabelIndex = markdown[markdown.index(after: openLabelIndex)...].firstIndex(of: "]"), - markdown.index(after: closeLabelIndex) < markdown.endIndex, - markdown[markdown.index(after: closeLabelIndex)] == "(", + let closeLabelRange = firstUnescapedRange( + of: "]", + in: markdown, + startingAt: markdown.index(after: openLabelIndex) + ), + markdown.index(after: closeLabelRange.lowerBound) < markdown.endIndex, + markdown[markdown.index(after: closeLabelRange.lowerBound)] == "(", let closeDestinationIndex = closingMarkdownLinkDestinationIndex( in: markdown, - startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) + startingAt: markdown.index(after: markdown.index(after: closeLabelRange.lowerBound)) ) else { return nil } + let closeLabelIndex = closeLabelRange.lowerBound + let labelStartIndex = markdown.index(after: openLabelIndex) let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) let label = String(markdown[labelStartIndex.. InlineMarkdownMatch? { guard - let openRange = markdown.range(of: delimiter), - let closeRange = markdown[openRange.upperBound...].range(of: delimiter) + let openRange = firstUnescapedRange(of: delimiter, in: markdown), + let closeRange = firstUnescapedRange( + of: delimiter, + in: markdown, + startingAt: openRange.upperBound + ) else { return nil } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift new file mode 100644 index 0000000..251bab3 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift @@ -0,0 +1,207 @@ +import Foundation + +extension NativeEditorMarkdownParser { + private struct ReferenceDefinition { + var destination: String + } + + static func resolvingReferenceStyleLinks(in markdown: String) -> String { + let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) + var definitions: [String: ReferenceDefinition] = [:] + var contentLines: [String] = [] + var fence: (marker: Character, length: Int)? + + for line in lines { + if let activeFence = fence { + contentLines.append(line) + if isReferenceFenceClosingLine(line, matching: activeFence) { + fence = nil + } + continue + } + + if let openingFence = referenceFenceOpening(from: line) { + fence = openingFence + contentLines.append(line) + } else if let definition = referenceDefinition(from: line) { + if definitions[definition.label] == nil { + definitions[definition.label] = ReferenceDefinition(destination: definition.destination) + } + contentLines.append("") + } else { + contentLines.append(line) + } + } + + guard definitions.isEmpty == false else { return markdown } + var resolvedFence: (marker: Character, length: Int)? + return contentLines.map { line in + if let activeFence = resolvedFence { + if isReferenceFenceClosingLine(line, matching: activeFence) { + resolvedFence = nil + } + return line + } + if let openingFence = referenceFenceOpening(from: line) { + resolvedFence = openingFence + return line + } + return replacingReferenceStyleLinks(in: line, definitions: definitions) + } + .joined(separator: "\n") + } + + private static func referenceDefinition(from line: String) -> (label: String, destination: String)? { + let leadingSpaces = line.prefix { $0 == " " }.count + guard leadingSpaces <= 3 else { return nil } + let trimmed = line.dropFirst(leadingSpaces) + guard trimmed.first == "[", + let closeLabel = trimmed.firstIndex(of: "]") else { + return nil + } + + let colon = trimmed.index(after: closeLabel) + guard colon < trimmed.endIndex, trimmed[colon] == ":" else { return nil } + let labelStart = trimmed.index(after: trimmed.startIndex) + let label = normalizedReferenceLabel(String(trimmed[labelStart.. Bool { + guard destination.isEmpty == false else { return false } + if destination.first == "<" { + return destination.contains(">") + } + return destination.first?.isWhitespace == false + } + + private static func replacingReferenceStyleLinks( + in line: String, + definitions: [String: ReferenceDefinition] + ) -> String { + guard line.contains("[") else { return line } + var output = "" + var index = line.startIndex + + while index < line.endIndex { + if line[index] == "`", let codeRange = markdownCodeSpanRange(in: line, startingAt: index) { + output += line[codeRange] + index = codeRange.upperBound + continue + } + + let isImage = line[index] == "!" && line.index(after: index) < line.endIndex && + line[line.index(after: index)] == "[" + let openLabel = isImage ? line.index(after: index) : index + if line[openLabel] == "[", + let match = referenceStyleLinkMatch( + in: line, + openingLabelAt: openLabel, + isImage: isImage, + definitions: definitions + ) { + output += match.replacement + index = match.endIndex + continue + } + + output.append(line[index]) + index = line.index(after: index) + } + + return output + } + + private static func referenceStyleLinkMatch( + in line: String, + openingLabelAt openLabel: String.Index, + isImage: Bool, + definitions: [String: ReferenceDefinition] + ) -> (replacement: String, endIndex: String.Index)? { + guard let closeLabel = closingReferenceBracket(in: line, after: openLabel) else { return nil } + let labelStart = line.index(after: openLabel) + let visibleLabel = String(line[labelStart.. String.Index? { + var index = text.index(after: openBracket) + var isEscaped = false + while index < text.endIndex { + let character = text[index] + if character == "]", isEscaped == false { + return index + } + if character == "\\" { + isEscaped.toggle() + } else { + isEscaped = false + } + index = text.index(after: index) + } + return nil + } + + private static func normalizedReferenceLabel(_ label: String) -> String { + label + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + .lowercased() + } + + private static func markdownCodeSpanRange( + in text: String, + startingAt openingStart: String.Index + ) -> Range? { + var openingEnd = openingStart + while openingEnd < text.endIndex, text[openingEnd] == "`" { + openingEnd = text.index(after: openingEnd) + } + let delimiter = String(text[openingStart.. (marker: Character, length: Int)? { + let line = line.trimmingCharacters(in: .whitespaces) + guard let marker = line.first, marker == "`" || marker == "~" else { return nil } + let length = line.prefix { $0 == marker }.count + return length >= 3 ? (marker, length) : nil + } + + private static func isReferenceFenceClosingLine( + _ line: String, + matching fence: (marker: Character, length: Int) + ) -> Bool { + let line = line.trimmingCharacters(in: .whitespaces) + let markerCount = line.prefix { $0 == fence.marker }.count + guard markerCount >= fence.length else { return false } + return line.dropFirst(markerCount).allSatisfy(\.isWhitespace) + } +} diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index 8180724..db2dcff 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -13,7 +13,9 @@ enum NativeEditorMarkdownParser { } static func blocks(from markdown: String) -> [NativeEditorBlock] { - let markdown = removingLeadingYAMLFrontMatter(from: markdown) + let markdown = resolvingReferenceStyleLinks( + in: removingLeadingYAMLFrontMatter(from: markdown) + ) let lines = markdown.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) var blocks: [NativeEditorBlock] = [] var index = lines.startIndex @@ -25,6 +27,12 @@ enum NativeEditorMarkdownParser { continue } + if let commonMarkBlock = commonMarkBlock(in: lines, startingAt: index) { + blocks.append(commonMarkBlock.block) + index = commonMarkBlock.endIndex + continue + } + if appendImageMarkdownBlocksWithTrailingText(in: lines, startingAt: &index, to: &blocks) { continue } if let richBlock = richBlock(in: lines, startingAt: index) { @@ -66,25 +74,6 @@ enum NativeEditorMarkdownParser { return blocks.isEmpty ? [NativeEditorDocument.emptyBlock()] : blocks } - private static func removingLeadingYAMLFrontMatter(from markdown: String) -> String { - var start = markdown.startIndex - while start < markdown.endIndex, markdown[start].isWhitespace { - start = markdown.index(after: start) - } - - guard start < markdown.endIndex, markdown[start...].hasPrefix("---") else { return markdown } - - let bodyStart = markdown.index(start, offsetBy: 3) - guard let closeRange = markdown[bodyStart...].range(of: "---") else { return markdown } - - var contentStart = closeRange.upperBound - while contentStart < markdown.endIndex, markdown[contentStart].isWhitespace { - contentStart = markdown.index(after: contentStart) - } - - return String(markdown[contentStart...]) - } - private static func paragraphBlock( in lines: [String], startingAt index: Array.Index @@ -409,7 +398,7 @@ enum NativeEditorMarkdownParser { case .table(let table): return tableMarkdown(from: table) default: - return richMarkdownLine(from: block) ?? text + return richMarkdownLine(from: block) ?? escapedBlockLeadingMarkdown(in: text) } } diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index 7ddf3ca..a8c8ab4 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -168,12 +168,25 @@ struct NativeEditorRichBlockPreviewView: View { case .unsupported: NativeEditorUnsupportedBlockView(block: block) case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: - Text(block.text) + Text(previewText) .font(block.kind.editorFont) .frame(maxWidth: .infinity, alignment: .leading) } } + private var previewText: AttributedString { + var text = block.text + guard case .heading = block.kind else { return text } + + let stronglyEmphasizedRanges = text.runs.compactMap { run in + run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true ? run.range : nil + } + for range in stronglyEmphasizedRanges { + text[range].font = block.kind.stronglyEmphasizedEditorFont + } + return text + } + private func previewShell( systemImage: String, title: String, diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift index 0aa243b..761fbc1 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+iOS.swift @@ -122,6 +122,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { textView.attributedText = renderedText( sourceText, font: platformFont(for: parent.block.kind), + kind: parent.block.kind, alignment: platformAlignment(parent.block.alignment) ) (textView as? NativeEditorUITextView)?.invalidateRemotePresenceRendering() @@ -305,6 +306,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { textView.attributedText = renderedText( source, font: platformFont(for: parent.block.kind), + kind: parent.block.kind, alignment: platformAlignment(parent.block.alignment) ) (textView as? NativeEditorUITextView)?.invalidateRemotePresenceRendering() @@ -359,6 +361,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { private func renderedText( _ text: AttributedString, font: UIFont, + kind: NativeEditorBlockKind, alignment: NSTextAlignment ) -> NSAttributedString { let rendered = NSMutableAttributedString( @@ -369,7 +372,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { paragraphStyle.alignment = alignment rendered.addAttribute(.font, value: font, range: fullRange) - applyInlinePresentationFonts(from: text, baseFont: font, to: rendered) + applyInlinePresentationFonts(from: text, baseFont: font, kind: kind, to: rendered) var rangesMissingForegroundColor: [NSRange] = [] rendered.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in if value == nil { @@ -386,6 +389,7 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { private func applyInlinePresentationFonts( from text: AttributedString, baseFont: UIFont, + kind: NativeEditorBlockKind, to rendered: NSMutableAttributedString ) { let plainText = String(text.characters) @@ -395,12 +399,16 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { let upperBound = text.characters.distance(from: text.startIndex, to: run.range.upperBound) let characterRange = lowerBound.. UIFont { switch kind { - case .heading(let level): - let font = UIFont.preferredFont(forTextStyle: level == 1 ? .title1 : .title2) + case .heading: + let font = UIFont.preferredFont(forTextStyle: kind.headingTextStyle) var traits = font.fontDescriptor.symbolicTraits traits.insert(.traitBold) guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { @@ -456,6 +464,27 @@ final class NativeEditorTextInputCoordinator: NSObject, UITextViewDelegate { } } +private extension NativeEditorBlockKind { + var isHeading: Bool { + if case .heading = self { + return true + } + return false + } + + var headingTextStyle: UIFont.TextStyle { + guard case .heading(let level) = self else { return .body } + return switch level { + case 1: .title1 + case 2: .title2 + case 3: .title3 + case 4: .headline + case 5: .subheadline + default: .footnote + } + } +} + @MainActor final class NativeEditorUITextView: UITextView { var requestsFirstResponder = false diff --git a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift index 399e13e..599da0d 100644 --- a/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift +++ b/docmostly/Features/Editor/NativeEditorTextInputView+macOS.swift @@ -130,6 +130,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { renderedText( sourceText, font: platformFont(for: parent.block.kind), + kind: parent.block.kind, alignment: platformAlignment(parent.block.alignment) ) ) @@ -351,6 +352,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { renderedText( source, font: platformFont(for: parent.block.kind), + kind: parent.block.kind, alignment: platformAlignment(parent.block.alignment) ) ) @@ -405,6 +407,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { private func renderedText( _ text: AttributedString, font: NSFont, + kind: NativeEditorBlockKind, alignment: NSTextAlignment ) -> NSAttributedString { let rendered = NSMutableAttributedString( @@ -415,7 +418,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { paragraphStyle.alignment = alignment rendered.addAttribute(.font, value: font, range: fullRange) - applyInlinePresentationFonts(from: text, baseFont: font, to: rendered) + applyInlinePresentationFonts(from: text, baseFont: font, kind: kind, to: rendered) var rangesMissingForegroundColor: [NSRange] = [] rendered.enumerateAttribute(.foregroundColor, in: fullRange) { value, range, _ in if value == nil { @@ -432,6 +435,7 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { private func applyInlinePresentationFonts( from text: AttributedString, baseFont: NSFont, + kind: NativeEditorBlockKind, to rendered: NSMutableAttributedString ) { let plainText = String(text.characters) @@ -441,10 +445,21 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { let upperBound = text.characters.distance(from: text.startIndex, to: run.range.upperBound) let characterRange = lowerBound.. NSFont { switch kind { - case .heading(let level): + case .heading: let font = NSFont.preferredFont( - forTextStyle: level == 1 ? .title1 : .title2, + forTextStyle: kind.headingTextStyle, options: [:] ) return NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) @@ -491,6 +506,27 @@ final class NativeEditorTextInputCoordinator: NSObject, NSTextViewDelegate { } } +private extension NativeEditorBlockKind { + var isHeading: Bool { + if case .heading = self { + return true + } + return false + } + + var headingTextStyle: NSFont.TextStyle { + guard case .heading(let level) = self else { return .body } + return switch level { + case 1: .title1 + case 2: .title2 + case 3: .title3 + case 4: .headline + case 5: .subheadline + default: .footnote + } + } +} + @MainActor final class NativeEditorNSTextView: NSTextView { var requestsFirstResponder = false diff --git a/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift index 7be8d37..e4f35f2 100644 --- a/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift @@ -4,6 +4,33 @@ import Testing @MainActor struct NativeEditorHeadingMarkdownTests { + @Test func boldHeadingRoundTripsThroughProseMirror() throws { + let source = ProseMirrorNode( + type: "heading", + attrs: ["level": .int(2)], + content: [ + ProseMirrorNode(type: "text", text: "Release "), + ProseMirrorNode( + type: "text", + marks: [ProseMirrorMark(type: "bold")], + text: "today" + ) + ] + ) + + let block = try #require(NativeEditorDocument.blocks(from: source).first) + let boldRun = try #require(block.text.runs.last) + + #expect(block.kind == .heading(level: 2)) + #expect(String(block.text[boldRun.range].characters) == "today") + #expect(boldRun.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + let encoded = NativeEditorDocument.node(from: block) + #expect(encoded.type == "heading") + #expect(encoded.attrs?["level"] == .int(2)) + #expect(encoded.attrs?["id"]?.stringValue?.isEmpty == false) + #expect(encoded.content == source.content) + } + @Test func markdownImportPreservesDeepHeadingLevels() throws { let markdown = """ #### Deep section diff --git a/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift new file mode 100644 index 0000000..56d077d --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import docmostly + +@MainActor +struct NativeEditorMarkdownParityTests { + @Test func setextHeadingsPreserveInlineFormatting() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + **Overview** + === + Release notes + --- + """) + + try #require(blocks.count == 2) + #expect(blocks.map(\.kind) == [.heading(level: 1), .heading(level: 2)]) + #expect(String(blocks[0].text.characters) == "Overview") + #expect(blocks[0].text.runs.first?.inlinePresentationIntent?.contains(.stronglyEmphasized) == true) + #expect(String(blocks[1].text.characters) == "Release notes") + } + + @Test func indentedCodeBlocksPreserveBlankLinesAndIndentation() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + let value = 1 + + print(value) + Paragraph + """) + + try #require(blocks.count == 2) + guard case .codeBlock(language: nil) = blocks[0].kind else { + Issue.record("Expected a language-neutral indented code block.") + return + } + #expect(String(blocks[0].text.characters) == "let value = 1\n\n print(value)") + #expect(blocks[1].kind == .paragraph) + #expect(String(blocks[1].text.characters) == "Paragraph") + } + + @Test func referenceStyleLinksAndImagesResolveCaseInsensitively() throws { + let blocks = NativeEditorMarkdownParser.blocks(from: """ + Read the [Guide][DOCS]. + ![Architecture][asset] + [Shortcut] + + [docs]: https://docs.example.com/guide + [asset]: /files/architecture.png "System diagram" + [shortcut]: https://example.com + """) + + try #require(blocks.count == 3) + let guideRun = try #require(blocks[0].text.runs.first { run in + blocks[0].text[run.range].characters.elementsEqual("Guide") + }) + #expect(guideRun[NativeEditorLinkAttribute.self]?.href == "https://docs.example.com/guide") + + guard case .image(let image) = blocks[1].kind else { + Issue.record("Expected a reference-style image to become a native image block.") + return + } + #expect(image.source == "/files/architecture.png") + #expect(image.title == "System diagram") + + let shortcutRun = try #require(blocks[2].text.runs.first) + #expect(shortcutRun[NativeEditorLinkAttribute.self]?.href == "https://example.com") + } + + @Test func referenceDefinitionsDoNotRewriteFencedCode() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: """ + ```markdown + [Guide][docs] + ``` + [docs]: https://docs.example.com + """).first) + + guard case .codeBlock(language: "markdown") = block.kind else { + Issue.record("Expected fenced Markdown code.") + return + } + #expect(String(block.text.characters) == "[Guide][docs]") + } + + @Test(arguments: [ + "*literal emphasis*", + "~~literal strike~~", + "`literal code`", + "# literal heading", + "- literal list", + "1. literal ordered list", + "> literal quote", + "```literal fence", + "[label](not-a-link)", + "![image](not-an-image.png)", + "literal HTML", + "---", + #"path\name"# + ]) + func literalMarkdownSyntaxRoundTripsAsParagraph(_ source: String) throws { + let block = NativeEditorBlock(kind: .paragraph, text: AttributedString(source), alignment: .left) + let markdown = NativeEditorMarkdownParser.markdown(from: [block]) + let imported = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + #expect(imported.kind == .paragraph) + #expect(String(imported.text.characters) == source) + } +} From ff43f6b30df4da77e906d0b8dfe84a215c540ea6 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:00:53 +0100 Subject: [PATCH 2/7] fix: address markdown review findings --- ...ditorMarkdownParser+CommonMarkBlocks.swift | 14 +++++ .../NativeEditorMarkdownParser+Escaping.swift | 18 ++++-- ...tiveEditorMarkdownParser+FrontMatter.swift | 18 +++--- ...tiveEditorMarkdownParser+InlineMarks.swift | 45 ++++++++++---- ...ativeEditorMarkdownParser+References.swift | 40 +++++++++++-- .../NativeEditorPreviewTextFormatter.swift | 16 +++++ .../NativeEditorRichBlockPreviewView.swift | 15 +---- .../NativeEditorHeadingMarkdownTests.swift | 14 +++++ ...EditorMarkdownFrontMatterImportTests.swift | 14 +++++ .../NativeEditorMarkdownParityTests.swift | 58 ++++++++++++++++++- 10 files changed, 208 insertions(+), 44 deletions(-) create mode 100644 docmostly/Features/Editor/NativeEditorPreviewTextFormatter.swift diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift index aabb381..9e63748 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift @@ -13,6 +13,10 @@ extension NativeEditorMarkdownParser { in lines: [String], startingAt index: Array.Index ) -> (block: NativeEditorBlock, endIndex: Array.Index)? { + let trimmedLine = lines[index].trimmingCharacters(in: .whitespaces) + if isIndentedListItem(trimmedLine) { + return nil + } guard let firstLine = indentedCodeLine(from: lines[index]) else { return nil } var content = [firstLine] @@ -55,6 +59,16 @@ extension NativeEditorMarkdownParser { return String(line[index...]) } + private static func isIndentedListItem(_ line: String) -> Bool { + guard let kind = inputRule(from: line)?.kind else { return false } + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem: + true + default: + false + } + } + private static func setextHeadingBlock( in lines: [String], startingAt index: Array.Index diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift index e2ac0f3..6d7793d 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift @@ -71,20 +71,28 @@ extension NativeEditorMarkdownParser { let exactMarkers = ["---", "***", "___"] let startsFixedSyntax = fixedPrefixes.contains { text.hasPrefix($0) } let startsFence = text.hasPrefix("```") || text.hasPrefix("~~~") - let startsOrderedList = startsWithOrderedListMarker(text) + let isSetextUnderline = text.isEmpty == false && + (text.allSatisfy { $0 == "-" } || text.allSatisfy { $0 == "=" }) let isExactMarker = exactMarkers.contains(text) - guard startsFixedSyntax || startsFence || startsOrderedList || isExactMarker else { return text } + + if let orderedListDotIndex = orderedListDotIndex(in: text) { + var escaped = text + escaped.insert("\\", at: orderedListDotIndex) + return escaped + } + + guard startsFixedSyntax || startsFence || isSetextUnderline || isExactMarker else { return text } return "\\\(text)" } - private static func startsWithOrderedListMarker(_ text: String) -> Bool { + private static func orderedListDotIndex(in text: String) -> String.Index? { guard let dotIndex = text.firstIndex(of: "."), text.distance(from: text.startIndex, to: dotIndex) <= 4, Int(text[.. = [ diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift index f581a61..842e677 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+FrontMatter.swift @@ -7,16 +7,16 @@ extension NativeEditorMarkdownParser { start = markdown.index(after: start) } - guard start < markdown.endIndex, markdown[start...].hasPrefix("---") else { return markdown } - - let bodyStart = markdown.index(start, offsetBy: 3) - guard let closeRange = markdown[bodyStart...].range(of: "---") else { return markdown } - - var contentStart = closeRange.upperBound - while contentStart < markdown.endIndex, markdown[contentStart].isWhitespace { - contentStart = markdown.index(after: contentStart) + let lines = markdown[start...].split(separator: "\n", omittingEmptySubsequences: false) + guard lines.first?.trimmingCharacters(in: .whitespacesAndNewlines) == "---", + let closingIndex = lines.dropFirst().firstIndex(where: { + $0.trimmingCharacters(in: .whitespacesAndNewlines) == "---" + }) else { + return markdown } - return String(markdown[contentStart...]) + let content = lines[lines.index(after: closingIndex)...].joined(separator: "\n") + guard let contentStart = content.firstIndex(where: { $0.isWhitespace == false }) else { return "" } + return String(content[contentStart...]) } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift index 15f3f30..53bee2b 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMarks.swift @@ -237,23 +237,17 @@ extension NativeEditorMarkdownParser { } guard - let closeLabelRange = firstUnescapedRange( - of: "]", - in: markdown, - startingAt: markdown.index(after: openLabelIndex) - ), - markdown.index(after: closeLabelRange.lowerBound) < markdown.endIndex, - markdown[markdown.index(after: closeLabelRange.lowerBound)] == "(", + let closeLabelIndex = closingInlineMarkdownLabelIndex(in: markdown, after: openLabelIndex), + markdown.index(after: closeLabelIndex) < markdown.endIndex, + markdown[markdown.index(after: closeLabelIndex)] == "(", let closeDestinationIndex = closingMarkdownLinkDestinationIndex( in: markdown, - startingAt: markdown.index(after: markdown.index(after: closeLabelRange.lowerBound)) + startingAt: markdown.index(after: markdown.index(after: closeLabelIndex)) ) else { return nil } - let closeLabelIndex = closeLabelRange.lowerBound - let labelStartIndex = markdown.index(after: openLabelIndex) let destinationStartIndex = markdown.index(after: markdown.index(after: closeLabelIndex)) let label = String(markdown[labelStartIndex.. String.Index? { + var index = markdown.index(after: openLabelIndex) + while index < markdown.endIndex { + if markdown[index] == "`", let codeRange = inlineCodeSpanRange(in: markdown, startingAt: index) { + index = codeRange.upperBound + continue + } + if markdown[index] == "]", isEscapedMarkdownCharacter(at: index, in: markdown) == false { + return index + } + index = markdown.index(after: index) + } + return nil + } + + private static func inlineCodeSpanRange( + in markdown: Substring, + startingAt openingStart: String.Index + ) -> Range? { + var openingEnd = openingStart + while openingEnd < markdown.endIndex, markdown[openingEnd] == "`" { + openingEnd = markdown.index(after: openingEnd) + } + let delimiter = String(markdown[openingStart.. String { var resolvedFence: (marker: Character, length: Int)? return contentLines.map { line in if let activeFence = resolvedFence { @@ -46,6 +53,9 @@ extension NativeEditorMarkdownParser { resolvedFence = openingFence return line } + if isReferenceIndentedCodeLine(line) { + return line + } return replacingReferenceStyleLinks(in: line, definitions: definitions) } .joined(separator: "\n") @@ -54,9 +64,9 @@ extension NativeEditorMarkdownParser { private static func referenceDefinition(from line: String) -> (label: String, destination: String)? { let leadingSpaces = line.prefix { $0 == " " }.count guard leadingSpaces <= 3 else { return nil } - let trimmed = line.dropFirst(leadingSpaces) + let trimmed = String(line.dropFirst(leadingSpaces)) guard trimmed.first == "[", - let closeLabel = trimmed.firstIndex(of: "]") else { + let closeLabel = closingReferenceBracket(in: trimmed, after: trimmed.startIndex) else { return nil } @@ -96,7 +106,11 @@ extension NativeEditorMarkdownParser { let isImage = line[index] == "!" && line.index(after: index) < line.endIndex && line[line.index(after: index)] == "[" let openLabel = isImage ? line.index(after: index) : index - if line[openLabel] == "[", + let lineSlice = line[...] + let openingIsEscaped = isEscapedMarkdownCharacter(at: openLabel, in: lineSlice) || + (isImage && isEscapedMarkdownCharacter(at: index, in: lineSlice)) + if openingIsEscaped == false, + line[openLabel] == "[", let match = referenceStyleLinkMatch( in: line, openingLabelAt: openLabel, @@ -169,12 +183,30 @@ extension NativeEditorMarkdownParser { } private static func normalizedReferenceLabel(_ label: String) -> String { - label + unescapedMarkdownPlainText(label) .split(whereSeparator: \.isWhitespace) .joined(separator: " ") .lowercased() } + private static func isReferenceIndentedCodeLine(_ line: String) -> Bool { + let leadingSpaces = line.prefix { $0 == " " }.count + let startsWithTab = line.first == "\t" + guard leadingSpaces >= 4 || startsWithTab else { return false } + let trimmedLine = line.trimmingCharacters(in: .whitespaces) + return isReferenceListItem(trimmedLine) == false + } + + private static func isReferenceListItem(_ line: String) -> Bool { + guard let kind = inputRule(from: line)?.kind else { return false } + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem: + true + default: + false + } + } + private static func markdownCodeSpanRange( in text: String, startingAt openingStart: String.Index diff --git a/docmostly/Features/Editor/NativeEditorPreviewTextFormatter.swift b/docmostly/Features/Editor/NativeEditorPreviewTextFormatter.swift new file mode 100644 index 0000000..f6a4ebf --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorPreviewTextFormatter.swift @@ -0,0 +1,16 @@ +import SwiftUI + +enum NativeEditorPreviewTextFormatter { + static func text(_ source: AttributedString, for kind: NativeEditorBlockKind) -> AttributedString { + var result = source + guard case .heading = kind else { return result } + + let stronglyEmphasizedRanges = result.runs.compactMap { run in + run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true ? run.range : nil + } + for range in stronglyEmphasizedRanges { + result[range].font = kind.stronglyEmphasizedEditorFont + } + return result + } +} diff --git a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift index a8c8ab4..1ecf671 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -168,25 +168,12 @@ struct NativeEditorRichBlockPreviewView: View { case .unsupported: NativeEditorUnsupportedBlockView(block: block) case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: - Text(previewText) + Text(NativeEditorPreviewTextFormatter.text(block.text, for: block.kind)) .font(block.kind.editorFont) .frame(maxWidth: .infinity, alignment: .leading) } } - private var previewText: AttributedString { - var text = block.text - guard case .heading = block.kind else { return text } - - let stronglyEmphasizedRanges = text.runs.compactMap { run in - run.inlinePresentationIntent?.contains(.stronglyEmphasized) == true ? run.range : nil - } - for range in stronglyEmphasizedRanges { - text[range].font = block.kind.stronglyEmphasizedEditorFont - } - return text - } - private func previewShell( systemImage: String, title: String, diff --git a/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift b/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift index e4f35f2..bc9b96f 100644 --- a/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift @@ -1,4 +1,5 @@ import Foundation +import SwiftUI import Testing @testable import docmostly @@ -45,4 +46,17 @@ struct NativeEditorHeadingMarkdownTests { #expect(blocks[2].kind == .heading(level: 6)) #expect(NativeEditorMarkdownParser.markdown(from: blocks) == markdown) } + + @Test func previewFormatterAppliesAFontOnlyToStrongHeadingRuns() throws { + var source = AttributedString("Release today") + let strongRange = try #require(source.range(of: "today")) + source[strongRange].inlinePresentationIntent = .stronglyEmphasized + + let formatted = NativeEditorPreviewTextFormatter.text(source, for: .heading(level: 2)) + let releaseRun = try #require(formatted.runs.first) + let strongRun = try #require(formatted.runs.last) + + #expect(releaseRun.font == nil) + #expect(strongRun.font != nil) + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift index c5df60e..591b9d6 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownFrontMatterImportTests.swift @@ -24,4 +24,18 @@ struct NativeEditorFrontMatterImportTests { #expect(paragraph.kind == .paragraph) #expect(String(paragraph.text.characters) == "Ready to ship") } + + @Test func frontMatterDelimiterMustBeOnItsOwnLine() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: """ + --- + title: Launch --- plan + summary: Keep parsing metadata + --- + + Ready to ship + """).first) + + #expect(block.kind == .paragraph) + #expect(String(block.text.characters) == "Ready to ship") + } } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift index 56d077d..871e4c1 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift @@ -52,7 +52,7 @@ struct NativeEditorMarkdownParityTests { let guideRun = try #require(blocks[0].text.runs.first { run in blocks[0].text[run.range].characters.elementsEqual("Guide") }) - #expect(guideRun[NativeEditorLinkAttribute.self]?.href == "https://docs.example.com/guide") + #expect(linkDestination(in: guideRun) == "https://docs.example.com/guide") guard case .image(let image) = blocks[1].kind else { Issue.record("Expected a reference-style image to become a native image block.") @@ -62,7 +62,55 @@ struct NativeEditorMarkdownParityTests { #expect(image.title == "System diagram") let shortcutRun = try #require(blocks[2].text.runs.first) - #expect(shortcutRun[NativeEditorLinkAttribute.self]?.href == "https://example.com") + #expect(linkDestination(in: shortcutRun) == "https://example.com") + } + + @Test func referenceDefinitionsSupportEscapedClosingBrackets() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: #""" + [Guide][foo\]bar] + + [foo\]bar]: https://docs.example.com/guide + """#).first) + let linkRun = try #require(block.text.runs.first) + + #expect(String(block.text.characters) == "Guide") + #expect(linkDestination(in: linkRun) == "https://docs.example.com/guide") + } + + @Test func referenceSyntaxIsNotResolvedInsideIndentedCode() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: """ + [Guide][docs] + + [docs]: https://docs.example.com/guide + """).first) + + guard case .codeBlock(language: nil) = block.kind else { + Issue.record("Expected indented code.") + return + } + #expect(String(block.text.characters) == "[Guide][docs]") + } + + @Test func escapedReferenceOpeningRemainsLiteral() throws { + let block = try #require(NativeEditorMarkdownParser.blocks(from: #""" + \[Guide][docs] + + [docs]: https://docs.example.com/guide + """#).first) + + #expect(String(block.text.characters) == "[Guide][docs]") + #expect(block.text.runs.allSatisfy { linkDestination(in: $0) == nil }) + } + + @Test func inlineCodeLinkLabelsMayContainClosingBrackets() throws { + let block = try #require(NativeEditorMarkdownParser.blocks( + from: "[`value]`](https://docs.example.com/code)" + ).first) + let run = try #require(block.text.runs.first) + + #expect(String(block.text.characters) == "value]") + #expect(run.inlinePresentationIntent?.contains(.code) == true) + #expect(linkDestination(in: run) == "https://docs.example.com/code") } @Test func referenceDefinitionsDoNotRewriteFencedCode() throws { @@ -93,6 +141,8 @@ struct NativeEditorMarkdownParityTests { "![image](not-an-image.png)", "literal HTML", "---", + "----", + "====", #"path\name"# ]) func literalMarkdownSyntaxRoundTripsAsParagraph(_ source: String) throws { @@ -103,4 +153,8 @@ struct NativeEditorMarkdownParityTests { #expect(imported.kind == .paragraph) #expect(String(imported.text.characters) == source) } + + private func linkDestination(in run: AttributedString.Runs.Run) -> String? { + run[NativeEditorLinkAttribute.self]?.href ?? run.link?.absoluteString + } } From e8c0e651ecfbaeb5487d4b729f5332fe0ff0037e Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:03:27 +0100 Subject: [PATCH 3/7] fix: return list classification results --- .../Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift | 4 ++-- .../Editor/NativeEditorMarkdownParser+References.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift index 9e63748..36c7929 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift @@ -63,9 +63,9 @@ extension NativeEditorMarkdownParser { guard let kind = inputRule(from: line)?.kind else { return false } switch kind { case .bulletListItem, .orderedListItem, .taskListItem: - true + return true default: - false + return false } } diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift index 87c349c..ac3db76 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift @@ -201,9 +201,9 @@ extension NativeEditorMarkdownParser { guard let kind = inputRule(from: line)?.kind else { return false } switch kind { case .bulletListItem, .orderedListItem, .taskListItem: - true + return true default: - false + return false } } From c7be190ccf49c1e3d34c9eee8018c538e52b6e35 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:12:31 +0100 Subject: [PATCH 4/7] fix: route escaped markdown through native parser --- .../Editor/NativeEditorMarkdownParser+InlineMath.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift index 4a0ee3c..80233a4 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+InlineMath.swift @@ -68,7 +68,10 @@ extension NativeEditorMarkdownParser { after result: AttributedString ) -> Bool { let trimmedMarkdown = markdown.trimmingCharacters(in: .whitespacesAndNewlines) - return result.characters.isEmpty && trimmedMarkdown.hasPrefix("<") == false + let requiresNativeInlineParser = markdown.contains("\\[") || markdown.contains("`") + return result.characters.isEmpty && + trimmedMarkdown.hasPrefix("<") == false && + requiresNativeInlineParser == false } private static func appendInlineMath(_ text: String, to result: inout AttributedString) { From 863c3e50e65e27bc19ff09e36d05406ad86b8795 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:19:34 +0100 Subject: [PATCH 5/7] fix: preserve escaped reference literals --- ...ativeEditorMarkdownParser+References.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift index ac3db76..164b89e 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift @@ -109,6 +109,12 @@ extension NativeEditorMarkdownParser { let lineSlice = line[...] let openingIsEscaped = isEscapedMarkdownCharacter(at: openLabel, in: lineSlice) || (isImage && isEscapedMarkdownCharacter(at: index, in: lineSlice)) + if openingIsEscaped, + let escapedReferenceEnd = escapedReferenceLiteralEnd(in: line, openingLabelAt: openLabel) { + output += line[index.. String.Index? { + guard let closeLabel = closingReferenceBracket(in: line, after: openLabel) else { return nil } + let afterLabel = line.index(after: closeLabel) + guard afterLabel < line.endIndex, line[afterLabel] == "[", + let closeReference = closingReferenceBracket(in: line, after: afterLabel) else { + return afterLabel + } + return line.index(after: closeReference) + } + private static func referenceStyleLinkMatch( in line: String, openingLabelAt openLabel: String.Index, From 77571c570a15a3eb9e042062620c9c6198d9a632 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:25:15 +0100 Subject: [PATCH 6/7] fix: escape whitespace-padded setext markers --- .../NativeEditorMarkdownParser+Escaping.swift | 13 ++++++++++--- .../Editor/NativeEditorMarkdownParityTests.swift | 13 +++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift index 6d7793d..646d526 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift @@ -71,8 +71,9 @@ extension NativeEditorMarkdownParser { let exactMarkers = ["---", "***", "___"] let startsFixedSyntax = fixedPrefixes.contains { text.hasPrefix($0) } let startsFence = text.hasPrefix("```") || text.hasPrefix("~~~") - let isSetextUnderline = text.isEmpty == false && - (text.allSatisfy { $0 == "-" } || text.allSatisfy { $0 == "=" }) + let setextCandidate = text.trimmingCharacters(in: .whitespaces) + let isSetextUnderline = setextCandidate.isEmpty == false && + (setextCandidate.allSatisfy { $0 == "-" } || setextCandidate.allSatisfy { $0 == "=" }) let isExactMarker = exactMarkers.contains(text) if let orderedListDotIndex = orderedListDotIndex(in: text) { @@ -81,7 +82,13 @@ extension NativeEditorMarkdownParser { return escaped } - guard startsFixedSyntax || startsFence || isSetextUnderline || isExactMarker else { return text } + if isSetextUnderline, let markerIndex = text.firstIndex(where: { $0.isWhitespace == false }) { + var escaped = text + escaped.insert("\\", at: markerIndex) + return escaped + } + + guard startsFixedSyntax || startsFence || isExactMarker else { return text } return "\\\(text)" } diff --git a/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift index 871e4c1..36f9db4 100644 --- a/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift +++ b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift @@ -113,6 +113,19 @@ struct NativeEditorMarkdownParityTests { #expect(linkDestination(in: run) == "https://docs.example.com/code") } + @Test func setextLikeLinesWithWhitespaceRemainParagraphContent() throws { + let source = NativeEditorBlock( + kind: .paragraph, + text: AttributedString("Release notes\n---- "), + alignment: .left + ) + let markdown = NativeEditorMarkdownParser.markdown(from: [source]) + let imported = try #require(NativeEditorMarkdownParser.blocks(from: markdown).first) + + #expect(imported.kind == .paragraph) + #expect(String(imported.text.characters) == "Release notes\n----") + } + @Test func referenceDefinitionsDoNotRewriteFencedCode() throws { let block = try #require(NativeEditorMarkdownParser.blocks(from: """ ```markdown From 1d4d85cabfdcc9631190277656462258999c9132 Mon Sep 17 00:00:00 2001 From: Patryk Radziszewski Date: Sat, 25 Jul 2026 07:34:30 +0100 Subject: [PATCH 7/7] fix: parse multiline paragraph markdown once --- docmostly/Features/Editor/NativeEditorMarkdownParser.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift index db2dcff..91324ec 100644 --- a/docmostly/Features/Editor/NativeEditorMarkdownParser.swift +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser.swift @@ -123,7 +123,7 @@ enum NativeEditorMarkdownParser { return nil } - return String(block.text.characters) + return lines[index].trimmingCharacters(in: .whitespaces) } static func inputRule(from text: String) -> NativeEditorMarkdownInputRule? {