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..36c7929 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+CommonMarkBlocks.swift @@ -0,0 +1,104 @@ +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)? { + 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] + 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 isIndentedListItem(_ line: String) -> Bool { + guard let kind = inputRule(from: line)?.kind else { return false } + switch kind { + case .bulletListItem, .orderedListItem, .taskListItem: + return true + default: + return false + } + } + + 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..646d526 --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift @@ -0,0 +1,109 @@ +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 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) { + var escaped = text + escaped.insert("\\", at: orderedListDotIndex) + return escaped + } + + 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)" + } + + 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 new file mode 100644 index 0000000..842e677 --- /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) + } + + 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 + } + + 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 b37f3a8..53bee2b 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,14 +229,15 @@ 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: "]"), + let closeLabelIndex = closingInlineMarkdownLabelIndex(in: markdown, after: openLabelIndex), markdown.index(after: closeLabelIndex) < markdown.endIndex, markdown[markdown.index(after: closeLabelIndex)] == "(", let closeDestinationIndex = closingMarkdownLinkDestinationIndex( @@ -274,6 +275,37 @@ extension NativeEditorMarkdownParser { return nil } + private static func closingInlineMarkdownLabelIndex( + in markdown: Substring, + after openLabelIndex: String.Index + ) -> 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.. 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+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) { diff --git a/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift new file mode 100644 index 0000000..164b89e --- /dev/null +++ b/docmostly/Features/Editor/NativeEditorMarkdownParser+References.swift @@ -0,0 +1,258 @@ +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 } + return resolvingReferenceLinks(in: contentLines, definitions: definitions) + } + + private static func resolvingReferenceLinks( + in contentLines: [String], + definitions: [String: ReferenceDefinition] + ) -> String { + 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 + } + if isReferenceIndentedCodeLine(line) { + 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 = String(line.dropFirst(leadingSpaces)) + guard trimmed.first == "[", + let closeLabel = closingReferenceBracket(in: trimmed, after: trimmed.startIndex) 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 + 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, + 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 { + 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: + return true + default: + return false + } + } + + 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..91324ec 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 @@ -134,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? { @@ -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/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 7ddf3ca..1ecf671 100644 --- a/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift +++ b/docmostly/Features/Editor/NativeEditorRichBlockPreviewView.swift @@ -168,7 +168,7 @@ struct NativeEditorRichBlockPreviewView: View { case .unsupported: NativeEditorUnsupportedBlockView(block: block) case .paragraph, .heading, .bulletListItem, .orderedListItem, .taskListItem, .blockquote, .codeBlock: - Text(block.text) + Text(NativeEditorPreviewTextFormatter.text(block.text, for: block.kind)) .font(block.kind.editorFont) .frame(maxWidth: .infinity, alignment: .leading) } 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..bc9b96f 100644 --- a/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift +++ b/docmostlyTests/Editor/NativeEditorHeadingMarkdownTests.swift @@ -1,9 +1,37 @@ import Foundation +import SwiftUI import Testing @testable import docmostly @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 @@ -18,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 new file mode 100644 index 0000000..36f9db4 --- /dev/null +++ b/docmostlyTests/Editor/NativeEditorMarkdownParityTests.swift @@ -0,0 +1,173 @@ +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(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.") + return + } + #expect(image.source == "/files/architecture.png") + #expect(image.title == "System diagram") + + let shortcutRun = try #require(blocks[2].text.runs.first) + #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 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 + [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) + } + + private func linkDestination(in run: AttributedString.Runs.Run) -> String? { + run[NativeEditorLinkAttribute.self]?.href ?? run.link?.absoluteString + } +}