summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift699
-rw-r--r--HutchTests/ReadmeViewTests.swift211
3 files changed, 849 insertions, 77 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 347d6fc..a52d824 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -459,7 +459,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.4.1;
+ MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -496,7 +496,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -513,7 +513,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.4.1;
+ MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -586,7 +586,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.4.1;
+ MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -605,7 +605,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 11;
+ CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -615,7 +615,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 2.4.1;
+ MARKETING_VERSION = 2.4.2;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index dea41f3..27cec55 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -283,8 +283,15 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
var html = ""
var inCodeBlock = false
- var inList = false
+ var codeBlockInListItem = false
+ var codeBlockLines: [String] = []
+ var listType: MarkupListType?
+ var inBlockquote = false
+ var pendingListItemBreak = false
+ var currentListItemLines: [String] = []
+ var currentListItemBlocks: [String] = []
var paragraph: [String] = []
+ var tableRows: [[String]] = []
func flushParagraph() {
if !paragraph.isEmpty {
@@ -296,72 +303,262 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
}
}
+ func flushListItem() {
+ guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return }
+ let itemContent = currentListItemLines
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+
+ if currentListItemBlocks.isEmpty {
+ html += "<li>" + renderTaskListItem(
+ itemContent,
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ } else {
+ if !itemContent.isEmpty {
+ currentListItemBlocks.append(
+ "<p>" + renderTaskListItem(
+ itemContent,
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</p>"
+ )
+ }
+ html += "<li>" + currentListItemBlocks.joined(separator: "\n") + "</li>\n"
+ }
+ currentListItemLines = []
+ currentListItemBlocks = []
+ }
+
+ func flushListItemParagraphIntoBlocks() {
+ guard !currentListItemLines.isEmpty else { return }
+ let itemContent = currentListItemLines
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ currentListItemBlocks.append(
+ "<p>" + renderTaskListItem(
+ itemContent,
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</p>"
+ )
+ currentListItemLines = []
+ }
+
+ func flushCodeBlock() {
+ let content = codeBlockLines.joined(separator: "\n")
+ let blockHTML = "<pre><code>" + content + "</code></pre>\n"
+ if codeBlockInListItem {
+ currentListItemBlocks.append(blockHTML)
+ } else {
+ html += blockHTML
+ }
+ codeBlockLines = []
+ codeBlockInListItem = false
+ }
+
func closeList() {
- if inList {
+ flushListItem()
+ switch listType {
+ case .unordered:
html += "</ul>\n"
- inList = false
+ case .ordered:
+ html += "</ol>\n"
+ case nil:
+ break
+ }
+ listType = nil
+ }
+
+ func flushTable() {
+ guard !tableRows.isEmpty else { return }
+ html += renderHTMLTable(
+ rows: tableRows,
+ inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
+ )
+ tableRows = []
+ }
+
+ func closeBlockquote() {
+ if inBlockquote {
+ flushParagraph()
+ html += "</blockquote>\n"
+ inBlockquote = false
}
}
for line in lines {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+
+ if pendingListItemBreak, listType != nil {
+ if trimmed.isEmpty {
+ continue
+ }
+ if isIndentedContinuationLine(line) || trimmed.hasPrefix("```") {
+ pendingListItemBreak = false
+ } else if isMarkdownUnorderedListItem(trimmed) || orderedListItem(in: trimmed) != nil {
+ flushListItem()
+ pendingListItemBreak = false
+ } else {
+ flushListItem()
+ closeList()
+ pendingListItemBreak = false
+ }
+ }
+
+ if let rawHTML = sanitizedMarkdownHTMLLine(from: trimmed) {
+ closeBlockquote()
+ flushParagraph()
+ flushTable()
+ if listType != nil {
+ flushListItemParagraphIntoBlocks()
+ currentListItemBlocks.append(rawHTML)
+ } else {
+ closeList()
+ html += rawHTML + "\n"
+ }
+ continue
+ }
+
// Fenced code blocks
- if line.hasPrefix("```") {
+ if trimmed.hasPrefix("```") {
if inCodeBlock {
- html += "</code></pre>\n"
+ flushCodeBlock()
inCodeBlock = false
} else {
+ closeBlockquote()
flushParagraph()
- closeList()
- html += "<pre><code>"
+ flushTable()
+ codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
+ if !codeBlockInListItem {
+ closeList()
+ } else {
+ flushListItemParagraphIntoBlocks()
+ }
inCodeBlock = true
+ codeBlockLines = []
}
continue
}
if inCodeBlock {
- html += escapeHTML(line) + "\n"
+ codeBlockLines.append(escapeHTML(line))
continue
}
+ if isTableLine(trimmed) {
+ closeBlockquote()
+ flushParagraph()
+ closeList()
+ tableRows.append(parseTableRow(trimmed))
+ continue
+ } else {
+ flushTable()
+ }
+
// Headings
+ if line.hasPrefix("###### ") {
+ closeBlockquote()
+ flushParagraph()
+ closeList()
+ html += "<h6>" + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "</h6>\n"
+ continue
+ }
+ if line.hasPrefix("##### ") {
+ closeBlockquote()
+ flushParagraph()
+ closeList()
+ html += "<h5>" + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "</h5>\n"
+ continue
+ }
+ if line.hasPrefix("#### ") {
+ closeBlockquote()
+ flushParagraph()
+ closeList()
+ html += "<h4>" + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "</h4>\n"
+ continue
+ }
if line.hasPrefix("### ") {
+ closeBlockquote()
flushParagraph()
closeList()
html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
continue
}
if line.hasPrefix("## ") {
+ closeBlockquote()
flushParagraph()
closeList()
html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
continue
}
if line.hasPrefix("# ") {
+ closeBlockquote()
flushParagraph()
closeList()
html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
continue
}
+ if isMarkdownHorizontalRule(trimmed) {
+ closeBlockquote()
+ flushParagraph()
+ closeList()
+ html += "<hr>\n"
+ continue
+ }
+
+ if trimmed.hasPrefix("> ") {
+ flushTable()
+ closeList()
+ if !inBlockquote {
+ flushParagraph()
+ html += "<blockquote>\n"
+ inBlockquote = true
+ }
+ paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver))
+ continue
+ } else {
+ closeBlockquote()
+ }
+
// List items
- let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
flushParagraph()
- if !inList {
+ if listType != .unordered {
+ closeList()
html += "<ul>\n"
- inList = true
+ listType = .unordered
}
- html += "<li>" + renderTaskListItem(
- String(trimmed.dropFirst(2)),
- inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
- ) + "</li>\n"
+ flushListItem()
+ currentListItemLines = [String(trimmed.dropFirst(2))]
+ continue
+ }
+ if let orderedItem = orderedListItem(in: trimmed) {
+ flushParagraph()
+ if listType != .ordered {
+ closeList()
+ html += "<ol>\n"
+ listType = .ordered
+ }
+ flushListItem()
+ currentListItemLines = [orderedItem]
+ continue
+ }
+
+ if listType != nil && isIndentedContinuationLine(line) {
+ currentListItemLines.append(trimmed)
continue
}
// Blank line
if trimmed.isEmpty {
- flushParagraph()
- closeList()
+ if inBlockquote {
+ closeBlockquote()
+ } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty {
+ pendingListItemBreak = true
+ } else {
+ flushParagraph()
+ closeList()
+ }
continue
}
@@ -371,21 +568,33 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
// Flush remaining state
if inCodeBlock {
- html += "</code></pre>\n"
+ flushCodeBlock()
}
+ closeBlockquote()
flushParagraph()
+ flushTable()
closeList()
return html
}
nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
- var result = escapeHTML(text)
+ var protectedFragments: [String: String] = [:]
+ var result = protectMatches(
+ in: text,
+ pattern: #"</?[A-Za-z][^>]*?>"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let rawTag = nsText.substring(with: match.range)
+ return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
+ }
+
+ result = escapeHTML(result)
// Images: ![alt](url)
result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
let alt = nsText.substring(with: match.range(at: 1))
- let source = nsText.substring(with: match.range(at: 2))
+ let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
let resolvedSource = imageURLResolver?(source) ?? source
guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
return escapeHTML(alt)
@@ -395,12 +604,30 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
// Links: [text](url)
result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
let label = nsText.substring(with: match.range(at: 1))
- let rawURL = nsText.substring(with: match.range(at: 2))
+ let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
return label
}
return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
}
+ // Plain email autolinks
+ result = replaceMatches(
+ in: result,
+ pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
+ ) { match, nsText in
+ guard !isInsideHTMLTag(nsText, range: match.range) else {
+ return nsText.substring(with: match.range)
+ }
+ let email = nsText.substring(with: match.range(at: 1))
+ let href = escapeHTMLAttribute("mailto:\(email)")
+ return #"<a href="\#(href)">\#(email)</a>"#
+ }
+ // Strikethrough: ~~text~~
+ result = result.replacingOccurrences(
+ of: #"~~(.+?)~~"#,
+ with: "<del>$1</del>",
+ options: .regularExpression
+ )
// Bold: **text**
result = result.replacingOccurrences(
of: #"\*\*(.+?)\*\*"#,
@@ -409,7 +636,13 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
)
// Italic: *text*
result = result.replacingOccurrences(
- of: #"\*(.+?)\*"#,
+ of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
+ with: "<em>$1</em>",
+ options: .regularExpression
+ )
+ // Italic: _text_
+ result = result.replacingOccurrences(
+ of: #"(?<!\w)_(.+?)_(?!\w)"#,
with: "<em>$1</em>",
options: .regularExpression
)
@@ -420,6 +653,10 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
options: .regularExpression
)
+ for (token, fragment) in protectedFragments {
+ result = result.replacingOccurrences(of: token, with: fragment)
+ }
+
return result
}
@@ -429,12 +666,39 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
let normalizedText = text
.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
- let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
+ var title: String?
+ var author: String?
+ var date: String?
+ let lines = rawLines.filter { line in
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+ guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else {
+ return true
+ }
+ let keyword = String(keywordMatch.1).lowercased()
+ let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
+ switch keyword {
+ case "title":
+ title = value
+ return false
+ case "author":
+ author = value
+ return false
+ case "date":
+ date = value
+ return false
+ default:
+ return true
+ }
+ }
var html = ""
var listType: OrgListType?
var inQuoteBlock = false
var inPropertyDrawer = false
var srcLanguage: String?
+ var inExampleBlock = false
+ var inCenterBlock = false
+ var currentListItemLines: [String] = []
var paragraph: [String] = []
var tableRows: [[String]] = []
var propertyRows: [(String, String)] = []
@@ -449,7 +713,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
}
}
+ func flushListItem() {
+ guard !currentListItemLines.isEmpty else { return }
+ let content = currentListItemLines
+ .map { $0.trimmingCharacters(in: .whitespaces) }
+ .joined(separator: " ")
+ html += "<li>" + renderTaskListItem(
+ content,
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ ) + "</li>\n"
+ currentListItemLines = []
+ }
+
func closeList() {
+ flushListItem()
switch listType {
case .unordered:
html += "</ul>\n"
@@ -463,34 +740,10 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
func flushTable() {
guard !tableRows.isEmpty else { return }
- let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
- let headerRow = tableRows.first ?? []
- let bodyRows: [[String]]
-
- html += "<table>\n"
- if hasHeaderSeparator {
- html += "<thead><tr>"
- for cell in headerRow {
- html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
- }
- html += "</tr></thead>\n<tbody>\n"
- bodyRows = Array(tableRows.dropFirst(2))
- } else {
- bodyRows = tableRows
- }
-
- for row in bodyRows {
- html += "<tr>"
- for cell in row {
- html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
- }
- html += "</tr>\n"
- }
-
- if hasHeaderSeparator {
- html += "</tbody>\n"
- }
- html += "</table>\n"
+ html += renderHTMLTable(
+ rows: tableRows,
+ inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
+ )
tableRows = []
}
@@ -520,6 +773,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
}
}
+ func closeExampleBlock() {
+ if inExampleBlock {
+ html += "</code></pre>\n"
+ inExampleBlock = false
+ }
+ }
+
+ func closeCenterBlock() {
+ if inCenterBlock {
+ flushParagraph()
+ html += "</div>\n"
+ inCenterBlock = false
+ }
+ }
+
func flushBlockState() {
flushParagraph()
closeList()
@@ -527,6 +795,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
flushPropertyDrawer()
}
+ if title != nil || author != nil || date != nil {
+ html += "<div class=\"org-metadata\">\n"
+ if let title {
+ html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
+ }
+ if let author {
+ html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
+ }
+ if let date {
+ html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
+ }
+ html += "</div>\n"
+ }
+
for line in lines {
let trimmed = line.trimmingCharacters(in: .whitespaces)
@@ -539,11 +821,35 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
continue
}
+ if inExampleBlock {
+ if trimmed.lowercased() == "#+end_example" {
+ closeExampleBlock()
+ } else {
+ html += escapeHTML(line) + "\n"
+ }
+ continue
+ }
+
if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
closeQuoteBlock()
continue
}
+ if inCenterBlock {
+ if trimmed.lowercased() == "#+end_center" {
+ closeCenterBlock()
+ } else if trimmed.isEmpty {
+ flushParagraph()
+ } else {
+ paragraph.append(line)
+ }
+ continue
+ }
+
+ if trimmed == "#" || trimmed.hasPrefix("# ") {
+ continue
+ }
+
if trimmed.lowercased().hasPrefix("#+begin_src") {
closeQuoteBlock()
flushBlockState()
@@ -559,6 +865,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
continue
}
+ if trimmed.lowercased() == "#+begin_example" {
+ closeQuoteBlock()
+ flushBlockState()
+ html += "<pre><code>"
+ inExampleBlock = true
+ continue
+ }
+
if trimmed.lowercased() == "#+begin_quote" {
flushBlockState()
html += "<blockquote>\n"
@@ -566,6 +880,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
continue
}
+ if trimmed.lowercased() == "#+begin_center" {
+ closeQuoteBlock()
+ flushBlockState()
+ html += "<div style=\"text-align:center\">\n"
+ inCenterBlock = true
+ continue
+ }
+
if trimmed == ":PROPERTIES:" {
closeQuoteBlock()
flushBlockState()
@@ -592,18 +914,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
}
}
- if isOrgTableLine(trimmed) {
+ if isTableLine(trimmed) {
closeQuoteBlock()
flushParagraph()
closeList()
- tableRows.append(parseOrgTableRow(trimmed))
+ tableRows.append(parseTableRow(trimmed))
continue
} else {
flushTable()
}
+ if isOrgHorizontalRule(trimmed) {
+ closeQuoteBlock()
+ flushBlockState()
+ html += "<hr>\n"
+ continue
+ }
+
// Org headings: * heading, ** heading, *** heading
- if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
+ if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
closeQuoteBlock()
flushBlockState()
let level = match.1.count
@@ -621,10 +950,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
html += "<ul>\n"
listType = .unordered
}
- html += "<li>" + renderTaskListItem(
- String(trimmed.dropFirst(2)),
- inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
- ) + "</li>\n"
+ flushListItem()
+ currentListItemLines = [String(trimmed.dropFirst(2))]
continue
}
@@ -636,10 +963,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
html += "<ol>\n"
listType = .ordered
}
- html += "<li>" + renderTaskListItem(
- orderedItem,
- inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
- ) + "</li>\n"
+ flushListItem()
+ currentListItemLines = [orderedItem]
+ continue
+ }
+
+ if listType != nil && isIndentedContinuationLine(line) {
+ currentListItemLines.append(trimmed)
continue
}
@@ -658,6 +988,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
}
closeSourceBlock()
+ closeExampleBlock()
+ closeCenterBlock()
closeQuoteBlock()
flushBlockState()
@@ -720,6 +1052,22 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
}
return "<code>\(codeText)</code>"
}
+ result = protectMatches(
+ in: result,
+ pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let value = nsText.substring(with: match.range(at: 1))
+ return "<del>\(value)</del>"
+ }
+ result = protectMatches(
+ in: result,
+ pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
+ protectedFragments: &protectedFragments
+ ) { match, nsText in
+ let value = nsText.substring(with: match.range(at: 1))
+ return "<u>\(value)</u>"
+ }
// Bold: *text*
result = result.replacingOccurrences(
@@ -733,6 +1081,17 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
with: "<em>$1</em>",
options: .regularExpression
)
+ result = replaceMatches(
+ in: result,
+ pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
+ ) { match, nsText in
+ guard !isInsideHTMLTag(nsText, range: match.range) else {
+ return nsText.substring(with: match.range)
+ }
+ let email = nsText.substring(with: match.range(at: 1))
+ let href = escapeHTMLAttribute("mailto:\(email)")
+ return #"<a href="\#(href)">\#(email)</a>"#
+ }
for (token, fragment) in protectedFragments {
result = result.replacingOccurrences(of: token, with: fragment)
@@ -805,11 +1164,11 @@ nonisolated private func sanitizeReadmeURLString(
return escapeHTMLAttribute(sanitizedURL)
}
-nonisolated private func isOrgTableLine(_ line: String) -> Bool {
+nonisolated private func isTableLine(_ line: String) -> Bool {
line.hasPrefix("|") && line.hasSuffix("|")
}
-nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
+nonisolated private func parseTableRow(_ line: String) -> [String] {
line
.split(separator: "|", omittingEmptySubsequences: false)
.dropFirst()
@@ -817,21 +1176,163 @@ nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
.map { String($0).trimmingCharacters(in: .whitespaces) }
}
-nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
+nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
let trimmed = cell.trimmingCharacters(in: .whitespaces)
return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
}
-private enum OrgListType {
+nonisolated private func renderHTMLTable(
+ rows: [[String]],
+ inlineRenderer: (String) -> String
+) -> String {
+ guard !rows.isEmpty else { return "" }
+ let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell)
+ let headerRow = rows.first ?? []
+ let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
+ var html = "<table>\n"
+
+ if hasHeaderSeparator {
+ html += "<thead><tr>"
+ for cell in headerRow {
+ html += "<th>" + inlineRenderer(cell) + "</th>"
+ }
+ html += "</tr></thead>\n"
+ }
+
+ html += "<tbody>\n"
+ for row in bodyRows {
+ html += "<tr>"
+ for cell in row {
+ html += "<td>" + inlineRenderer(cell) + "</td>"
+ }
+ html += "</tr>\n"
+ }
+ html += "</tbody>\n"
+ html += "</table>\n"
+ return html
+}
+
+private enum MarkupListType: Equatable {
case unordered
case ordered
}
+private typealias OrgListType = MarkupListType
+
nonisolated private func orderedListItem(in line: String) -> String? {
guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
return String(match.2)
}
+nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool {
+ matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#)
+}
+
+nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
+ matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
+}
+
+nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
+ let range = NSRange(location: 0, length: (text as NSString).length)
+ return regex.firstMatch(in: text, range: range) != nil
+}
+
+nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
+ guard range.location != NSNotFound else { return false }
+ let prefix = text.substring(to: range.location)
+ guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
+ guard let lastClose = prefix.lastIndex(of: ">") else { return true }
+ return lastOpen > lastClose
+}
+
+nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
+ guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
+ guard let first = line.first else { return false }
+ return first == " " || first == "\t"
+}
+
+nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool {
+ line.hasPrefix("- ") || line.hasPrefix("* ")
+}
+
+nonisolated private func decodeHTMLEntities(_ text: String) -> String {
+ text
+ .replacingOccurrences(of: "&amp;", with: "&")
+ .replacingOccurrences(of: "&quot;", with: "\"")
+ .replacingOccurrences(of: "&#39;", with: "'")
+ .replacingOccurrences(of: "&lt;", with: "<")
+ .replacingOccurrences(of: "&gt;", with: ">")
+}
+
+nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? {
+ guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil }
+ return sanitizedMarkdownHTMLTag(line)
+}
+
+nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
+ let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
+ guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
+
+ let selfClosing = trimmed.hasSuffix("/>")
+ let contentStart = trimmed.index(after: trimmed.startIndex)
+ let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
+ let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
+ let isClosing = inner.hasPrefix("/")
+ let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
+ let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
+ guard let rawName = parts.first else { return nil }
+ let tagName = rawName.lowercased()
+ let allowedTags: Set<String> = [
+ "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
+ "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
+ "sup", "u", "ul"
+ ]
+ guard allowedTags.contains(tagName) else { return nil }
+
+ if isClosing {
+ return "</\(tagName)>"
+ }
+
+ let attributePortion = parts.count > 1 ? String(parts[1]) : ""
+ let attributes = parseHTMLAttributes(attributePortion)
+ var renderedAttributes: [String] = []
+
+ for (name, value) in attributes {
+ switch (tagName, name.lowercased()) {
+ case ("a", "href"):
+ if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
+ renderedAttributes.append(#"href="\#(sanitized)""#)
+ }
+ case ("img", "src"):
+ if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
+ renderedAttributes.append(#"src="\#(sanitized)""#)
+ }
+ case ("img", "alt"), (_, "title"), (_, "class"):
+ renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
+ default:
+ continue
+ }
+ }
+
+ let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
+ let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
+ return "<\(tagName)\(attributeText)\(suffix)>"
+}
+
+nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
+ guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
+ return []
+ }
+ let nsText = text as NSString
+ return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
+ let name = nsText.substring(with: match.range(at: 1))
+ let value = nsText.substring(with: match.range(at: 2))
+ return (name, value)
+ }
+}
+
nonisolated private func protectMatches(
in text: String,
pattern: String,
@@ -843,7 +1344,7 @@ nonisolated private func protectMatches(
let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
for match in matches.reversed() {
- let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
+ let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
let nsText = result as NSString
protectedFragments[token] = transform(match, nsText)
result = nsText.replacingCharacters(in: match.range, with: token)
@@ -877,8 +1378,9 @@ nonisolated private func makeOrgImageHTML(
) -> String? {
guard isRenderableImageSource(source) else { return nil }
let resolvedSource = imageURLResolver?(source) ?? source
+ guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
let altText = escapeHTMLAttribute(alt ?? "")
- return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
+ return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
}
nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
@@ -911,7 +1413,19 @@ nonisolated func resolveRepositoryAssetURL(
}
guard !relativePath.isEmpty else { return nil }
- return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
+ var components = URLComponents()
+ components.scheme = "https"
+ components.host = "git.sr.ht"
+ let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
+ let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
+ let encodedRelativePath = relativePath
+ .split(separator: "/", omittingEmptySubsequences: false)
+ .map { segment in
+ String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
+ }
+ .joined(separator: "/")
+ components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
+ return components.string
}
nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
@@ -1099,6 +1613,53 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
a { color: \(linkColor); }
table { border-collapse: collapse; width: 100%; }
td, th { border: 1px solid #ccc; padding: 4px 8px; }
+ blockquote {
+ border-left: 3px solid rgba(128, 128, 128, 0.5);
+ margin: 0.5em 0;
+ padding: 0.25em 0 0.25em 1em;
+ color: inherit;
+ opacity: 0.85;
+ }
+ hr {
+ border: none;
+ border-top: 1px solid rgba(128, 128, 128, 0.35);
+ margin: 1em 0;
+ }
+ table {
+ border-collapse: collapse;
+ width: 100%;
+ margin: 0.75em 0;
+ font-size: 0.95em;
+ }
+ th {
+ background: rgba(128, 128, 128, 0.15);
+ font-weight: 600;
+ text-align: left;
+ }
+ td, th {
+ border: 1px solid rgba(128, 128, 128, 0.3);
+ padding: 6px 10px;
+ }
+ dl.org-properties {
+ margin: 0.5em 0;
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 2px 12px;
+ }
+ dt {
+ font-weight: 600;
+ font-family: ui-monospace, Menlo, monospace;
+ font-size: 0.9em;
+ }
+ dd { margin: 0; }
+ .org-metadata { margin-bottom: 1em; }
+ .org-title { margin: 0 0 0.25em; }
+ .org-author, .org-date {
+ margin: 0;
+ color: rgba(128, 128, 128, 0.85);
+ font-size: 0.9em;
+ }
+ del { opacity: 0.7; }
</style>
</head>
<body>\(html)</body>
diff --git a/HutchTests/ReadmeViewTests.swift b/HutchTests/ReadmeViewTests.swift
index 9c3c019..7fbca86 100644
--- a/HutchTests/ReadmeViewTests.swift
+++ b/HutchTests/ReadmeViewTests.swift
@@ -27,3 +27,214 @@ struct ReadmeViewTests {
#expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
}
}
+
+struct MarkdownRenderingTests {
+
+ @Test
+ func markdownOrderedList() {
+ let html = markdownToHTML("1. First\n2. Second\n3. Third")
+
+ #expect(html.contains("<ol>"))
+ #expect(html.contains("<li>First</li>"))
+ #expect(html.contains("<li>Third</li>"))
+ #expect(html.contains("</ol>"))
+ }
+
+ @Test
+ func markdownBlockquote() {
+ let html = markdownToHTML("> This is a quote")
+
+ #expect(html.contains("<blockquote>"))
+ #expect(html.contains("This is a quote"))
+ #expect(html.contains("</blockquote>"))
+ }
+
+ @Test
+ func markdownTable() {
+ let input = "| A | B |\n|---|---|\n| 1 | 2 |"
+ let html = markdownToHTML(input)
+
+ #expect(html.contains("<table>"))
+ #expect(html.contains("<th>"))
+ #expect(html.contains("<td>"))
+ }
+
+ @Test
+ func markdownHorizontalRule() {
+ let html = markdownToHTML("---")
+
+ #expect(html.contains("<hr>"))
+ }
+
+ @Test
+ func markdownDeepHeadings() {
+ let html = markdownToHTML("#### Level 4\n##### Level 5\n###### Level 6")
+
+ #expect(html.contains("<h4>"))
+ #expect(html.contains("<h5>"))
+ #expect(html.contains("<h6>"))
+ }
+
+ @Test
+ func markdownStrikethrough() {
+ let html = markdownToHTML("~~deleted~~")
+
+ #expect(html.contains("<del>deleted</del>"))
+ }
+
+ @Test
+ func markdownInlineCodeEscaping() {
+ let html = processInline("`<b>`")
+
+ #expect(html.contains("<code>"))
+ #expect(html.contains("&lt;b&gt;"))
+ #expect(!html.contains("<b>"))
+ }
+
+ @Test
+ func markdownWrappedBulletNormalizesLines() {
+ let html = markdownToHTML("- First line\n continues here")
+
+ #expect(html.contains("<li>First line continues here</li>"))
+ }
+
+ @Test
+ func markdownPlainEmailAutolinks() {
+ let html = processInline("Contact [email protected]")
+
+ #expect(html.contains(#"href="mailto:[email protected]""#))
+ #expect(html.contains(">[email protected]</a>"))
+ }
+
+ @Test
+ func markdownListContinuesAfterCodeBlock() {
+ let html = markdownToHTML("""
+ 1. Clone the repository:
+ ```sh
+ git clone https://git.sr.ht/~ccleberg/Hutch
+ ```
+ 2. Open the project in Xcode.
+ """)
+
+ #expect(html.contains("<ol>"))
+ #expect(html.contains("<pre><code>"))
+ #expect(html.contains("<li><p>Clone the repository:</p>"))
+ #expect(html.contains("<li>Open the project in Xcode.</li>"))
+ #expect(html.contains("</ol>"))
+ }
+
+ @Test
+ func markdownListContinuesAfterBlankLineIndentedCodeBlock() {
+ let html = markdownToHTML("""
+ 1. Clone the repository:
+
+ ```sh
+ git clone https://git.sr.ht/~ccleberg/Hutch
+ ```
+
+ 2. Open the project in Xcode.
+ """)
+
+ #expect(html.contains("<li><p>Clone the repository:</p>"))
+ #expect(html.contains("<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>"))
+ #expect(html.contains("<li>Open the project in Xcode.</li>"))
+ #expect(!html.contains("<ol>\n<li>Open the project in Xcode.</li>\n</ol>\n<ol>"))
+ #expect(html.firstRange(of: "<p>Clone the repository:</p>")!.lowerBound < html.firstRange(of: "<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>")!.lowerBound)
+ }
+
+ @Test
+ func markdownImageQueryStringPreservesAmpersands() {
+ let html = processInline("![badge](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)")
+
+ #expect(html.contains("metric=security_rating"))
+ #expect(!html.contains("amp;metric"))
+ #expect(html.contains("<img"))
+ }
+
+ @Test
+ func markdownAllowsSafeInlineHTML() {
+ let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)
+
+ #expect(html.contains("<strong>Bold</strong>"))
+ #expect(html.contains(#"<a href="https://example.com">Link</a>"#))
+ }
+
+ @Test
+ func markdownProtectedTokensDoNotLeak() {
+ let html = processInline(#"<a href="https://example.com">Back to top</a> `yoshi [ARG] <FILE>`"#)
+
+ #expect(!html.contains("ZZPROTECTED"))
+ #expect(html.contains(#"<a href="https://example.com">Back to top</a>"#))
+ #expect(html.contains("<code>yoshi [ARG] &lt;FILE&gt;</code>"))
+ }
+}
+
+struct OrgRenderingTests {
+
+ @Test
+ func orgDeepHeading() {
+ let html = orgToHTML("**** Level 4 Heading")
+
+ #expect(html.contains("<h4>"))
+ }
+
+ @Test
+ func orgCommentLinesIgnored() {
+ let html = orgToHTML("# This is a comment\nNormal text")
+
+ #expect(!html.contains("This is a comment"))
+ #expect(html.contains("Normal text"))
+ }
+
+ @Test
+ func orgStrikethrough() {
+ let html = orgToHTML("+deleted text+")
+
+ #expect(html.contains("<del>"))
+ }
+
+ @Test
+ func orgExampleBlock() {
+ let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
+
+ #expect(html.contains("<pre><code>"))
+ #expect(html.contains("hello world"))
+ }
+
+ @Test
+ func orgTitleKeyword() {
+ let html = orgToHTML("#+TITLE: My Document\nBody text")
+
+ #expect(html.contains("org-title"))
+ #expect(html.contains("My Document"))
+ }
+
+ @Test
+ func orgItalicDoesNotMatchURLPaths() {
+ let html = orgToHTML("[[https://example.com/path/to/file]]")
+
+ #expect(!html.contains("<em>"))
+ }
+
+ @Test
+ func orgWrappedBulletNormalizesLines() {
+ let html = orgToHTML("- First line\n continues here")
+
+ #expect(html.contains("<li>First line continues here</li>"))
+ }
+}
+
+struct RepositoryAssetURLTests {
+
+ @Test
+ func repositoryAssetURLPercentEncodesImagePaths() {
+ let url = resolveRepositoryAssetURL(
+ "images/My Logo.png",
+ owner: "~ccleberg",
+ repositoryName: "Hutch",
+ readmePath: "README.md"
+ )
+
+ #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
+ }
+}