Commit a34fb9e738

a34fb9e7381e8ff210d1ea289ccd403f12112c11

parent: aaba253bce

Unsigned

cmc <hello@cleberg.net> · 2026-03-30 18:10 UTC

partial extended support for markdown and org-mode

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -459,7 +459,7 @@
459459 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
460460 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
461461 CODE_SIGN_STYLE = Automatic;
462 CURRENT_PROJECT_VERSION = 11;
462 CURRENT_PROJECT_VERSION = 12;
463463 DEVELOPMENT_TEAM = ZCNAX3VL9D;
464464 ENABLE_PREVIEWS = YES;
465465 GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
476476 "$(inherited)",
477477 "@executable_path/Frameworks",
478478 );
479 MARKETING_VERSION = 2.4.1;
479 MARKETING_VERSION = 2.4.2;
480480 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
481481 PRODUCT_NAME = "$(TARGET_NAME)";
482482 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -496,7 +496,7 @@
496496 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
497497 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
498498 CODE_SIGN_STYLE = Automatic;
499 CURRENT_PROJECT_VERSION = 11;
499 CURRENT_PROJECT_VERSION = 12;
500500 DEVELOPMENT_TEAM = ZCNAX3VL9D;
501501 ENABLE_PREVIEWS = YES;
502502 GENERATE_INFOPLIST_FILE = YES;
@@ -513,7 +513,7 @@
513513 "$(inherited)",
514514 "@executable_path/Frameworks",
515515 );
516 MARKETING_VERSION = 2.4.1;
516 MARKETING_VERSION = 2.4.2;
517517 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
518518 PRODUCT_NAME = "$(TARGET_NAME)";
519519 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576576 APPLICATION_EXTENSION_API_ONLY = YES;
577577 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
578578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 11;
579 CURRENT_PROJECT_VERSION = 12;
580580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581581 GENERATE_INFOPLIST_FILE = NO;
582582 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -586,7 +586,7 @@
586586 "@executable_path/Frameworks",
587587 "@executable_path/../../Frameworks",
588588 );
589 MARKETING_VERSION = 2.4.1;
589 MARKETING_VERSION = 2.4.2;
590590 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
591591 PRODUCT_NAME = "$(TARGET_NAME)";
592592 SKIP_INSTALL = YES;
@@ -605,7 +605,7 @@
605605 APPLICATION_EXTENSION_API_ONLY = YES;
606606 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
607607 CODE_SIGN_STYLE = Automatic;
608 CURRENT_PROJECT_VERSION = 11;
608 CURRENT_PROJECT_VERSION = 12;
609609 DEVELOPMENT_TEAM = ZCNAX3VL9D;
610610 GENERATE_INFOPLIST_FILE = NO;
611611 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -615,7 +615,7 @@
615615 "@executable_path/Frameworks",
616616 "@executable_path/../../Frameworks",
617617 );
618 MARKETING_VERSION = 2.4.1;
618 MARKETING_VERSION = 2.4.2;
619619 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
620620 PRODUCT_NAME = "$(TARGET_NAME)";
621621 SKIP_INSTALL = YES;
Hutch/Views/Repositories/ReadmeView.swift +630 −69
@@ -283,8 +283,15 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
283283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
284284 var html = ""
285285 var inCodeBlock = false
286 var inList = false
286 var codeBlockInListItem = false
287 var codeBlockLines: [String] = []
288 var listType: MarkupListType?
289 var inBlockquote = false
290 var pendingListItemBreak = false
291 var currentListItemLines: [String] = []
292 var currentListItemBlocks: [String] = []
287293 var paragraph: [String] = []
294 var tableRows: [[String]] = []
288295
289296 func flushParagraph() {
290297 if !paragraph.isEmpty {
@@ -296,72 +303,262 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
296303 }
297304 }
298305
306 func flushListItem() {
307 guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return }
308 let itemContent = currentListItemLines
309 .map { $0.trimmingCharacters(in: .whitespaces) }
310 .joined(separator: " ")
311
312 if currentListItemBlocks.isEmpty {
313 html += "<li>" + renderTaskListItem(
314 itemContent,
315 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
316 ) + "</li>\n"
317 } else {
318 if !itemContent.isEmpty {
319 currentListItemBlocks.append(
320 "<p>" + renderTaskListItem(
321 itemContent,
322 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
323 ) + "</p>"
324 )
325 }
326 html += "<li>" + currentListItemBlocks.joined(separator: "\n") + "</li>\n"
327 }
328 currentListItemLines = []
329 currentListItemBlocks = []
330 }
331
332 func flushListItemParagraphIntoBlocks() {
333 guard !currentListItemLines.isEmpty else { return }
334 let itemContent = currentListItemLines
335 .map { $0.trimmingCharacters(in: .whitespaces) }
336 .joined(separator: " ")
337 currentListItemBlocks.append(
338 "<p>" + renderTaskListItem(
339 itemContent,
340 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
341 ) + "</p>"
342 )
343 currentListItemLines = []
344 }
345
346 func flushCodeBlock() {
347 let content = codeBlockLines.joined(separator: "\n")
348 let blockHTML = "<pre><code>" + content + "</code></pre>\n"
349 if codeBlockInListItem {
350 currentListItemBlocks.append(blockHTML)
351 } else {
352 html += blockHTML
353 }
354 codeBlockLines = []
355 codeBlockInListItem = false
356 }
357
299358 func closeList() {
300 if inList {
359 flushListItem()
360 switch listType {
361 case .unordered:
301362 html += "</ul>\n"
302 inList = false
363 case .ordered:
364 html += "</ol>\n"
365 case nil:
366 break
367 }
368 listType = nil
369 }
370
371 func flushTable() {
372 guard !tableRows.isEmpty else { return }
373 html += renderHTMLTable(
374 rows: tableRows,
375 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
376 )
377 tableRows = []
378 }
379
380 func closeBlockquote() {
381 if inBlockquote {
382 flushParagraph()
383 html += "</blockquote>\n"
384 inBlockquote = false
303385 }
304386 }
305387
306388 for line in lines {
389 let trimmed = line.trimmingCharacters(in: .whitespaces)
390
391 if pendingListItemBreak, listType != nil {
392 if trimmed.isEmpty {
393 continue
394 }
395 if isIndentedContinuationLine(line) || trimmed.hasPrefix("```") {
396 pendingListItemBreak = false
397 } else if isMarkdownUnorderedListItem(trimmed) || orderedListItem(in: trimmed) != nil {
398 flushListItem()
399 pendingListItemBreak = false
400 } else {
401 flushListItem()
402 closeList()
403 pendingListItemBreak = false
404 }
405 }
406
407 if let rawHTML = sanitizedMarkdownHTMLLine(from: trimmed) {
408 closeBlockquote()
409 flushParagraph()
410 flushTable()
411 if listType != nil {
412 flushListItemParagraphIntoBlocks()
413 currentListItemBlocks.append(rawHTML)
414 } else {
415 closeList()
416 html += rawHTML + "\n"
417 }
418 continue
419 }
420
307421 // Fenced code blocks
308 if line.hasPrefix("```") {
422 if trimmed.hasPrefix("```") {
309423 if inCodeBlock {
310 html += "</code></pre>\n"
424 flushCodeBlock()
311425 inCodeBlock = false
312426 } else {
427 closeBlockquote()
313428 flushParagraph()
314 closeList()
315 html += "<pre><code>"
429 flushTable()
430 codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
431 if !codeBlockInListItem {
432 closeList()
433 } else {
434 flushListItemParagraphIntoBlocks()
435 }
316436 inCodeBlock = true
437 codeBlockLines = []
317438 }
318439 continue
319440 }
320441
321442 if inCodeBlock {
322 html += escapeHTML(line) + "\n"
443 codeBlockLines.append(escapeHTML(line))
323444 continue
324445 }
325446
447 if isTableLine(trimmed) {
448 closeBlockquote()
449 flushParagraph()
450 closeList()
451 tableRows.append(parseTableRow(trimmed))
452 continue
453 } else {
454 flushTable()
455 }
456
326457 // Headings
458 if line.hasPrefix("###### ") {
459 closeBlockquote()
460 flushParagraph()
461 closeList()
462 html += "<h6>" + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "</h6>\n"
463 continue
464 }
465 if line.hasPrefix("##### ") {
466 closeBlockquote()
467 flushParagraph()
468 closeList()
469 html += "<h5>" + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "</h5>\n"
470 continue
471 }
472 if line.hasPrefix("#### ") {
473 closeBlockquote()
474 flushParagraph()
475 closeList()
476 html += "<h4>" + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "</h4>\n"
477 continue
478 }
327479 if line.hasPrefix("### ") {
480 closeBlockquote()
328481 flushParagraph()
329482 closeList()
330483 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
331484 continue
332485 }
333486 if line.hasPrefix("## ") {
487 closeBlockquote()
334488 flushParagraph()
335489 closeList()
336490 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
337491 continue
338492 }
339493 if line.hasPrefix("# ") {
494 closeBlockquote()
340495 flushParagraph()
341496 closeList()
342497 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
343498 continue
344499 }
345500
501 if isMarkdownHorizontalRule(trimmed) {
502 closeBlockquote()
503 flushParagraph()
504 closeList()
505 html += "<hr>\n"
506 continue
507 }
508
509 if trimmed.hasPrefix("> ") {
510 flushTable()
511 closeList()
512 if !inBlockquote {
513 flushParagraph()
514 html += "<blockquote>\n"
515 inBlockquote = true
516 }
517 paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver))
518 continue
519 } else {
520 closeBlockquote()
521 }
522
346523 // List items
347 let trimmed = line.trimmingCharacters(in: .whitespaces)
348524 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
349525 flushParagraph()
350 if !inList {
526 if listType != .unordered {
527 closeList()
351528 html += "<ul>\n"
352 inList = true
529 listType = .unordered
353530 }
354 html += "<li>" + renderTaskListItem(
355 String(trimmed.dropFirst(2)),
356 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
357 ) + "</li>\n"
531 flushListItem()
532 currentListItemLines = [String(trimmed.dropFirst(2))]
533 continue
534 }
535 if let orderedItem = orderedListItem(in: trimmed) {
536 flushParagraph()
537 if listType != .ordered {
538 closeList()
539 html += "<ol>\n"
540 listType = .ordered
541 }
542 flushListItem()
543 currentListItemLines = [orderedItem]
544 continue
545 }
546
547 if listType != nil && isIndentedContinuationLine(line) {
548 currentListItemLines.append(trimmed)
358549 continue
359550 }
360551
361552 // Blank line
362553 if trimmed.isEmpty {
363 flushParagraph()
364 closeList()
554 if inBlockquote {
555 closeBlockquote()
556 } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty {
557 pendingListItemBreak = true
558 } else {
559 flushParagraph()
560 closeList()
561 }
365562 continue
366563 }
367564
@@ -371,21 +568,33 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
371568
372569 // Flush remaining state
373570 if inCodeBlock {
374 html += "</code></pre>\n"
571 flushCodeBlock()
375572 }
573 closeBlockquote()
376574 flushParagraph()
575 flushTable()
377576 closeList()
378577
379578 return html
380579}
381580
382581nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
383 var result = escapeHTML(text)
582 var protectedFragments: [String: String] = [:]
583 var result = protectMatches(
584 in: text,
585 pattern: #"</?[A-Za-z][^>]*?>"#,
586 protectedFragments: &protectedFragments
587 ) { match, nsText in
588 let rawTag = nsText.substring(with: match.range)
589 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
590 }
591
592 result = escapeHTML(result)
384593
385594 // Images: ![alt](url)
386595 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
387596 let alt = nsText.substring(with: match.range(at: 1))
388 let source = nsText.substring(with: match.range(at: 2))
597 let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
389598 let resolvedSource = imageURLResolver?(source) ?? source
390599 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
391600 return escapeHTML(alt)
@@ -395,12 +604,30 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
395604 // Links: [text](url)
396605 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
397606 let label = nsText.substring(with: match.range(at: 1))
398 let rawURL = nsText.substring(with: match.range(at: 2))
607 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
399608 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
400609 return label
401610 }
402611 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
403612 }
613 // Plain email autolinks
614 result = replaceMatches(
615 in: result,
616 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
617 ) { match, nsText in
618 guard !isInsideHTMLTag(nsText, range: match.range) else {
619 return nsText.substring(with: match.range)
620 }
621 let email = nsText.substring(with: match.range(at: 1))
622 let href = escapeHTMLAttribute("mailto:\(email)")
623 return #"<a href="\#(href)">\#(email)</a>"#
624 }
625 // Strikethrough: ~~text~~
626 result = result.replacingOccurrences(
627 of: #"~~(.+?)~~"#,
628 with: "<del>$1</del>",
629 options: .regularExpression
630 )
404631 // Bold: **text**
405632 result = result.replacingOccurrences(
406633 of: #"\*\*(.+?)\*\*"#,
@@ -409,7 +636,13 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
409636 )
410637 // Italic: *text*
411638 result = result.replacingOccurrences(
412 of: #"\*(.+?)\*"#,
639 of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
640 with: "<em>$1</em>",
641 options: .regularExpression
642 )
643 // Italic: _text_
644 result = result.replacingOccurrences(
645 of: #"(?<!\w)_(.+?)_(?!\w)"#,
413646 with: "<em>$1</em>",
414647 options: .regularExpression
415648 )
@@ -420,6 +653,10 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
420653 options: .regularExpression
421654 )
422655
656 for (token, fragment) in protectedFragments {
657 result = result.replacingOccurrences(of: token, with: fragment)
658 }
659
423660 return result
424661}
425662
@@ -429,12 +666,39 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
429666 let normalizedText = text
430667 .replacingOccurrences(of: "\r\n", with: "\n")
431668 .replacingOccurrences(of: "\r", with: "\n")
432 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
669 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
670 var title: String?
671 var author: String?
672 var date: String?
673 let lines = rawLines.filter { line in
674 let trimmed = line.trimmingCharacters(in: .whitespaces)
675 guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else {
676 return true
677 }
678 let keyword = String(keywordMatch.1).lowercased()
679 let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
680 switch keyword {
681 case "title":
682 title = value
683 return false
684 case "author":
685 author = value
686 return false
687 case "date":
688 date = value
689 return false
690 default:
691 return true
692 }
693 }
433694 var html = ""
434695 var listType: OrgListType?
435696 var inQuoteBlock = false
436697 var inPropertyDrawer = false
437698 var srcLanguage: String?
699 var inExampleBlock = false
700 var inCenterBlock = false
701 var currentListItemLines: [String] = []
438702 var paragraph: [String] = []
439703 var tableRows: [[String]] = []
440704 var propertyRows: [(String, String)] = []
@@ -449,7 +713,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
449713 }
450714 }
451715
716 func flushListItem() {
717 guard !currentListItemLines.isEmpty else { return }
718 let content = currentListItemLines
719 .map { $0.trimmingCharacters(in: .whitespaces) }
720 .joined(separator: " ")
721 html += "<li>" + renderTaskListItem(
722 content,
723 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
724 ) + "</li>\n"
725 currentListItemLines = []
726 }
727
452728 func closeList() {
729 flushListItem()
453730 switch listType {
454731 case .unordered:
455732 html += "</ul>\n"
@@ -463,34 +740,10 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
463740
464741 func flushTable() {
465742 guard !tableRows.isEmpty else { return }
466 let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell)
467 let headerRow = tableRows.first ?? []
468 let bodyRows: [[String]]
469
470 html += "<table>\n"
471 if hasHeaderSeparator {
472 html += "<thead><tr>"
473 for cell in headerRow {
474 html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
475 }
476 html += "</tr></thead>\n<tbody>\n"
477 bodyRows = Array(tableRows.dropFirst(2))
478 } else {
479 bodyRows = tableRows
480 }
481
482 for row in bodyRows {
483 html += "<tr>"
484 for cell in row {
485 html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
486 }
487 html += "</tr>\n"
488 }
489
490 if hasHeaderSeparator {
491 html += "</tbody>\n"
492 }
493 html += "</table>\n"
743 html += renderHTMLTable(
744 rows: tableRows,
745 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
746 )
494747 tableRows = []
495748 }
496749
@@ -520,6 +773,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
520773 }
521774 }
522775
776 func closeExampleBlock() {
777 if inExampleBlock {
778 html += "</code></pre>\n"
779 inExampleBlock = false
780 }
781 }
782
783 func closeCenterBlock() {
784 if inCenterBlock {
785 flushParagraph()
786 html += "</div>\n"
787 inCenterBlock = false
788 }
789 }
790
523791 func flushBlockState() {
524792 flushParagraph()
525793 closeList()
@@ -527,6 +795,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
527795 flushPropertyDrawer()
528796 }
529797
798 if title != nil || author != nil || date != nil {
799 html += "<div class=\"org-metadata\">\n"
800 if let title {
801 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
802 }
803 if let author {
804 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
805 }
806 if let date {
807 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
808 }
809 html += "</div>\n"
810 }
811
530812 for line in lines {
531813 let trimmed = line.trimmingCharacters(in: .whitespaces)
532814
@@ -539,11 +821,35 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
539821 continue
540822 }
541823
824 if inExampleBlock {
825 if trimmed.lowercased() == "#+end_example" {
826 closeExampleBlock()
827 } else {
828 html += escapeHTML(line) + "\n"
829 }
830 continue
831 }
832
542833 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
543834 closeQuoteBlock()
544835 continue
545836 }
546837
838 if inCenterBlock {
839 if trimmed.lowercased() == "#+end_center" {
840 closeCenterBlock()
841 } else if trimmed.isEmpty {
842 flushParagraph()
843 } else {
844 paragraph.append(line)
845 }
846 continue
847 }
848
849 if trimmed == "#" || trimmed.hasPrefix("# ") {
850 continue
851 }
852
547853 if trimmed.lowercased().hasPrefix("#+begin_src") {
548854 closeQuoteBlock()
549855 flushBlockState()
@@ -559,6 +865,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
559865 continue
560866 }
561867
868 if trimmed.lowercased() == "#+begin_example" {
869 closeQuoteBlock()
870 flushBlockState()
871 html += "<pre><code>"
872 inExampleBlock = true
873 continue
874 }
875
562876 if trimmed.lowercased() == "#+begin_quote" {
563877 flushBlockState()
564878 html += "<blockquote>\n"
@@ -566,6 +880,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
566880 continue
567881 }
568882
883 if trimmed.lowercased() == "#+begin_center" {
884 closeQuoteBlock()
885 flushBlockState()
886 html += "<div style=\"text-align:center\">\n"
887 inCenterBlock = true
888 continue
889 }
890
569891 if trimmed == ":PROPERTIES:" {
570892 closeQuoteBlock()
571893 flushBlockState()
@@ -592,18 +914,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
592914 }
593915 }
594916
595 if isOrgTableLine(trimmed) {
917 if isTableLine(trimmed) {
596918 closeQuoteBlock()
597919 flushParagraph()
598920 closeList()
599 tableRows.append(parseOrgTableRow(trimmed))
921 tableRows.append(parseTableRow(trimmed))
600922 continue
601923 } else {
602924 flushTable()
603925 }
604926
927 if isOrgHorizontalRule(trimmed) {
928 closeQuoteBlock()
929 flushBlockState()
930 html += "<hr>\n"
931 continue
932 }
933
605934 // Org headings: * heading, ** heading, *** heading
606 if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) {
935 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
607936 closeQuoteBlock()
608937 flushBlockState()
609938 let level = match.1.count
@@ -621,10 +950,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
621950 html += "<ul>\n"
622951 listType = .unordered
623952 }
624 html += "<li>" + renderTaskListItem(
625 String(trimmed.dropFirst(2)),
626 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
627 ) + "</li>\n"
953 flushListItem()
954 currentListItemLines = [String(trimmed.dropFirst(2))]
628955 continue
629956 }
630957
@@ -636,10 +963,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
636963 html += "<ol>\n"
637964 listType = .ordered
638965 }
639 html += "<li>" + renderTaskListItem(
640 orderedItem,
641 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
642 ) + "</li>\n"
966 flushListItem()
967 currentListItemLines = [orderedItem]
968 continue
969 }
970
971 if listType != nil && isIndentedContinuationLine(line) {
972 currentListItemLines.append(trimmed)
643973 continue
644974 }
645975
@@ -658,6 +988,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
658988 }
659989
660990 closeSourceBlock()
991 closeExampleBlock()
992 closeCenterBlock()
661993 closeQuoteBlock()
662994 flushBlockState()
663995
@@ -720,6 +1052,22 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
7201052 }
7211053 return "<code>\(codeText)</code>"
7221054 }
1055 result = protectMatches(
1056 in: result,
1057 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
1058 protectedFragments: &protectedFragments
1059 ) { match, nsText in
1060 let value = nsText.substring(with: match.range(at: 1))
1061 return "<del>\(value)</del>"
1062 }
1063 result = protectMatches(
1064 in: result,
1065 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
1066 protectedFragments: &protectedFragments
1067 ) { match, nsText in
1068 let value = nsText.substring(with: match.range(at: 1))
1069 return "<u>\(value)</u>"
1070 }
7231071
7241072 // Bold: *text*
7251073 result = result.replacingOccurrences(
@@ -733,6 +1081,17 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
7331081 with: "<em>$1</em>",
7341082 options: .regularExpression
7351083 )
1084 result = replaceMatches(
1085 in: result,
1086 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
1087 ) { match, nsText in
1088 guard !isInsideHTMLTag(nsText, range: match.range) else {
1089 return nsText.substring(with: match.range)
1090 }
1091 let email = nsText.substring(with: match.range(at: 1))
1092 let href = escapeHTMLAttribute("mailto:\(email)")
1093 return #"<a href="\#(href)">\#(email)</a>"#
1094 }
7361095
7371096 for (token, fragment) in protectedFragments {
7381097 result = result.replacingOccurrences(of: token, with: fragment)
@@ -805,11 +1164,11 @@ nonisolated private func sanitizeReadmeURLString(
8051164 return escapeHTMLAttribute(sanitizedURL)
8061165}
8071166
808nonisolated private func isOrgTableLine(_ line: String) -> Bool {
1167nonisolated private func isTableLine(_ line: String) -> Bool {
8091168 line.hasPrefix("|") && line.hasSuffix("|")
8101169}
8111170
812nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
1171nonisolated private func parseTableRow(_ line: String) -> [String] {
8131172 line
8141173 .split(separator: "|", omittingEmptySubsequences: false)
8151174 .dropFirst()
@@ -817,21 +1176,163 @@ nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
8171176 .map { String($0).trimmingCharacters(in: .whitespaces) }
8181177}
8191178
820nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool {
1179nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
8211180 let trimmed = cell.trimmingCharacters(in: .whitespaces)
8221181 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
8231182}
8241183
825private enum OrgListType {
1184nonisolated private func renderHTMLTable(
1185 rows: [[String]],
1186 inlineRenderer: (String) -> String
1187) -> String {
1188 guard !rows.isEmpty else { return "" }
1189 let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell)
1190 let headerRow = rows.first ?? []
1191 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
1192 var html = "<table>\n"
1193
1194 if hasHeaderSeparator {
1195 html += "<thead><tr>"
1196 for cell in headerRow {
1197 html += "<th>" + inlineRenderer(cell) + "</th>"
1198 }
1199 html += "</tr></thead>\n"
1200 }
1201
1202 html += "<tbody>\n"
1203 for row in bodyRows {
1204 html += "<tr>"
1205 for cell in row {
1206 html += "<td>" + inlineRenderer(cell) + "</td>"
1207 }
1208 html += "</tr>\n"
1209 }
1210 html += "</tbody>\n"
1211 html += "</table>\n"
1212 return html
1213}
1214
1215private enum MarkupListType: Equatable {
8261216 case unordered
8271217 case ordered
8281218}
8291219
1220private typealias OrgListType = MarkupListType
1221
8301222nonisolated private func orderedListItem(in line: String) -> String? {
8311223 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
8321224 return String(match.2)
8331225}
8341226
1227nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool {
1228 matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#)
1229}
1230
1231nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1232 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1233}
1234
1235nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1236 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1237 let range = NSRange(location: 0, length: (text as NSString).length)
1238 return regex.firstMatch(in: text, range: range) != nil
1239}
1240
1241nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1242 guard range.location != NSNotFound else { return false }
1243 let prefix = text.substring(to: range.location)
1244 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
1245 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
1246 return lastOpen > lastClose
1247}
1248
1249nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1250 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1251 guard let first = line.first else { return false }
1252 return first == " " || first == "\t"
1253}
1254
1255nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool {
1256 line.hasPrefix("- ") || line.hasPrefix("* ")
1257}
1258
1259nonisolated private func decodeHTMLEntities(_ text: String) -> String {
1260 text
1261 .replacingOccurrences(of: "&amp;", with: "&")
1262 .replacingOccurrences(of: "&quot;", with: "\"")
1263 .replacingOccurrences(of: "&#39;", with: "'")
1264 .replacingOccurrences(of: "&lt;", with: "<")
1265 .replacingOccurrences(of: "&gt;", with: ">")
1266}
1267
1268nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? {
1269 guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil }
1270 return sanitizedMarkdownHTMLTag(line)
1271}
1272
1273nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1274 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1275 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1276 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
1277
1278 let selfClosing = trimmed.hasSuffix("/>")
1279 let contentStart = trimmed.index(after: trimmed.startIndex)
1280 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
1281 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
1282 let isClosing = inner.hasPrefix("/")
1283 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
1284 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
1285 guard let rawName = parts.first else { return nil }
1286 let tagName = rawName.lowercased()
1287 let allowedTags: Set<String> = [
1288 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1289 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1290 "sup", "u", "ul"
1291 ]
1292 guard allowedTags.contains(tagName) else { return nil }
1293
1294 if isClosing {
1295 return "</\(tagName)>"
1296 }
1297
1298 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1299 let attributes = parseHTMLAttributes(attributePortion)
1300 var renderedAttributes: [String] = []
1301
1302 for (name, value) in attributes {
1303 switch (tagName, name.lowercased()) {
1304 case ("a", "href"):
1305 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1306 renderedAttributes.append(#"href="\#(sanitized)""#)
1307 }
1308 case ("img", "src"):
1309 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1310 renderedAttributes.append(#"src="\#(sanitized)""#)
1311 }
1312 case ("img", "alt"), (_, "title"), (_, "class"):
1313 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1314 default:
1315 continue
1316 }
1317 }
1318
1319 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1320 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1321 return "<\(tagName)\(attributeText)\(suffix)>"
1322}
1323
1324nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1325 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1326 return []
1327 }
1328 let nsText = text as NSString
1329 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1330 let name = nsText.substring(with: match.range(at: 1))
1331 let value = nsText.substring(with: match.range(at: 2))
1332 return (name, value)
1333 }
1334}
1335
8351336nonisolated private func protectMatches(
8361337 in text: String,
8371338 pattern: String,
@@ -843,7 +1344,7 @@ nonisolated private func protectMatches(
8431344 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
8441345
8451346 for match in matches.reversed() {
846 let token = "__ORG_PROTECTED_\(protectedFragments.count)__"
1347 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
8471348 let nsText = result as NSString
8481349 protectedFragments[token] = transform(match, nsText)
8491350 result = nsText.replacingCharacters(in: match.range, with: token)
@@ -877,8 +1378,9 @@ nonisolated private func makeOrgImageHTML(
8771378) -> String? {
8781379 guard isRenderableImageSource(source) else { return nil }
8791380 let resolvedSource = imageURLResolver?(source) ?? source
1381 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
8801382 let altText = escapeHTMLAttribute(alt ?? "")
881 return #"<img src="\#(resolvedSource)" alt="\#(altText)">"#
1383 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
8821384}
8831385
8841386nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
@@ -911,7 +1413,19 @@ nonisolated func resolveRepositoryAssetURL(
9111413 }
9121414
9131415 guard !relativePath.isEmpty else { return nil }
914 return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)"
1416 var components = URLComponents()
1417 components.scheme = "https"
1418 components.host = "git.sr.ht"
1419 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1420 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1421 let encodedRelativePath = relativePath
1422 .split(separator: "/", omittingEmptySubsequences: false)
1423 .map { segment in
1424 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1425 }
1426 .joined(separator: "/")
1427 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1428 return components.string
9151429}
9161430
9171431nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
@@ -1099,6 +1613,53 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
10991613 a { color: \(linkColor); }
11001614 table { border-collapse: collapse; width: 100%; }
11011615 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1616 blockquote {
1617 border-left: 3px solid rgba(128, 128, 128, 0.5);
1618 margin: 0.5em 0;
1619 padding: 0.25em 0 0.25em 1em;
1620 color: inherit;
1621 opacity: 0.85;
1622 }
1623 hr {
1624 border: none;
1625 border-top: 1px solid rgba(128, 128, 128, 0.35);
1626 margin: 1em 0;
1627 }
1628 table {
1629 border-collapse: collapse;
1630 width: 100%;
1631 margin: 0.75em 0;
1632 font-size: 0.95em;
1633 }
1634 th {
1635 background: rgba(128, 128, 128, 0.15);
1636 font-weight: 600;
1637 text-align: left;
1638 }
1639 td, th {
1640 border: 1px solid rgba(128, 128, 128, 0.3);
1641 padding: 6px 10px;
1642 }
1643 dl.org-properties {
1644 margin: 0.5em 0;
1645 display: grid;
1646 grid-template-columns: max-content 1fr;
1647 gap: 2px 12px;
1648 }
1649 dt {
1650 font-weight: 600;
1651 font-family: ui-monospace, Menlo, monospace;
1652 font-size: 0.9em;
1653 }
1654 dd { margin: 0; }
1655 .org-metadata { margin-bottom: 1em; }
1656 .org-title { margin: 0 0 0.25em; }
1657 .org-author, .org-date {
1658 margin: 0;
1659 color: rgba(128, 128, 128, 0.85);
1660 font-size: 0.9em;
1661 }
1662 del { opacity: 0.7; }
11021663 </style>
11031664 </head>
11041665 <body>\(html)</body>
HutchTests/ReadmeViewTests.swift +211
@@ -27,3 +27,214 @@ struct ReadmeViewTests {
2727 #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
2828 }
2929}
30
31struct MarkdownRenderingTests {
32
33 @Test
34 func markdownOrderedList() {
35 let html = markdownToHTML("1. First\n2. Second\n3. Third")
36
37 #expect(html.contains("<ol>"))
38 #expect(html.contains("<li>First</li>"))
39 #expect(html.contains("<li>Third</li>"))
40 #expect(html.contains("</ol>"))
41 }
42
43 @Test
44 func markdownBlockquote() {
45 let html = markdownToHTML("> This is a quote")
46
47 #expect(html.contains("<blockquote>"))
48 #expect(html.contains("This is a quote"))
49 #expect(html.contains("</blockquote>"))
50 }
51
52 @Test
53 func markdownTable() {
54 let input = "| A | B |\n|---|---|\n| 1 | 2 |"
55 let html = markdownToHTML(input)
56
57 #expect(html.contains("<table>"))
58 #expect(html.contains("<th>"))
59 #expect(html.contains("<td>"))
60 }
61
62 @Test
63 func markdownHorizontalRule() {
64 let html = markdownToHTML("---")
65
66 #expect(html.contains("<hr>"))
67 }
68
69 @Test
70 func markdownDeepHeadings() {
71 let html = markdownToHTML("#### Level 4\n##### Level 5\n###### Level 6")
72
73 #expect(html.contains("<h4>"))
74 #expect(html.contains("<h5>"))
75 #expect(html.contains("<h6>"))
76 }
77
78 @Test
79 func markdownStrikethrough() {
80 let html = markdownToHTML("~~deleted~~")
81
82 #expect(html.contains("<del>deleted</del>"))
83 }
84
85 @Test
86 func markdownInlineCodeEscaping() {
87 let html = processInline("`<b>`")
88
89 #expect(html.contains("<code>"))
90 #expect(html.contains("&lt;b&gt;"))
91 #expect(!html.contains("<b>"))
92 }
93
94 @Test
95 func markdownWrappedBulletNormalizesLines() {
96 let html = markdownToHTML("- First line\n continues here")
97
98 #expect(html.contains("<li>First line continues here</li>"))
99 }
100
101 @Test
102 func markdownPlainEmailAutolinks() {
103 let html = processInline("Contact hello@cleberg.net")
104
105 #expect(html.contains(#"href="mailto:hello@cleberg.net""#))
106 #expect(html.contains(">hello@cleberg.net</a>"))
107 }
108
109 @Test
110 func markdownListContinuesAfterCodeBlock() {
111 let html = markdownToHTML("""
112 1. Clone the repository:
113 ```sh
114 git clone https://git.sr.ht/~ccleberg/Hutch
115 ```
116 2. Open the project in Xcode.
117 """)
118
119 #expect(html.contains("<ol>"))
120 #expect(html.contains("<pre><code>"))
121 #expect(html.contains("<li><p>Clone the repository:</p>"))
122 #expect(html.contains("<li>Open the project in Xcode.</li>"))
123 #expect(html.contains("</ol>"))
124 }
125
126 @Test
127 func markdownListContinuesAfterBlankLineIndentedCodeBlock() {
128 let html = markdownToHTML("""
129 1. Clone the repository:
130
131 ```sh
132 git clone https://git.sr.ht/~ccleberg/Hutch
133 ```
134
135 2. Open the project in Xcode.
136 """)
137
138 #expect(html.contains("<li><p>Clone the repository:</p>"))
139 #expect(html.contains("<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>"))
140 #expect(html.contains("<li>Open the project in Xcode.</li>"))
141 #expect(!html.contains("<ol>\n<li>Open the project in Xcode.</li>\n</ol>\n<ol>"))
142 #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)
143 }
144
145 @Test
146 func markdownImageQueryStringPreservesAmpersands() {
147 let html = processInline("![badge](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)")
148
149 #expect(html.contains("metric=security_rating"))
150 #expect(!html.contains("amp;metric"))
151 #expect(html.contains("<img"))
152 }
153
154 @Test
155 func markdownAllowsSafeInlineHTML() {
156 let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)
157
158 #expect(html.contains("<strong>Bold</strong>"))
159 #expect(html.contains(#"<a href="https://example.com">Link</a>"#))
160 }
161
162 @Test
163 func markdownProtectedTokensDoNotLeak() {
164 let html = processInline(#"<a href="https://example.com">Back to top</a> `yoshi [ARG] <FILE>`"#)
165
166 #expect(!html.contains("ZZPROTECTED"))
167 #expect(html.contains(#"<a href="https://example.com">Back to top</a>"#))
168 #expect(html.contains("<code>yoshi [ARG] &lt;FILE&gt;</code>"))
169 }
170}
171
172struct OrgRenderingTests {
173
174 @Test
175 func orgDeepHeading() {
176 let html = orgToHTML("**** Level 4 Heading")
177
178 #expect(html.contains("<h4>"))
179 }
180
181 @Test
182 func orgCommentLinesIgnored() {
183 let html = orgToHTML("# This is a comment\nNormal text")
184
185 #expect(!html.contains("This is a comment"))
186 #expect(html.contains("Normal text"))
187 }
188
189 @Test
190 func orgStrikethrough() {
191 let html = orgToHTML("+deleted text+")
192
193 #expect(html.contains("<del>"))
194 }
195
196 @Test
197 func orgExampleBlock() {
198 let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
199
200 #expect(html.contains("<pre><code>"))
201 #expect(html.contains("hello world"))
202 }
203
204 @Test
205 func orgTitleKeyword() {
206 let html = orgToHTML("#+TITLE: My Document\nBody text")
207
208 #expect(html.contains("org-title"))
209 #expect(html.contains("My Document"))
210 }
211
212 @Test
213 func orgItalicDoesNotMatchURLPaths() {
214 let html = orgToHTML("[[https://example.com/path/to/file]]")
215
216 #expect(!html.contains("<em>"))
217 }
218
219 @Test
220 func orgWrappedBulletNormalizesLines() {
221 let html = orgToHTML("- First line\n continues here")
222
223 #expect(html.contains("<li>First line continues here</li>"))
224 }
225}
226
227struct RepositoryAssetURLTests {
228
229 @Test
230 func repositoryAssetURLPercentEncodesImagePaths() {
231 let url = resolveRepositoryAssetURL(
232 "images/My Logo.png",
233 owner: "~ccleberg",
234 repositoryName: "Hutch",
235 readmePath: "README.md"
236 )
237
238 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
239 }
240}