Render Org READMEs with the shared OrgSwift package !2

merged merged by cmc on 2026-08-28 19:33 UTC · krz/hutch:org-swift into main

5 files changed, +46 −1095

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +17
@@ -23,6 +23,7 @@
23 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 23 8BE082012F80000100000001 /* HutchWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE081F62F80000100000001 /* HutchWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
24 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 24 8BE083012F81000100000001 /* HutchSafariExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 8BE083032F81000100000001 /* HutchSafariExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
25 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; }; 25 8BF100032F9A000100000001 /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = 8BF100022F9A000100000001 /* Markdown */; };
26 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */; };
26/* End PBXBuildFile section */ 27/* End PBXBuildFile section */
27 28
28/* Begin PBXContainerItemProxy section */ 29/* Begin PBXContainerItemProxy section */
@@ -174,6 +175,7 @@
174 files = ( 175 files = (
175 8BF100032F9A000100000001 /* Markdown in Frameworks */, 176 8BF100032F9A000100000001 /* Markdown in Frameworks */,
176 8BAA000000000000000000B0 /* Highlightr in Frameworks */, 177 8BAA000000000000000000B0 /* Highlightr in Frameworks */,
178 0F0F0F0F0F0F0F0F000000A3 /* OrgSwift in Frameworks */,
177 ); 179 );
178 runOnlyForDeploymentPostprocessing = 0; 180 runOnlyForDeploymentPostprocessing = 0;
179 }; 181 };
@@ -325,6 +327,7 @@
325 packageProductDependencies = ( 327 packageProductDependencies = (
326 8BF100022F9A000100000001 /* Markdown */, 328 8BF100022F9A000100000001 /* Markdown */,
327 8BAA000000000000000000B2 /* Highlightr */, 329 8BAA000000000000000000B2 /* Highlightr */,
330 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */,
328 ); 331 );
329 productName = Hutch; 332 productName = Hutch;
330 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */; 333 productReference = 8B4B28D12F6704280045FA19 /* Hutch.app */;
@@ -426,6 +429,7 @@
426 packageReferences = ( 429 packageReferences = (
427 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */, 430 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */,
428 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */, 431 8BAA000000000000000000B3 /* XCRemoteSwiftPackageReference "Highlightr" */,
432 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */,
429 ); 433 );
430 preferredProjectObjectVersion = 77; 434 preferredProjectObjectVersion = 77;
431 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */; 435 productRefGroup = 8B4B28D22F6704280045FA19 /* Products */;
@@ -1019,6 +1023,14 @@
1019 minimumVersion = 0.8.0; 1023 minimumVersion = 0.8.0;
1020 }; 1024 };
1021 }; 1025 };
1026 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */ = {
1027 isa = XCRemoteSwiftPackageReference;
1028 repositoryURL = "ssh://git@gitbay.org/krz/org-swift.git";
1029 requirement = {
1030 branch = main;
1031 kind = branch;
1032 };
1033 };
1022/* End XCRemoteSwiftPackageReference section */ 1034/* End XCRemoteSwiftPackageReference section */
1023 1035
1024/* Begin XCSwiftPackageProductDependency section */ 1036/* Begin XCSwiftPackageProductDependency section */
@@ -1032,6 +1044,11 @@
1032 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */; 1044 package = 8BF100012F9A000100000001 /* XCRemoteSwiftPackageReference "swift-markdown" */;
1033 productName = Markdown; 1045 productName = Markdown;
1034 }; 1046 };
1047 0F0F0F0F0F0F0F0F000000A2 /* OrgSwift */ = {
1048 isa = XCSwiftPackageProductDependency;
1049 package = 0F0F0F0F0F0F0F0F000000A1 /* XCRemoteSwiftPackageReference "org-swift" */;
1050 productName = OrgSwift;
1051 };
1035/* End XCSwiftPackageProductDependency section */ 1052/* End XCSwiftPackageProductDependency section */
1036 }; 1053 };
1037 rootObject = 8B4B28C92F6704280045FA19 /* Project object */; 1054 rootObject = 8B4B28C92F6704280045FA19 /* Project object */;
Hutch.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +10 −1
@@ -1,5 +1,5 @@
1{ 1{
2 "originHash" : "4554417ca2693020116bce7cbda415a5e51ada6b4cc4a8b39f7f43a9644bb5e5", 2 "originHash" : "161ab13c39311c33c8ab987c4e00f5238e1b905e4b929b88f0ff7103a6dc20e4",
3 "pins" : [ 3 "pins" : [
4 { 4 {
5 "identity" : "highlightr", 5 "identity" : "highlightr",
@@ -10,6 +10,15 @@
10 "version" : "2.3.0" 10 "version" : "2.3.0"
11 } 11 }
12 }, 12 },
13 {
14 "identity" : "org-swift",
15 "kind" : "remoteSourceControl",
16 "location" : "ssh://git@gitbay.org/krz/org-swift.git",
17 "state" : {
18 "branch" : "main",
19 "revision" : "47ac630c479b79dbfa8944c91b22b7bb6974cadc"
20 }
21 },
13 { 22 {
14 "identity" : "swift-cmark", 23 "identity" : "swift-cmark",
15 "kind" : "remoteSourceControl", 24 "kind" : "remoteSourceControl",
Hutch/Views/Repositories/ReadmeView.swift +12 −935
@@ -1,3 +1,4 @@
1import OrgSwift
1import SwiftUI 2import SwiftUI
2import WebKit 3import WebKit
3 4
@@ -314,29 +315,19 @@ struct RenderedMarkupContentView: View {
314 return 315 return
315 } 316 }
316 let theme = SyntaxHighlightTheme(colorScheme: colorScheme) 317 let theme = SyntaxHighlightTheme(colorScheme: colorScheme)
318 let host = repositoryHost
319 let owner = ownerCanonicalName
320 let repo = repositoryName
321 let path = readmePath
317 let html = await Task.detached(priority: .userInitiated) { 322 let html = await Task.detached(priority: .userInitiated) {
318 orgToHTML( 323 let options = OrgRenderOptions(
319 text, 324 host: host,
320 codeTheme: theme, 325 owner: owner,
321 imageURLResolver: { source in 326 repositoryName: repo,
322 resolveRepositoryAssetURL( 327 ref: "HEAD",
323 source, 328 readmePath: path
324 owner: ownerCanonicalName,
325 repositoryName: repositoryName,
326 readmePath: readmePath
327 )?
328 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
329 },
330 linkURLResolver: { source in
331 resolveRepositoryLinkURL(
332 source,
333 owner: ownerCanonicalName,
334 repositoryName: repositoryName,
335 readmePath: readmePath
336 )?
337 .replacingOccurrences(of: "git.sr.ht", with: repositoryHost)
338 }
339 ) 329 )
330 return OrgRenderer.renderToHTML(text, options: options, highlighter: SyntaxHighlighter(theme: theme))
340 }.value 331 }.value
341 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey) 332 RenderedReadmeHTMLCache.shared.setHTML(html, forKey: cacheKey)
342 guard !Task.isCancelled else { return } 333 guard !Task.isCancelled else { return }
@@ -465,536 +456,6 @@ nonisolated func processInline(
465 return result 456 return result
466} 457}
467 458
468// MARK: - Org-mode to HTML
469
470nonisolated func orgToHTML(
471 _ text: String,
472 codeTheme: SyntaxHighlightTheme = .light,
473 imageURLResolver: ((String) -> String?)? = nil,
474 linkURLResolver: ((String) -> String?)? = nil
475) -> String {
476 let highlighter = SyntaxHighlighter(theme: codeTheme)
477 let normalizedText = text
478 .replacingOccurrences(of: "\r\n", with: "\n")
479 .replacingOccurrences(of: "\r", with: "\n")
480 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
481 var title: String?
482 var author: String?
483 var date: String?
484 let lines = rawLines.filter { line in
485 let trimmed = line.trimmingCharacters(in: .whitespaces)
486 guard let directive = orgKeywordDirective(in: trimmed) else {
487 return true
488 }
489 switch directive.keyword {
490 case "title":
491 title = directive.value
492 return false
493 case "author":
494 author = directive.value
495 return false
496 case "date":
497 date = directive.value
498 return false
499 default:
500 return true
501 }
502 }
503 var html = ""
504 var listType: OrgListType?
505 var inQuoteBlock = false
506 var inPropertyDrawer = false
507 var srcLanguage: String?
508 var srcLines: [String] = []
509 var inExampleBlock = false
510 var inCenterBlock = false
511 var inVerseBlock = false
512 var currentListItemLines: [String] = []
513 var paragraph: [String] = []
514 var tableRows: [[String]] = []
515 var propertyRows: [(String, String)] = []
516 var verseLines: [String] = []
517 var pendingBlockName: String?
518 var pendingBlockCaption: String?
519 var activeBlockCaption: String?
520 var isWrappingBlockFigure = false
521
522 func beginPendingBlockWrapperIfNeeded() {
523 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
524 let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
525 html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
526 activeBlockCaption = pendingBlockCaption
527 isWrappingBlockFigure = true
528 pendingBlockName = nil
529 pendingBlockCaption = nil
530 }
531
532 func closePendingBlockWrapper() {
533 guard isWrappingBlockFigure else { return }
534 if let activeBlockCaption {
535 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</figcaption>\n"
536 }
537 html += "</figure>\n"
538 activeBlockCaption = nil
539 isWrappingBlockFigure = false
540 }
541
542 func flushParagraph() {
543 if !paragraph.isEmpty {
544 let normalizedParagraph = paragraph
545 .map { $0.trimmingCharacters(in: .whitespaces) }
546 .joined(separator: " ")
547 html += "<p>" + processOrgInline(normalizedParagraph, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</p>\n"
548 paragraph = []
549 }
550 }
551
552 func flushListItem() {
553 guard !currentListItemLines.isEmpty else { return }
554 html += "<li>" + renderOrgListItemBody(
555 currentListItemLines,
556 imageURLResolver: imageURLResolver,
557 linkURLResolver: linkURLResolver
558 ) + "</li>\n"
559 currentListItemLines = []
560 }
561
562 func closeList() {
563 flushListItem()
564 switch listType {
565 case .unordered:
566 html += "</ul>\n"
567 case .ordered:
568 html += "</ol>\n"
569 case nil:
570 break
571 }
572 listType = nil
573 }
574
575 func flushTable() {
576 guard !tableRows.isEmpty else { return }
577 beginPendingBlockWrapperIfNeeded()
578 html += renderHTMLTable(
579 rows: tableRows,
580 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
581 )
582 closePendingBlockWrapper()
583 tableRows = []
584 }
585
586 func flushPropertyDrawer() {
587 guard !propertyRows.isEmpty else { return }
588 html += "<dl class=\"org-properties\">\n"
589 for (key, value) in propertyRows {
590 html += "<dt>" + escapeHTML(key) + "</dt>"
591 html += "<dd>" + processOrgInline(value, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</dd>\n"
592 }
593 html += "</dl>\n"
594 propertyRows = []
595 }
596
597 func closeQuoteBlock() {
598 if inQuoteBlock {
599 flushParagraph()
600 html += "</blockquote>\n"
601 inQuoteBlock = false
602 }
603 }
604
605 func closeSourceBlock() {
606 if let language = srcLanguage {
607 let code = srcLines.joined(separator: "\n")
608 if !code.isEmpty {
609 html += (highlighter.highlightedHTML(for: code, language: language) ?? escapeHTML(code)) + "\n"
610 }
611 html += "</code></pre>\n"
612 srcLanguage = nil
613 srcLines = []
614 closePendingBlockWrapper()
615 }
616 }
617
618 func closeExampleBlock() {
619 if inExampleBlock {
620 html += "</code></pre>\n"
621 inExampleBlock = false
622 closePendingBlockWrapper()
623 }
624 }
625
626 func closeCenterBlock() {
627 if inCenterBlock {
628 flushParagraph()
629 html += "</div>\n"
630 inCenterBlock = false
631 closePendingBlockWrapper()
632 }
633 }
634
635 func closeVerseBlock() {
636 if inVerseBlock {
637 let content = verseLines
638 .map { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
639 .joined(separator: "\n")
640 html += #"<blockquote class="org-verse">"# + "\n"
641 html += content + "\n"
642 html += "</blockquote>\n"
643 verseLines = []
644 inVerseBlock = false
645 closePendingBlockWrapper()
646 }
647 }
648
649 func flushBlockState() {
650 flushParagraph()
651 closeList()
652 flushTable()
653 flushPropertyDrawer()
654 }
655
656 if title != nil || author != nil || date != nil {
657 html += "<div class=\"org-metadata\">\n"
658 if let title {
659 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
660 }
661 if let author {
662 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
663 }
664 if let date {
665 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
666 }
667 html += "</div>\n"
668 }
669
670 for line in lines {
671 let trimmed = line.trimmingCharacters(in: .whitespaces)
672
673 if srcLanguage != nil {
674 if trimmed.lowercased() == "#+end_src" {
675 closeSourceBlock()
676 } else {
677 srcLines.append(line)
678 }
679 continue
680 }
681
682 if inExampleBlock {
683 if trimmed.lowercased() == "#+end_example" {
684 closeExampleBlock()
685 } else {
686 html += escapeHTML(line) + "\n"
687 }
688 continue
689 }
690
691 if inVerseBlock {
692 if trimmed.lowercased() == "#+end_verse" {
693 closeVerseBlock()
694 } else {
695 verseLines.append(line)
696 }
697 continue
698 }
699
700 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
701 closeQuoteBlock()
702 continue
703 }
704
705 if inCenterBlock {
706 if trimmed.lowercased() == "#+end_center" {
707 closeCenterBlock()
708 } else if trimmed.isEmpty {
709 flushParagraph()
710 } else {
711 paragraph.append(line)
712 }
713 continue
714 }
715
716 if trimmed == "#" || trimmed.hasPrefix("# ") {
717 continue
718 }
719
720 if let directive = orgKeywordDirective(in: trimmed) {
721 switch directive.keyword {
722 case "caption":
723 pendingBlockCaption = directive.value
724 continue
725 case "name":
726 pendingBlockName = directive.value
727 continue
728 case "options", "property":
729 continue
730 default:
731 break
732 }
733 }
734
735 if trimmed.lowercased().hasPrefix("#+begin_src") {
736 closeQuoteBlock()
737 flushBlockState()
738 beginPendingBlockWrapperIfNeeded()
739 let language = trimmed
740 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
741 .dropFirst()
742 .first
743 .map(String.init)?
744 .trimmingCharacters(in: .whitespacesAndNewlines)
745 let classAttribute = language.map { " class=\"language-\(escapeHTMLAttribute($0))\"" } ?? ""
746 html += "<pre><code\(classAttribute)>"
747 srcLanguage = language ?? ""
748 srcLines = []
749 continue
750 }
751
752 if trimmed.lowercased() == "#+begin_example" {
753 closeQuoteBlock()
754 flushBlockState()
755 beginPendingBlockWrapperIfNeeded()
756 html += "<pre><code>"
757 inExampleBlock = true
758 continue
759 }
760
761 if trimmed.lowercased() == "#+begin_quote" {
762 flushBlockState()
763 beginPendingBlockWrapperIfNeeded()
764 html += "<blockquote>\n"
765 inQuoteBlock = true
766 continue
767 }
768
769 if trimmed.lowercased() == "#+begin_center" {
770 closeQuoteBlock()
771 flushBlockState()
772 beginPendingBlockWrapperIfNeeded()
773 html += "<div style=\"text-align:center\">\n"
774 inCenterBlock = true
775 continue
776 }
777
778 if trimmed.lowercased() == "#+begin_verse" {
779 closeQuoteBlock()
780 flushBlockState()
781 beginPendingBlockWrapperIfNeeded()
782 verseLines = []
783 inVerseBlock = true
784 continue
785 }
786
787 if trimmed == ":PROPERTIES:" {
788 closeQuoteBlock()
789 flushBlockState()
790 inPropertyDrawer = true
791 continue
792 }
793
794 if trimmed == ":END:", inPropertyDrawer {
795 flushPropertyDrawer()
796 inPropertyDrawer = false
797 continue
798 }
799
800 if inPropertyDrawer,
801 trimmed.hasPrefix(":"),
802 let secondColonIndex = trimmed.dropFirst().firstIndex(of: ":") {
803 let keyStart = trimmed.index(after: trimmed.startIndex)
804 let key = String(trimmed[keyStart..<secondColonIndex]).trimmingCharacters(in: .whitespaces)
805 let valueStart = trimmed.index(after: secondColonIndex)
806 let value = String(trimmed[valueStart...]).trimmingCharacters(in: .whitespaces)
807 if !key.isEmpty {
808 propertyRows.append((key, value))
809 continue
810 }
811 }
812
813 if isTableLine(trimmed) {
814 closeQuoteBlock()
815 flushParagraph()
816 closeList()
817 tableRows.append(parseTableRow(trimmed))
818 continue
819 } else {
820 flushTable()
821 }
822
823 if isOrgHorizontalRule(trimmed) {
824 closeQuoteBlock()
825 flushBlockState()
826 html += "<hr>\n"
827 continue
828 }
829
830 // Org headings: * heading, ** heading, *** heading
831 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
832 closeQuoteBlock()
833 flushBlockState()
834 let level = match.1.count
835 let content = processOrgInline(String(match.2), imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
836 html += "<h\(level)>" + content + "</h\(level)>\n"
837 continue
838 }
839
840 if listType != nil && isIndentedContinuationLine(line) {
841 currentListItemLines.append(line)
842 continue
843 }
844
845 // List items: - item
846 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
847 flushParagraph()
848 flushPropertyDrawer()
849 if listType != .unordered {
850 closeList()
851 html += "<ul>\n"
852 listType = .unordered
853 }
854 flushListItem()
855 currentListItemLines = [String(trimmed.dropFirst(2))]
856 continue
857 }
858
859 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
860 flushParagraph()
861 flushPropertyDrawer()
862 if listType != .ordered {
863 closeList()
864 html += "<ol>\n"
865 listType = .ordered
866 }
867 flushListItem()
868 currentListItemLines = [orderedItem]
869 continue
870 }
871
872 // Blank line
873 if trimmed.isEmpty {
874 if inQuoteBlock {
875 flushParagraph()
876 } else {
877 flushBlockState()
878 }
879 continue
880 }
881
882 // Regular text
883 if pendingBlockName != nil || pendingBlockCaption != nil {
884 pendingBlockName = nil
885 pendingBlockCaption = nil
886 }
887 paragraph.append(line)
888 }
889
890 closeSourceBlock()
891 closeExampleBlock()
892 closeCenterBlock()
893 closeVerseBlock()
894 closeQuoteBlock()
895 flushBlockState()
896
897 return html
898}
899
900nonisolated private func processOrgInline(
901 _ text: String,
902 imageURLResolver: ((String) -> String?)? = nil,
903 linkURLResolver: ((String) -> String?)? = nil
904) -> String {
905 var result = escapeHTML(text)
906 var protectedFragments: [String: String] = [:]
907
908 result = protectMatches(
909 in: result,
910 pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
911 protectedFragments: &protectedFragments
912 ) { match, nsText in
913 let destination = nsText.substring(with: match.range(at: 1))
914 let source = nsText.substring(with: match.range(at: 2))
915 guard let imageHTML = makeOrgImageHTML(
916 source: source,
917 alt: nil,
918 imageURLResolver: imageURLResolver
919 ) else {
920 return source
921 }
922 let resolvedDestination = linkURLResolver?(destination) ?? destination
923 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
924 return imageHTML
925 }
926 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
927 }
928
929 result = protectOrgLinks(
930 in: result,
931 protectedFragments: &protectedFragments,
932 imageURLResolver: imageURLResolver,
933 linkURLResolver: linkURLResolver
934 )
935 result = protectMatches(
936 in: result,
937 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
938 protectedFragments: &protectedFragments
939 ) { match, nsText in
940 let tildeRange = match.range(at: 1)
941 let equalsRange = match.range(at: 2)
942 let codeText: String
943 if tildeRange.location != NSNotFound {
944 codeText = nsText.substring(with: tildeRange)
945 } else {
946 codeText = nsText.substring(with: equalsRange)
947 }
948 return "<code>\(codeText)</code>"
949 }
950 result = protectMatches(
951 in: result,
952 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
953 protectedFragments: &protectedFragments
954 ) { match, nsText in
955 let value = nsText.substring(with: match.range(at: 1))
956 return "<del>\(value)</del>"
957 }
958 result = protectMatches(
959 in: result,
960 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
961 protectedFragments: &protectedFragments
962 ) { match, nsText in
963 let value = nsText.substring(with: match.range(at: 1))
964 return "<u>\(value)</u>"
965 }
966
967 // Bold: *text*
968 result = result.replacingOccurrences(
969 of: #"(?<!\S)\*(.+?)\*(?=\s|$|[.,;:!?])"#,
970 with: "<strong>$1</strong>",
971 options: .regularExpression
972 )
973 // Italic: /text/
974 result = result.replacingOccurrences(
975 of: #"(?<!\S)/(.+?)/(?=\s|$|[.,;:!?])"#,
976 with: "<em>$1</em>",
977 options: .regularExpression
978 )
979 result = replaceMatches(
980 in: result,
981 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
982 ) { match, nsText in
983 guard !isInsideHTMLTag(nsText, range: match.range) else {
984 return nsText.substring(with: match.range)
985 }
986 let email = nsText.substring(with: match.range(at: 1))
987 let href = escapeHTMLAttribute("mailto:\(email)")
988 return #"<a href="\#(href)">\#(email)</a>"#
989 }
990
991 for (token, fragment) in protectedFragments {
992 result = result.replacingOccurrences(of: token, with: fragment)
993 }
994
995 return result
996}
997
998// MARK: - HTML Escaping 459// MARK: - HTML Escaping
999 460
1000nonisolated func escapeHTML(_ text: String) -> String { 461nonisolated func escapeHTML(_ text: String) -> String {
@@ -1059,330 +520,6 @@ nonisolated private func sanitizeReadmeURLString(
1059 return escapeHTMLAttribute(sanitizedURL) 520 return escapeHTMLAttribute(sanitizedURL)
1060} 521}
1061 522
1062nonisolated private func isTableLine(_ line: String) -> Bool {
1063 line.hasPrefix("|") && line.hasSuffix("|")
1064}
1065
1066nonisolated private func parseTableRow(_ line: String) -> [String] {
1067 line
1068 .split(separator: "|", omittingEmptySubsequences: false)
1069 .dropFirst()
1070 .dropLast()
1071 .map { String($0).trimmingCharacters(in: .whitespaces) }
1072}
1073
1074nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
1075 var content = line.trimmingCharacters(in: .whitespaces)
1076 if content.hasPrefix("|") {
1077 content.removeFirst()
1078 }
1079 if content.hasSuffix("|") {
1080 content.removeLast()
1081 }
1082 return content
1083 .split(separator: "+", omittingEmptySubsequences: false)
1084 .map { String($0).trimmingCharacters(in: .whitespaces) }
1085}
1086
1087nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
1088 tableAlignment(for: cell) != nil
1089}
1090
1091nonisolated private func tableAlignment(for cell: String) -> String? {
1092 let trimmed = cell.trimmingCharacters(in: .whitespaces)
1093 guard !trimmed.isEmpty else { return nil }
1094
1095 let core = trimmed.replacingOccurrences(of: ":", with: "")
1096 guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else {
1097 return nil
1098 }
1099
1100 let isLeftAligned = trimmed.hasPrefix(":")
1101 let isRightAligned = trimmed.hasSuffix(":")
1102 switch (isLeftAligned, isRightAligned) {
1103 case (true, true):
1104 return "center"
1105 case (true, false):
1106 return "left"
1107 case (false, true):
1108 return "right"
1109 case (false, false):
1110 return ""
1111 }
1112}
1113
1114nonisolated private func renderHTMLTable(
1115 rows: [[String]],
1116 inlineRenderer: (String) -> String
1117) -> String {
1118 guard !rows.isEmpty else { return "" }
1119 let separatorCells: [String]
1120 if rows.count > 1, rows[1].count == 1 {
1121 separatorCells = parseOrgTableSeparatorRow(rows[1][0])
1122 } else {
1123 separatorCells = rows.count > 1 ? rows[1] : []
1124 }
1125 let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell)
1126 let headerRow = rows.first ?? []
1127 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
1128 let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : []
1129 var html = "<table>\n"
1130
1131 if hasHeaderSeparator {
1132 html += "<thead><tr>"
1133 for (index, cell) in headerRow.enumerated() {
1134 html += "<th" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</th>"
1135 }
1136 html += "</tr></thead>\n"
1137 }
1138
1139 html += "<tbody>\n"
1140 for row in bodyRows {
1141 html += "<tr>"
1142 for (index, cell) in row.enumerated() {
1143 html += "<td" + tableAlignmentStyleAttribute(columnAlignment(at: index, in: columnAlignments)) + ">" + inlineRenderer(cell) + "</td>"
1144 }
1145 html += "</tr>\n"
1146 }
1147 html += "</tbody>\n"
1148 html += "</table>\n"
1149 return html
1150}
1151
1152nonisolated private func columnAlignment(at index: Int, in alignments: [String?]) -> String? {
1153 guard alignments.indices.contains(index) else { return nil }
1154 return alignments[index]
1155}
1156
1157nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1158 guard let alignment, !alignment.isEmpty else { return "" }
1159 return #" style="text-align: \#(alignment);""#
1160}
1161
1162private enum OrgListType: Equatable {
1163 case unordered
1164 case ordered
1165}
1166
1167nonisolated private func orderedListItem(in line: String) -> String? {
1168 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
1169 return String(match.2)
1170}
1171
1172nonisolated private func renderOrgListItemBody(
1173 _ lines: [String],
1174 imageURLResolver: ((String) -> String?)? = nil,
1175 linkURLResolver: ((String) -> String?)? = nil
1176) -> String {
1177 guard let firstLine = lines.first else { return "" }
1178
1179 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1180 var nestedLines: [String] = []
1181
1182 for line in lines.dropFirst() {
1183 let trimmed = line.trimmingCharacters(in: .whitespaces)
1184 if trimmed.isEmpty {
1185 continue
1186 }
1187
1188 if isIndentedListItemLine(line) {
1189 nestedLines.append(outdentOrgListLine(line))
1190 } else {
1191 contentLines.append(trimmed)
1192 }
1193 }
1194
1195 var html = renderTaskListItem(
1196 contentLines.joined(separator: " "),
1197 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) }
1198 )
1199 if !nestedLines.isEmpty {
1200 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1201 }
1202 return html
1203}
1204
1205nonisolated private func renderNestedOrgListHTML(
1206 _ lines: [String],
1207 imageURLResolver: ((String) -> String?)? = nil,
1208 linkURLResolver: ((String) -> String?)? = nil
1209) -> String {
1210 var html = ""
1211 var listType: OrgListType?
1212 var currentItemLines: [String] = []
1213
1214 func flushNestedItem() {
1215 guard !currentItemLines.isEmpty else { return }
1216 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver) + "</li>\n"
1217 currentItemLines = []
1218 }
1219
1220 func closeNestedList() {
1221 flushNestedItem()
1222 switch listType {
1223 case .unordered:
1224 html += "</ul>\n"
1225 case .ordered:
1226 html += "</ol>\n"
1227 case nil:
1228 break
1229 }
1230 listType = nil
1231 }
1232
1233 for line in lines {
1234 let trimmed = line.trimmingCharacters(in: .whitespaces)
1235 if trimmed.hasPrefix("- ") {
1236 if listType != .unordered {
1237 closeNestedList()
1238 html += "<ul>\n"
1239 listType = .unordered
1240 }
1241 flushNestedItem()
1242 currentItemLines = [String(trimmed.dropFirst(2))]
1243 continue
1244 }
1245
1246 if let orderedItem = orderedListItem(in: trimmed) {
1247 if listType != .ordered {
1248 closeNestedList()
1249 html += "<ol>\n"
1250 listType = .ordered
1251 }
1252 flushNestedItem()
1253 currentItemLines = [orderedItem]
1254 continue
1255 }
1256
1257 if listType != nil {
1258 currentItemLines.append(line)
1259 }
1260 }
1261
1262 closeNestedList()
1263 return html
1264}
1265
1266nonisolated private func protectOrgLinks(
1267 in text: String,
1268 protectedFragments: inout [String: String],
1269 imageURLResolver: ((String) -> String?)? = nil,
1270 linkURLResolver: ((String) -> String?)? = nil
1271) -> String {
1272 var result = text
1273
1274 while let range = result.range(of: "[[") {
1275 guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1276 break
1277 }
1278 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1279 protectedFragments[token] = renderOrgLink(
1280 destination: parsed.destination,
1281 label: parsed.label,
1282 imageURLResolver: imageURLResolver,
1283 linkURLResolver: linkURLResolver
1284 )
1285 result.replaceSubrange(parsed.range, with: token)
1286 }
1287
1288 return result
1289}
1290
1291nonisolated private func parseOrgLink(
1292 in text: String,
1293 from start: String.Index
1294) -> (range: Range<String.Index>, destination: String, label: String?)? {
1295 guard text[start...].hasPrefix("[[") else { return nil }
1296
1297 var index = text.index(start, offsetBy: 2)
1298 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1299 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1300 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1301 }
1302
1303 let destination = String(text[index..<destinationEnd])
1304 index = text.index(destinationEnd, offsetBy: 2)
1305 let labelStart = index
1306 var depth = 0
1307
1308 while index < text.endIndex {
1309 if text[index...].hasPrefix("[[") {
1310 depth += 1
1311 index = text.index(index, offsetBy: 2)
1312 continue
1313 }
1314 if text[index...].hasPrefix("]]") {
1315 if depth == 0 {
1316 let end = text.index(index, offsetBy: 2)
1317 return (start..<end, destination, String(text[labelStart..<index]))
1318 }
1319 depth -= 1
1320 index = text.index(index, offsetBy: 2)
1321 continue
1322 }
1323 index = text.index(after: index)
1324 }
1325
1326 return nil
1327}
1328
1329nonisolated private func renderOrgLink(
1330 destination: String,
1331 label: String?,
1332 imageURLResolver: ((String) -> String?)? = nil,
1333 linkURLResolver: ((String) -> String?)? = nil
1334) -> String {
1335 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1336 let source = String(label.dropFirst(2).dropLast(2))
1337 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1338 let resolvedDestination = linkURLResolver?(destination) ?? destination
1339 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1340 return imageHTML
1341 }
1342 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1343 }
1344 }
1345
1346 if let imageHTML = makeOrgImageHTML(
1347 source: destination,
1348 alt: label,
1349 imageURLResolver: imageURLResolver
1350 ) {
1351 return imageHTML
1352 }
1353
1354 let resolvedDestination = linkURLResolver?(destination) ?? destination
1355 guard let sanitizedURL = sanitizedReadmeLinkURLString(resolvedDestination) else {
1356 return label ?? destination
1357 }
1358
1359 let renderedLabel = label.map {
1360 processOrgInline($0, imageURLResolver: imageURLResolver, linkURLResolver: linkURLResolver)
1361 } ?? destination
1362 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1363}
1364
1365nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1366 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1367 return nil
1368 }
1369 return (
1370 keyword: String(match.1).lowercased(),
1371 value: String(match.2).trimmingCharacters(in: .whitespaces)
1372 )
1373}
1374
1375
1376nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1377 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1378}
1379
1380nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1381 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1382 let range = NSRange(location: 0, length: (text as NSString).length)
1383 return regex.firstMatch(in: text, range: range) != nil
1384}
1385
1386nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool { 523nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1387 guard range.location != NSNotFound else { return false } 524 guard range.location != NSNotFound else { return false }
1388 let prefix = text.substring(to: range.location) 525 let prefix = text.substring(to: range.location)
@@ -1391,26 +528,6 @@ nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bo
1391 return lastOpen > lastClose 528 return lastOpen > lastClose
1392} 529}
1393 530
1394nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1395 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1396 guard let first = line.first else { return false }
1397 return first == " " || first == "\t"
1398}
1399
1400nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1401 guard isIndentedContinuationLine(line) else { return false }
1402 let trimmed = line.trimmingCharacters(in: .whitespaces)
1403 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1404}
1405
1406nonisolated private func outdentOrgListLine(_ line: String) -> String {
1407 var result = line
1408 while result.first == " " || result.first == "\t" {
1409 result.removeFirst()
1410 }
1411 return result
1412}
1413
1414private extension Array { 531private extension Array {
1415 subscript(safe index: Int) -> Element? { 532 subscript(safe index: Int) -> Element? {
1416 guard indices.contains(index) else { return nil } 533 guard indices.contains(index) else { return nil }
@@ -1556,24 +673,6 @@ nonisolated private func replaceMatches(
1556 return result 673 return result
1557} 674}
1558 675
1559nonisolated private func makeOrgImageHTML(
1560 source: String,
1561 alt: String?,
1562 imageURLResolver: ((String) -> String?)?
1563) -> String? {
1564 guard isRenderableImageSource(source) else { return nil }
1565 let resolvedSource = imageURLResolver?(source) ?? source
1566 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
1567 let altText = escapeHTMLAttribute(alt ?? "")
1568 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
1569}
1570
1571nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
1572 let lowercased = source.lowercased()
1573 return [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".heic"]
1574 .contains(where: { lowercased.hasSuffix($0) })
1575}
1576
1577nonisolated func resolveRepositoryLinkURL( 676nonisolated func resolveRepositoryLinkURL(
1578 _ source: String, 677 _ source: String,
1579 owner: String, 678 owner: String,
@@ -1654,28 +753,6 @@ nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
1654 return components.joined(separator: "/") 753 return components.joined(separator: "/")
1655} 754}
1656 755
1657nonisolated private func renderTaskListItem(
1658 _ text: String,
1659 inlineRenderer: (String) -> String
1660) -> String {
1661 let trimmed = text.trimmingCharacters(in: .whitespaces)
1662 guard trimmed.count >= 4 else {
1663 return inlineRenderer(text)
1664 }
1665
1666 let prefix = String(trimmed.prefix(4))
1667 let remainder = String(trimmed.dropFirst(4)).trimmingCharacters(in: .whitespaces)
1668
1669 switch prefix {
1670 case "[ ] ":
1671 return #"<span class="task-list-item"><input type="checkbox" disabled> \#(inlineRenderer(remainder))</span>"#
1672 case "[x] ", "[X] ":
1673 return #"<span class="task-list-item"><input type="checkbox" checked disabled> \#(inlineRenderer(remainder))</span>"#
1674 default:
1675 return inlineRenderer(text)
1676 }
1677}
1678
1679// MARK: - WKWebView Wrapper 756// MARK: - WKWebView Wrapper
1680 757
1681/// 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.
Hutch/Views/Repositories/SyntaxHighlighter.swift +7 −1
@@ -1,5 +1,6 @@
1import Foundation 1import Foundation
2import Highlightr 2import Highlightr
3import OrgSwift
3import SwiftUI 4import SwiftUI
4import UIKit 5import UIKit
5 6
@@ -31,7 +32,7 @@ nonisolated enum SyntaxHighlightTheme {
31/// instance must stay on the thread/task that created it. Callers that fail to 32/// instance must stay on the thread/task that created it. Callers that fail to
32/// resolve a language — or hit an unavailable engine — get `nil` and should 33/// resolve a language — or hit an unavailable engine — get `nil` and should
33/// fall back to plain, escaped text. 34/// fall back to plain, escaped text.
34nonisolated final class SyntaxHighlighter { 35nonisolated final class SyntaxHighlighter: CodeHighlighter {
35 private let highlightr: Highlightr? 36 private let highlightr: Highlightr?
36 private let supportedLanguages: Set<String> 37 private let supportedLanguages: Set<String>
37 38
@@ -60,6 +61,11 @@ nonisolated final class SyntaxHighlighter {
60 return Self.html(from: attributed) 61 return Self.html(from: attributed)
61 } 62 }
62 63
64 /// `OrgSwift.CodeHighlighter` conformance — delegates to the existing method.
65 func highlightedHTML(code: String, language: String?) -> String? {
66 highlightedHTML(for: code, language: language)
67 }
68
63 private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular) 69 private static let htmlMeasurementFont = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
64 70
65 private func resolvedLanguage(_ raw: String?) -> String? { 71 private func resolvedLanguage(_ raw: String?) -> String? {
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