Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions docmostly/Features/Editor/NativeEditorBlockKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

extension NativeEditorMarkdownParser {
static func commonMarkBlock(
in lines: [String],
startingAt index: Array<String>.Index
) -> (block: NativeEditorBlock, endIndex: Array<String>.Index)? {
indentedCodeBlock(in: lines, startingAt: index) ??
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
setextHeadingBlock(in: lines, startingAt: index)
}

private static func indentedCodeBlock(
in lines: [String],
startingAt index: Array<String>.Index
) -> (block: NativeEditorBlock, endIndex: Array<String>.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<String>.Index
) -> (block: NativeEditorBlock, endIndex: Array<String>.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
}
}
109 changes: 109 additions & 0 deletions docmostly/Features/Editor/NativeEditorMarkdownParser+Escaping.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import Foundation

extension NativeEditorMarkdownParser {
static func escapedMarkdownPlainText(_ text: String) -> String {
let escapableCharacters: Set<Character> = ["\\", "`", "*", "_", "[", "]", "<", "~", "!"]
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<String.Index>? {
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 = ["---", "***", "___"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape all setext underline marker lines

This only treats exact ---, ***, and ___ as block-leading syntax, but the new setext parser accepts any non-empty line made entirely of - or = as a heading underline. A paragraph with a hard break before ---- or ==== therefore exports unchanged and re-imports as a heading, losing the second paragraph line. Escape all setext underline-only lines, not just the exact divider markers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape all setext underline marker lines

This only treats exact ---, ***, and ___ as block-leading syntax, but the new setext parser accepts any non-empty line made entirely of - or = as a heading underline. A paragraph with a hard break before ---- or ==== therefore exports unchanged and re-imports as a heading, losing the second paragraph line. Escape all setext underline-only lines, not just the exact divider markers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape all setext underline marker lines

This only treats exact ---, ***, and ___ as block-leading syntax, but the new setext parser accepts any non-empty line made entirely of - or = as a heading underline. A paragraph with a hard break before ---- or ==== therefore exports unchanged and re-imports as a heading, losing the second paragraph line. Escape all setext underline-only lines, not just the exact divider markers.

Useful? React with 👍 / 👎.

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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape ordered-list paragraphs at the marker

When paragraph text starts with an ordered-list marker, this branch emits a leading escape before the digit, e.g. \1. item. Markdown backslash escapes only apply to punctuation, and the importer’s unescaper also leaves a slash before 1, so exporting and re-importing a literal 1. item paragraph changes the saved text to include the backslash. Escape the period instead, or otherwise make this ordered-list escape symmetric.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape ordered-list paragraphs at the marker

When paragraph text starts with an ordered-list marker, this branch emits a leading escape before the digit, e.g. \1. item. Markdown backslash escapes only apply to punctuation, and the importer’s unescaper also leaves a slash before 1, so exporting and re-importing a literal 1. item paragraph changes the saved text to include the backslash. Escape the period instead, or otherwise make this ordered-list escape symmetric.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Escape ordered-list paragraphs at the marker

When paragraph text starts with an ordered-list marker, this branch emits a leading escape before the digit, e.g. \1. item. Markdown backslash escapes only apply to punctuation, and the importer’s unescaper also leaves a slash before 1, so exporting and re-importing a literal 1. item paragraph changes the saved text to include the backslash. Escape the period instead, or otherwise make this ordered-list escape symmetric.

Useful? React with 👍 / 👎.

}

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[..<dotIndex]) != nil else {
return nil
}
let spaceIndex = text.index(after: dotIndex)
return spaceIndex < text.endIndex && text[spaceIndex] == " " ? dotIndex : nil
}

private static let markdownEscapableCharacters: Set<Character> = [
"!", "\"", "#", "$", "%", "&", "'", "(", ")", "*", "+", ",", "-", ".", "/", ":", ";",
"<", "=", ">", "?", "@", "[", "\\", "]", "^", "_", "`", "{", "|", "}", "~"
]
}
Original file line number Diff line number Diff line change
@@ -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...])
}
}
Loading
Loading