Commit a8f630e659

a8f630e659dcdb9b8a7567400571f1f2d2353861

parent: dece66c32a

Unsigned ci/accessibility: success ci/sync-man-pages: success

cmc <hello@cleberg.net> · 2026-08-28 19:32 UTC

Remove the in-file Org renderer, now that OrgSwift owns it

Delete ~920 lines of duplicated org rendering (orgToHTML and its list/table/link/
image helpers) from ReadmeView.swift and the obsolete OrgRenderingTests suite. The
markdown path and shared helpers (sanitizers, resolvers, HTMLWebView) stay; OrgSwift
carries the org renderer and its own test suite and conformance corpus.

Layout: unified · split

Hutch/Views/Repositories/ReadmeView.swift −914
@@ -456,536 +456,6 @@ nonisolated func processInline(
456 return result 456 return result
457} 457}
458 458
459// MARK: - Org-mode to HTML
460
461nonisolated func orgToHTML(
462 _ text: String,
463 codeTheme: SyntaxHighlightTheme = .light,
464 imageURLResolver: ((String) -> String?)? = nil,
465 linkURLResolver: ((String) -> String?)? = nil
466) -> String {
467 let highlighter = SyntaxHighlighter(theme: codeTheme)
468 let normalizedText = text
469 .replacingOccurrences(of: "\r\n", with: "\n")
470 .replacingOccurrences(of: "\r", with: "\n")
471 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
472 var title: String?
473 var author: String?
474 var date: String?
475 let lines = rawLines.filter { line in
476 let trimmed = line.trimmingCharacters(in: .whitespaces)
477 guard let directive = orgKeywordDirective(in: trimmed) else {
478 return true
479 }
480 switch directive.keyword {
481 case "title":
482 title = directive.value
483 return false
484 case "author":
485 author = directive.value
486 return false
487 case "date":
488 date = directive.value
489 return false
490 default:
491 return true
492 }
493 }
494 var html = ""
495 var listType: OrgListType?
496 var inQuoteBlock = false
497 var inPropertyDrawer = false
498 var srcLanguage: String?
499 var srcLines: [String] = []
500 var inExampleBlock = false
501 var inCenterBlock = false
502 var inVerseBlock = false
503 var currentListItemLines: [String] = []
504 var paragraph: [String] = []
505 var tableRows: [[String]] = []
506 var propertyRows: [(String, String)] = []
507 var verseLines: [String] = []
508 var pendingBlockName: String?
509 var pendingBlockCaption: String?
510 var activeBlockCaption: String?
511 var isWrappingBlockFigure = false
512
513 func beginPendingBlockWrapperIfNeeded() {
514 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
515 let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
516 html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
517 activeBlockCaption = pendingBlockCaption
518 isWrappingBlockFigure = true
519 pendingBlockName = nil
520 pendingBlockCaption = nil
521 }
522
523 func closePendingBlockWrapper() {
524 guard isWrappingBlockFigure else { return }
525 if let activeBlockCaption {
526 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
527 }
528 html += "</figure>\n"
529 activeBlockCaption = nil
530 isWrappingBlockFigure = false
531 }
532
533 func flushParagraph() {
534 if !paragraph.isEmpty {
535 let normalizedParagraph = paragraph
536 .map { $0.trimmingCharacters(in: .whitespaces) }
537 .joined(separator: " ")
538 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
539 paragraph = []
540 }
541 }
542
543 func flushListItem() {
544 guard !currentListItemLines.isEmpty else { return }
545 html += "<li>" + renderOrgListItemBody(
546 currentListItemLines,
547 imageURLResolver: imageURLResolver,
548 linkURLResolver: linkURLResolver
549 ) + "</li>\n"
550 currentListItemLines = []
551 }
552
553 func closeList() {
554 flushListItem()
555 switch listType {
556 case .unordered:
557 html += "</ul>\n"
558 case .ordered:
559 html += "</ol>\n"
560 case nil:
561 break
562 }
563 listType = nil
564 }
565
566 func flushTable() {
567 guard !tableRows.isEmpty else { return }
568 beginPendingBlockWrapperIfNeeded()
569 html += renderHTMLTable(
570 rows: tableRows,
571 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
572 )
573 closePendingBlockWrapper()
574 tableRows = []
575 }
576
577 func flushPropertyDrawer() {
578 guard !propertyRows.isEmpty else { return }
579 html += "<dl class=\"org-properties\">\n"
580 for (key, value) in propertyRows {
581 html += "<dt>" + escapeHTML(key) + "</dt>"
582 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
583 }
584 html += "</dl>\n"
585 propertyRows = []
586 }
587
588 func closeQuoteBlock() {
589 if inQuoteBlock {
590 flushParagraph()
591 html += "</blockquote>\n"
592 inQuoteBlock = false
593 }
594 }
595
596 func closeSourceBlock() {
597 if let language = srcLanguage {
598 let code = srcLines.joined(separator: "\n")
599 if !code.isEmpty {
600 html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n"
601 }
602 html += "</code></pre>\n"
603 srcLanguage = nil
604 srcLines = []
605 closePendingBlockWrapper()
606 }
607 }
608
609 func closeExampleBlock() {
610 if inExampleBlock {
611 html += "</code></pre>\n"
612 inExampleBlock = false
613 closePendingBlockWrapper()
614 }
615 }
616
617 func closeCenterBlock() {
618 if inCenterBlock {
619 flushParagraph()
620 html += "</div>\n"
621 inCenterBlock = false
622 closePendingBlockWrapper()
623 }
624 }
625
626 func closeVerseBlock() {
627 if inVerseBlock {
628 let content = verseLines
629 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
630 .joined(separator: "\n")
631 html += #"<blockquote class="org-verse">"# + "\n"
632 html += content + "\n"
633 html += "</blockquote>\n"
634 verseLines = []
635 inVerseBlock = false
636 closePendingBlockWrapper()
637 }
638 }
639
640 func flushBlockState() {
641 flushParagraph()
642 closeList()
643 flushTable()
644 flushPropertyDrawer()
645 }
646
647 if title != nil || author != nil || date != nil {
648 html += "<div class=\"org-metadata\">\n"
649 if let title {
650 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
651 }
652 if let author {
653 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
654 }
655 if let date {
656 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
657 }
658 html += "</div>\n"
659 }
660
661 for line in lines {
662 let trimmed = line.trimmingCharacters(in: .whitespaces)
663
664 if srcLanguage != nil {
665 if trimmed.lowercased() == "#+end_src" {
666 closeSourceBlock()
667 } else {
668 srcLines.append(line)
669 }
670 continue
671 }
672
673 if inExampleBlock {
674 if trimmed.lowercased() == "#+end_example" {
675 closeExampleBlock()
676 } else {
677 html += escapeHTML(line) + "\n"
678 }
679 continue
680 }
681
682 if inVerseBlock {
683 if trimmed.lowercased() == "#+end_verse" {
684 closeVerseBlock()
685 } else {
686 verseLines.append(line)
687 }
688 continue
689 }
690
691 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
692 closeQuoteBlock()
693 continue
694 }
695
696 if inCenterBlock {
697 if trimmed.lowercased() == "#+end_center" {
698 closeCenterBlock()
699 } else if trimmed.isEmpty {
700 flushParagraph()
701 } else {
702 paragraph.append(line)
703 }
704 continue
705 }
706
707 if trimmed == "#" || trimmed.hasPrefix("# ") {
708 continue
709 }
710
711 if let directive = orgKeywordDirective(in: trimmed) {
712 switch directive.keyword {
713 case "caption":
714 pendingBlockCaption = directive.value
715 continue
716 case "name":
717 pendingBlockName = directive.value
718 continue
719 case "options", "property":
720 continue
721 default:
722 break
723 }
724 }
725
726 if trimmed.lowercased().hasPrefix("#+begin_src") {
727 closeQuoteBlock()
728 flushBlockState()
729 beginPendingBlockWrapperIfNeeded()
730 let language = trimmed
731 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
732 .dropFirst()
733 .first
734 .map(String.init)?
735 .trimmingCharacters(in: .whitespacesAndNewlines)
736 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
737 html += "<pre><code\(classAttribute)>"
738 srcLanguage = language ?? ""
739 srcLines = []
740 continue
741 }
742
743 if trimmed.lowercased() == "#+begin_example" {
744 closeQuoteBlock()
745 flushBlockState()
746 beginPendingBlockWrapperIfNeeded()
747 html += "<pre><code>"
748 inExampleBlock = true
749 continue
750 }
751
752 if trimmed.lowercased() == "#+begin_quote" {
753 flushBlockState()
754 beginPendingBlockWrapperIfNeeded()
755 html += "<blockquote>\n"
756 inQuoteBlock = true
757 continue
758 }
759
760 if trimmed.lowercased() == "#+begin_center" {
761 closeQuoteBlock()
762 flushBlockState()
763 beginPendingBlockWrapperIfNeeded()
764 html += "<div style=\"text-align:center\">\n"
765 inCenterBlock = true
766 continue
767 }
768
769 if trimmed.lowercased() == "#+begin_verse" {
770 closeQuoteBlock()
771 flushBlockState()
772 beginPendingBlockWrapperIfNeeded()
773 verseLines = []
774 inVerseBlock = true
775 continue
776 }
777
778 if trimmed == ":PROPERTIES:" {
779 closeQuoteBlock()
780 flushBlockState()
781 inPropertyDrawer = true
782 continue
783 }
784
785 if trimmed == ":END:", inPropertyDrawer {
786 flushPropertyDrawer()
787 inPropertyDrawer = false
788 continue
789 }
790
791 if inPropertyDrawer,
792 trimmed.hasPrefix(":"),
793 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
794 let keyStart = trimmed.index(after: trimmed.startIndex)
795 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
796 let valueStart = trimmed.index(after: secondColonIndex)
797 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
798 if !key.isEmpty {
799 propertyRows.append((key, value))
800 continue
801 }
802 }
803
804 if isTableLine(trimmed) {
805 closeQuoteBlock()
806 flushParagraph()
807 closeList()
808 tableRows.append(parseTableRow(trimmed))
809 continue
810 } else {
811 flushTable()
812 }
813
814 if isOrgHorizontalRule(trimmed) {
815 closeQuoteBlock()
816 flushBlockState()
817 html += "<hr>\n"
818 continue
819 }
820
821 // Org headings: * heading, ** heading, *** heading
822 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
823 closeQuoteBlock()
824 flushBlockState()
825 let level = match.1.count
826 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
827 html += "<h\(level)>" + content + "</h\(level)>\n"
828 continue
829 }
830
831 if listType != nil && isIndentedContinuationLine(line) {
832 currentListItemLines.append(line)
833 continue
834 }
835
836 // List items: - item
837 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
838 flushParagraph()
839 flushPropertyDrawer()
840 if listType != .unordered {
841 closeList()
842 html += "<ul>\n"
843 listType = .unordered
844 }
845 flushListItem()
846 currentListItemLines = [String(trimmed.dropFirst(2))]
847 continue
848 }
849
850 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
851 flushParagraph()
852 flushPropertyDrawer()
853 if listType != .ordered {
854 closeList()
855 html += "<ol>\n"
856 listType = .ordered
857 }
858 flushListItem()
859 currentListItemLines = [orderedItem]
860 continue
861 }
862
863 // Blank line
864 if trimmed.isEmpty {
865 if inQuoteBlock {
866 flushParagraph()
867 } else {
868 flushBlockState()
869 }
870 continue
871 }
872
873 // Regular text
874 if pendingBlockName != nil || pendingBlockCaption != nil {
875 pendingBlockName = nil
876 pendingBlockCaption = nil
877 }
878 paragraph.append(line)
879 }
880
881 closeSourceBlock()
882 closeExampleBlock()
883 closeCenterBlock()
884 closeVerseBlock()
885 closeQuoteBlock()
886 flushBlockState()
887
888 return html
889}
890
891nonisolated private func processOrgInline(
892 _ text: String,
893 imageURLResolver: ((String) -> String?)? = nil,
894 linkURLResolver: ((String) -> String?)? = nil
895) -> String {
896 var result = escapeHTML(text)
897 var protectedFragments: [String: String] = [:]
898
899 result = protectMatches(
900 in: result,
901 pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
902 protectedFragments: &protectedFragments
903 ) { match, nsText in
904 let destination = nsText.substring(with: match.range(at: 1))
905 let source = nsText.substring(with: match.range(at: 2))
906 guard let imageHTML = makeOrgImageHTML(
907 source: source,
908 alt: nil,
909 imageURLResolver: imageURLResolver
910 ) else {
911 return source
912 }
913 let resolvedDestination = linkURLResolver?(destination) ?? destination
914 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
915 return imageHTML
916 }
917 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
918 }
919
920 result = protectOrgLinks(
921 in: result,
922 protectedFragments: &protectedFragments,
923 imageURLResolver: imageURLResolver,
924 linkURLResolver: linkURLResolver
925 )
926 result = protectMatches(
927 in: result,
928 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
929 protectedFragments: &protectedFragments
930 ) { match, nsText in
931 let tildeRange = match.range(at: 1)
932 let equalsRange = match.range(at: 2)
933 let codeText: String
934 if tildeRange.location != NSNotFound {
935 codeText = nsText.substring(with: tildeRange)
936 } else {
937 codeText = nsText.substring(with: equalsRange)
938 }
939 return "<code>\(codeText)</code>"
940 }
941 result = protectMatches(
942 in: result,
943 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
944 protectedFragments: &protectedFragments
945 ) { match, nsText in
946 let value = nsText.substring(with: match.range(at: 1))
947 return "<del>\(value)</del>"
948 }
949 result = protectMatches(
950 in: result,
951 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
952 protectedFragments: &protectedFragments
953 ) { match, nsText in
954 let value = nsText.substring(with: match.range(at: 1))
955 return "<u>\(value)</u>"
956 }
957
958 // Bold: *text*
959 result = result.replacingOccurrences(
960 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
961 with: "<strong>$1</strong>",
962 options: .regularExpression
963 )
964 // Italic: /text/
965 result = result.replacingOccurrences(
966 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
967 with: "<em>$1</em>",
968 options: .regularExpression
969 )
970 result = replaceMatches(
971 in: result,
972 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
973 ) { match, nsText in
974 guard !isInsideHTMLTag(nsText, range: match.range) else {
975 return nsText.substring(with: match.range)
976 }
977 let email = nsText.substring(with: match.range(at: 1))
978 let href = escapeHTMLAttribute("mailto:\(email)")
979 return #"<a href="\#(href)">\#(email)</a>"#
980 }
981
982 for (token, fragment) in protectedFragments {
983 result = result.replacingOccurrences(of: token, with: fragment)
984 }
985
986 return result
987}
988
989// MARK: - HTML Escaping 459// MARK: - HTML Escaping
990 460
991nonisolated func escapeHTML(_ text: String) -> String { 461nonisolated func escapeHTML(_ text: String) -> String {
@@ -1050,330 +520,6 @@ nonisolated private func sanitizeReadmeURLString(
1050 return escapeHTMLAttribute(sanitizedURL) 520 return escapeHTMLAttribute(sanitizedURL)
1051} 521}
1052 522
1053nonisolated private func isTableLine(_ line: String) -> Bool {
1054 line.hasPrefix("|") && line.hasSuffix("|")
1055}
1056
1057nonisolated private func parseTableRow(_ line: String) -> [String] {
1058 line
1059 .split(separator: "|", omittingEmptySubsequences: false)
1060 .dropFirst()
1061 .dropLast()
1062 .map { String($0).trimmingCharacters(in: .whitespaces) }
1063}
1064
1065nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
1066 var content = line.trimmingCharacters(in: .whitespaces)
1067 if content.hasPrefix("|") {
1068 content.removeFirst()
1069 }
1070 if content.hasSuffix("|") {
1071 content.removeLast()
1072 }
1073 return content
1074 .split(separator: "+", omittingEmptySubsequences: false)
1075 .map { String($0).trimmingCharacters(in: .whitespaces) }
1076}
1077
1078nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
1079 tableAlignment(for: cell) != nil
1080}
1081
1082nonisolated private func tableAlignment(for cell: String) -> String? {
1083 let trimmed = cell.trimmingCharacters(in: .whitespaces)
1084 guard !trimmed.isEmpty else { return nil }
1085
1086 let core = trimmed.replacingOccurrences(of: ":", with: "")
1087 guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else {
1088 return nil
1089 }
1090
1091 let isLeftAligned = trimmed.hasPrefix(":")
1092 let isRightAligned = trimmed.hasSuffix(":")
1093 switch (isLeftAligned, isRightAligned) {
1094 case (true, true):
1095 return "center"
1096 case (true, false):
1097 return "left"
1098 case (false, true):
1099 return "right"
1100 case (false, false):
1101 return ""
1102 }
1103}
1104
1105nonisolated private func renderHTMLTable(
1106 rows: [[String]],
1107 inlineRenderer: (String) -> String
1108) -> String {
1109 guard !rows.isEmpty else { return "" }
1110 let separatorCells: [String]
1111 if rows.count > 1, rows[1].count == 1 {
1112 separatorCells = parseOrgTableSeparatorRow(rows[1][0])
1113 } else {
1114 separatorCells = rows.count > 1 ? rows[1] : []
1115 }
1116 let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell)
1117 let headerRow = rows.first ?? []
1118 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
1119 let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : []
1120 var html = "<table>\n"
1121
1122 if hasHeaderSeparator {
1123 html += "<thead><tr>"
1124 for (index, cell) in headerRow.enumerated() {
1125 html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>"
1126 }
1127 html += "</tr></thead>\n"
1128 }
1129
1130 html += "<tbody>\n"
1131 for row in bodyRows {
1132 html += "<tr>"
1133 for (index, cell) in row.enumerated() {
1134 html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>"
1135 }
1136 html += "</tr>\n"
1137 }
1138 html += "</tbody>\n"
1139 html += "</table>\n"
1140 return html
1141}
1142
1143nonisolated private func columnAlignment(at index: Int, in alignments: [String?]) -> String? {
1144 guard alignments.indices.contains(index) else { return nil }
1145 return alignments[index]
1146}
1147
1148nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1149 guard let alignment, !alignment.isEmpty else { return "" }
1150 return #" style="text-align: \#(alignment);""#
1151}
1152
1153private enum OrgListType: Equatable {
1154 case unordered
1155 case ordered
1156}
1157
1158nonisolated private func orderedListItem(in line: String) -> String? {
1159 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1160 return String(match.2)
1161}
1162
1163nonisolated private func renderOrgListItemBody(
1164 _ lines: [String],
1165 imageURLResolver: ((String) -> String?)? = nil,
1166 linkURLResolver: ((String) -> String?)? = nil
1167) -> String {
1168 guard let firstLine = lines.first else { return "" }
1169
1170 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1171 var nestedLines: [String] = []
1172
1173 for line in lines.dropFirst() {
1174 let trimmed = line.trimmingCharacters(in: .whitespaces)
1175 if trimmed.isEmpty {
1176 continue
1177 }
1178
1179 if isIndentedListItemLine(line) {
1180 nestedLines.append(outdentOrgListLine(line))
1181 } else {
1182 contentLines.append(trimmed)
1183 }
1184 }
1185
1186 var html = renderTaskListItem(
1187 contentLines.joined(separator: " "),
1188 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
1189 )
1190 if !nestedLines.isEmpty {
1191 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1192 }
1193 return html
1194}
1195
1196nonisolated private func renderNestedOrgListHTML(
1197 _ lines: [String],
1198 imageURLResolver: ((String) -> String?)? = nil,
1199 linkURLResolver: ((String) -> String?)? = nil
1200) -> String {
1201 var html = ""
1202 var listType: OrgListType?
1203 var currentItemLines: [String] = []
1204
1205 func flushNestedItem() {
1206 guard !currentItemLines.isEmpty else { return }
1207 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
1208 currentItemLines = []
1209 }
1210
1211 func closeNestedList() {
1212 flushNestedItem()
1213 switch listType {
1214 case .unordered:
1215 html += "</ul>\n"
1216 case .ordered:
1217 html += "</ol>\n"
1218 case nil:
1219 break
1220 }
1221 listType = nil
1222 }
1223
1224 for line in lines {
1225 let trimmed = line.trimmingCharacters(in: .whitespaces)
1226 if trimmed.hasPrefix("- ") {
1227 if listType != .unordered {
1228 closeNestedList()
1229 html += "<ul>\n"
1230 listType = .unordered
1231 }
1232 flushNestedItem()
1233 currentItemLines = [String(trimmed.dropFirst(2))]
1234 continue
1235 }
1236
1237 if let orderedItem = orderedListItem(in: trimmed) {
1238 if listType != .ordered {
1239 closeNestedList()
1240 html += "<ol>\n"
1241 listType = .ordered
1242 }
1243 flushNestedItem()
1244 currentItemLines = [orderedItem]
1245 continue
1246 }
1247
1248 if listType != nil {
1249 currentItemLines.append(line)
1250 }
1251 }
1252
1253 closeNestedList()
1254 return html
1255}
1256
1257nonisolated private func protectOrgLinks(
1258 in text: String,
1259 protectedFragments: inout [String: String],
1260 imageURLResolver: ((String) -> String?)? = nil,
1261 linkURLResolver: ((String) -> String?)? = nil
1262) -> String {
1263 var result = text
1264
1265 while let range = result.range(of: "[[") {
1266 guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1267 break
1268 }
1269 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1270 protectedFragments[token] = renderOrgLink(
1271 destination: parsed.destination,
1272 label: parsed.label,
1273 imageURLResolver: imageURLResolver,
1274 linkURLResolver: linkURLResolver
1275 )
1276 result.replaceSubrange(parsed.range, with: token)
1277 }
1278
1279 return result
1280}
1281
1282nonisolated private func parseOrgLink(
1283 in text: String,
1284 from start: String.Index
1285) -> (range: Range<String.Index>, destination: String, label: String?)? {
1286 guard text[start...].hasPrefix("[[") else { return nil }
1287
1288 var index = text.index(start, offsetBy: 2)
1289 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1290 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1291 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1292 }
1293
1294 let destination = String(text[index..<destinationEnd])
1295 index = text.index(destinationEnd, offsetBy: 2)
1296 let labelStart = index
1297 var depth = 0
1298
1299 while index < text.endIndex {
1300 if text[index...].hasPrefix("[[") {
1301 depth += 1
1302 index = text.index(index, offsetBy: 2)
1303 continue
1304 }
1305 if text[index...].hasPrefix("]]") {
1306 if depth == 0 {
1307 let end = text.index(index, offsetBy: 2)
1308 return (start..<end, destination, String(text[labelStart..<index]))
1309 }
1310 depth -= 1
1311 index = text.index(index, offsetBy: 2)
1312 continue
1313 }
1314 index = text.index(after: index)
1315 }
1316
1317 return nil
1318}
1319
1320nonisolated private func renderOrgLink(
1321 destination: String,
1322 label: String?,
1323 imageURLResolver: ((String) -> String?)? = nil,
1324 linkURLResolver: ((String) -> String?)? = nil
1325) -> String {
1326 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1327 let source = String(label.dropFirst(2).dropLast(2))
1328 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1329 let resolvedDestination = linkURLResolver?(destination) ?? destination
1330 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1331 return imageHTML
1332 }
1333 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1334 }
1335 }
1336
1337 if let imageHTML = makeOrgImageHTML(
1338 source: destination,
1339 alt: label,
1340 imageURLResolver: imageURLResolver
1341 ) {
1342 return imageHTML
1343 }
1344
1345 let resolvedDestination = linkURLResolver?(destination) ?? destination
1346 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1347 return label ?? destination
1348 }
1349
1350 let renderedLabel = label.map {
1351 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1352 } ?? destination
1353 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1354}
1355
1356nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1357 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1358 return nil
1359 }
1360 return (
1361 keyword: String(match.1).lowercased(),
1362 value: String(match.2).trimmingCharacters(in: .whitespaces)
1363 )
1364}
1365
1366
1367nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1368 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1369}
1370
1371nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1372 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1373 let range = NSRange(location: 0, length: (text as NSString).length)
1374 return regex.firstMatch(in: text, range: range) != nil
1375}
1376
1377nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { 523nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1378 guard range.location != NSNotFound else { return false } 524 guard range.location != NSNotFound else { return false }
1379 let prefix = text.substring(to: range.location) 525 let prefix = text.substring(to: range.location)
@@ -1382,26 +528,6 @@ nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bo
1382 return lastOpen > lastClose 528 return lastOpen > lastClose
1383} 529}
1384 530
1385nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1386 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1387 guard let first = line.first else { return false }
1388 return first == " " || first == "\t"
1389}
1390
1391nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1392 guard isIndentedContinuationLine(line) else { return false }
1393 let trimmed = line.trimmingCharacters(in: .whitespaces)
1394 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1395}
1396
1397nonisolated private func outdentOrgListLine(_ line: String) -> String {
1398 var result = line
1399 while result.first == " " || result.first == "\t" {
1400 result.removeFirst()
1401 }
1402 return result
1403}
1404
1405private extension Array { 531private extension Array {
1406 subscript(safe index: Int) -> Element? { 532 subscript(safe index: Int) -> Element? {
1407 guard indices.contains(index) else { return nil } 533 guard indices.contains(index) else { return nil }
@@ -1547,24 +673,6 @@ nonisolated private func replaceMatches(
1547 return result 673 return result
1548} 674}
1549 675
1550nonisolated private func makeOrgImageHTML(
1551 source: String,
1552 alt: String?,
1553 imageURLResolver: ((String) -> String?)?
1554) -> String? {
1555 guard isRenderableImageSource(source) else { return nil }
1556 let resolvedSource = imageURLResolver?(source) ?? source
1557 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1558 let altText = escapeHTMLAttribute(alt ?? "")
1559 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1560}
1561
1562nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1563 let lowercased = source.lowercased()
1564 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1565 .contains(where: { lowercased.hasSuffix($0) })
1566}
1567
1568nonisolated func resolveRepositoryLinkURL( 676nonisolated func resolveRepositoryLinkURL(
1569 _ source: String, 677 _ source: String,
1570 owner: String, 678 owner: String,
@@ -1645,28 +753,6 @@ nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1645 return components.joined(separator: "/") 753 return components.joined(separator: "/")
1646} 754}
1647 755
1648nonisolated private func renderTaskListItem(
1649 _ text: String,
1650 inlineRenderer: (String) -> String
1651) -> String {
1652 let trimmed = text.trimmingCharacters(in: .whitespaces)
1653 guard trimmed.count >= 4 else {
1654 return inlineRenderer(text)
1655 }
1656
1657 let prefix = String(trimmed.prefix(4))
1658 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1659
1660 switch prefix {
1661 case "[ ] ":
1662 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1663 case "[x] ", "[X] ":
1664 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1665 default:
1666 return inlineRenderer(text)
1667 }
1668}
1669
1670// MARK: - WKWebView Wrapper 756// MARK: - WKWebView Wrapper
1671 757
1672/// A WKWebView wrapper that renders HTML inline and grows to fit its content. 758/// A WKWebView wrapper that renders HTML inline and grows to fit its content.
HutchTests/ReadmeViewTests.swift −158
@@ -221,164 +221,6 @@ struct MarkdownRenderingTests {
221 } 221 }
222} 222}
223 223
224@MainActor
225struct OrgRenderingTests {
226
227 @Test
228 func orgDeepHeading() {
229 let html = orgToHTML("**** Level 4 Heading")
230
231 #expect(html.contains("<h4>"))
232 }
233
234 @Test
235 func orgCommentLinesIgnored() {
236 let html = orgToHTML("# This is a comment\nNormal text")
237
238 #expect(!html.contains("This is a comment"))
239 #expect(html.contains("Normal text"))
240 }
241
242 @Test
243 func orgStrikethrough() {
244 let html = orgToHTML("+deleted text+")
245
246 #expect(html.contains("<del>"))
247 }
248
249 @Test
250 func orgExampleBlock() {
251 let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
252
253 #expect(html.contains("<pre><code>"))
254 #expect(html.contains("hello world"))
255 }
256
257 @Test
258 func orgTitleKeyword() {
259 let html = orgToHTML("#+TITLE: My Document\nBody text")
260
261 #expect(html.contains("org-title"))
262 #expect(html.contains("My Document"))
263 }
264
265 @Test
266 func orgItalicDoesNotMatchURLPaths() {
267 let html = orgToHTML("[[https://example.com/path/to/file]]")
268
269 #expect(!html.contains("<em>"))
270 }
271
272 @Test
273 func orgWrappedBulletNormalizesLines() {
274 let html = orgToHTML("- First line\n continues here")
275
276 #expect(html.contains("<li>First line continues here</li>"))
277 }
278
279 @Test
280 func orgHeaderKeywordsIgnored() {
281 let html = orgToHTML("""
282 #+OPTIONS: toc:nil
283 #+PROPERTY: header-args :results output
284 Body text
285 """)
286
287 #expect(!html.contains("#+OPTIONS"))
288 #expect(!html.contains("#+PROPERTY"))
289 #expect(html.contains("<p>Body text</p>"))
290 }
291
292 @Test
293 func orgVerseBlock() {
294 let html = orgToHTML("""
295 #+begin_verse
296 There is a line.
297 And an indented line.
298 #+end_verse
299 """)
300
301 #expect(html.contains(#"<blockquote class="org-verse">"#))
302 #expect(html.contains("There is a line."))
303 #expect(html.contains("And an indented line."))
304 #expect(!html.contains("#+begin_verse"))
305 }
306
307 @Test
308 func orgNamedBlockRendersCaption() {
309 let html = orgToHTML("""
310 #+CAPTION: Build output
311 #+NAME: build-log
312 #+begin_example
313 hello world
314 #+end_example
315 """)
316
317 #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
318 #expect(html.contains("<figcaption>Build output</figcaption>"))
319 #expect(!html.contains("#+CAPTION"))
320 #expect(!html.contains("#+NAME"))
321 }
322
323 @Test
324 func orgLinkedImageRenders() {
325 let html = orgToHTML("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
326
327 #expect(html.contains(#"<a href="https://example.com">"#))
328 #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
329 }
330
331 @Test
332 func orgLinkedRelativeImageRenders() {
333 let html = orgToHTML(
334 "[[https://example.com/docs][[./images/badge.svg]]]",
335 imageURLResolver: { source in
336 source == "./images/badge.svg" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" : nil
337 }
338 )
339
340 #expect(html.contains(#"<a href="https://example.com/docs">"#))
341 #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
342 }
343
344 @Test
345 func orgNestedBulletListRendersNestedMarkup() {
346 let html = orgToHTML("""
347 * Lists
348 ** Unordered
349 - First bullet
350 - Second bullet
351 - Third bullet with /italic/ and *bold*
352 - Bullet with wrapped
353 continuation line
354 - Bullet with nested list
355 - Nested child one
356 - Nested child two
357 - Bullet with inline code ~let x = 1~
358 """)
359
360 #expect(html.contains("<ul>"))
361 #expect(html.contains("<li>Bullet with nested list\n<ul>"))
362 #expect(html.contains("<li>Nested child one</li>"))
363 #expect(html.contains("<li>Nested child two</li>"))
364 #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
365 }
366
367 @Test
368 func orgTableAlignmentRendersStyles() {
369 let html = orgToHTML("""
370 | Left | Center | Right |
371 |:-----+:-----:+------:|
372 | a | b | c |
373 | 1 | 2 | 3 |
374 """)
375
376 #expect(html.contains("text-align: left;"))
377 #expect(html.contains("text-align: center;"))
378 #expect(html.contains("text-align: right;"))
379 }
380}
381
382@MainActor 224@MainActor
383struct RepositoryAssetURLTests { 225struct RepositoryAssetURLTests {
384 226