Commit 481c39f230

481c39f2304ad1d5f60d9b61899fc34c21505f98

parent: 505bd429eb

Unsigned

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

fix(org): improve org-mode rendering support

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -465,7 +465,7 @@
465 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 465 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
466 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 466 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
467 CODE_SIGN_STYLE = Automatic; 467 CODE_SIGN_STYLE = Automatic;
468 CURRENT_PROJECT_VERSION = 12; 468 CURRENT_PROJECT_VERSION = 13;
469 DEVELOPMENT_TEAM = ZCNAX3VL9D; 469 DEVELOPMENT_TEAM = ZCNAX3VL9D;
470 ENABLE_PREVIEWS = YES; 470 ENABLE_PREVIEWS = YES;
471 GENERATE_INFOPLIST_FILE = YES; 471 GENERATE_INFOPLIST_FILE = YES;
@@ -482,7 +482,7 @@
482 "$(inherited)", 482 "$(inherited)",
483 "@executable_path/Frameworks", 483 "@executable_path/Frameworks",
484 ); 484 );
485 MARKETING_VERSION = 2.5.0; 485 MARKETING_VERSION = 2.5.1;
486 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 486 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
487 PRODUCT_NAME = "$(TARGET_NAME)"; 487 PRODUCT_NAME = "$(TARGET_NAME)";
488 STRING_CATALOG_GENERATE_SYMBOLS = YES; 488 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -502,7 +502,7 @@
502 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 502 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
503 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 503 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
504 CODE_SIGN_STYLE = Automatic; 504 CODE_SIGN_STYLE = Automatic;
505 CURRENT_PROJECT_VERSION = 12; 505 CURRENT_PROJECT_VERSION = 13;
506 DEVELOPMENT_TEAM = ZCNAX3VL9D; 506 DEVELOPMENT_TEAM = ZCNAX3VL9D;
507 ENABLE_PREVIEWS = YES; 507 ENABLE_PREVIEWS = YES;
508 GENERATE_INFOPLIST_FILE = YES; 508 GENERATE_INFOPLIST_FILE = YES;
@@ -519,7 +519,7 @@
519 "$(inherited)", 519 "$(inherited)",
520 "@executable_path/Frameworks", 520 "@executable_path/Frameworks",
521 ); 521 );
522 MARKETING_VERSION = 2.5.0; 522 MARKETING_VERSION = 2.5.1;
523 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 523 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
524 PRODUCT_NAME = "$(TARGET_NAME)"; 524 PRODUCT_NAME = "$(TARGET_NAME)";
525 STRING_CATALOG_GENERATE_SYMBOLS = YES; 525 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -582,7 +582,7 @@
582 APPLICATION_EXTENSION_API_ONLY = YES; 582 APPLICATION_EXTENSION_API_ONLY = YES;
583 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 583 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
584 CODE_SIGN_STYLE = Automatic; 584 CODE_SIGN_STYLE = Automatic;
585 CURRENT_PROJECT_VERSION = 12; 585 CURRENT_PROJECT_VERSION = 13;
586 DEVELOPMENT_TEAM = ZCNAX3VL9D; 586 DEVELOPMENT_TEAM = ZCNAX3VL9D;
587 GENERATE_INFOPLIST_FILE = NO; 587 GENERATE_INFOPLIST_FILE = NO;
588 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 588 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -592,7 +592,7 @@
592 "@executable_path/Frameworks", 592 "@executable_path/Frameworks",
593 "@executable_path/../../Frameworks", 593 "@executable_path/../../Frameworks",
594 ); 594 );
595 MARKETING_VERSION = 2.4.2; 595 MARKETING_VERSION = 2.5.1;
596 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 596 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
597 PRODUCT_NAME = "$(TARGET_NAME)"; 597 PRODUCT_NAME = "$(TARGET_NAME)";
598 SKIP_INSTALL = YES; 598 SKIP_INSTALL = YES;
@@ -611,7 +611,7 @@
611 APPLICATION_EXTENSION_API_ONLY = YES; 611 APPLICATION_EXTENSION_API_ONLY = YES;
612 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 612 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
613 CODE_SIGN_STYLE = Automatic; 613 CODE_SIGN_STYLE = Automatic;
614 CURRENT_PROJECT_VERSION = 12; 614 CURRENT_PROJECT_VERSION = 13;
615 DEVELOPMENT_TEAM = ZCNAX3VL9D; 615 DEVELOPMENT_TEAM = ZCNAX3VL9D;
616 GENERATE_INFOPLIST_FILE = NO; 616 GENERATE_INFOPLIST_FILE = NO;
617 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 617 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -621,7 +621,7 @@
621 "@executable_path/Frameworks", 621 "@executable_path/Frameworks",
622 "@executable_path/../../Frameworks", 622 "@executable_path/../../Frameworks",
623 ); 623 );
624 MARKETING_VERSION = 2.4.2; 624 MARKETING_VERSION = 2.5.1;
625 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 625 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
626 PRODUCT_NAME = "$(TARGET_NAME)"; 626 PRODUCT_NAME = "$(TARGET_NAME)";
627 SKIP_INSTALL = YES; 627 SKIP_INSTALL = YES;
Hutch/Views/Repositories/ReadmeView.swift +393 −54
@@ -371,20 +371,18 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
371 var date: String? 371 var date: String?
372 let lines = rawLines.filter { line in 372 let lines = rawLines.filter { line in
373 let trimmed = line.trimmingCharacters(in: .whitespaces) 373 let trimmed = line.trimmingCharacters(in: .whitespaces)
374 guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else { 374 guard let directive = orgKeywordDirective(in: trimmed) else {
375 return true 375 return true
376 } 376 }
377 let keyword = String(keywordMatch.1).lowercased() 377 switch directive.keyword {
378 let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
379 switch keyword {
380 case "title": 378 case "title":
381 title = value 379 title = directive.value
382 return false 380 return false
383 case "author": 381 case "author":
384 author = value 382 author = directive.value
385 return false 383 return false
386 case "date": 384 case "date":
387 date = value 385 date = directive.value
388 return false 386 return false
389 default: 387 default:
390 return true 388 return true
@@ -397,10 +395,36 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
397 var srcLanguage: String? 395 var srcLanguage: String?
398 var inExampleBlock = false 396 var inExampleBlock = false
399 var inCenterBlock = false 397 var inCenterBlock = false
398 var inVerseBlock = false
400 var currentListItemLines: [String] = [] 399 var currentListItemLines: [String] = []
401 var paragraph: [String] = [] 400 var paragraph: [String] = []
402 var tableRows: [[String]] = [] 401 var tableRows: [[String]] = []
403 var propertyRows: [(String, String)] = [] 402 var propertyRows: [(String, String)] = []
403 var verseLines: [String] = []
404 var pendingBlockName: String?
405 var pendingBlockCaption: String?
406 var activeBlockCaption: String?
407 var isWrappingBlockFigure = false
408
409 func beginPendingBlockWrapperIfNeeded() {
410 guard pendingBlockName != nil || pendingBlockCaption != nil else { return }
411 let idAttribute = pendingBlockName.map { #" id="\#(escapeHTMLAttribute($0))""# } ?? ""
412 html += #"<figure class="org-block"\#(idAttribute)>"# + "\n"
413 activeBlockCaption = pendingBlockCaption
414 isWrappingBlockFigure = true
415 pendingBlockName = nil
416 pendingBlockCaption = nil
417 }
418
419 func closePendingBlockWrapper() {
420 guard isWrappingBlockFigure else { return }
421 if let activeBlockCaption {
422 html += "<figcaption>" + processOrgInline(activeBlockCaption, imageURLResolver: imageURLResolver) + "</figcaption>\n"
423 }
424 html += "</figure>\n"
425 activeBlockCaption = nil
426 isWrappingBlockFigure = false
427 }
404 428
405 func flushParagraph() { 429 func flushParagraph() {
406 if !paragraph.isEmpty { 430 if !paragraph.isEmpty {
@@ -414,12 +438,9 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
414 438
415 func flushListItem() { 439 func flushListItem() {
416 guard !currentListItemLines.isEmpty else { return } 440 guard !currentListItemLines.isEmpty else { return }
417 let content = currentListItemLines 441 html += "<li>" + renderOrgListItemBody(
418 .map { $0.trimmingCharacters(in: .whitespaces) } 442 currentListItemLines,
419 .joined(separator: " ") 443 imageURLResolver: imageURLResolver
420 html += "<li>" + renderTaskListItem(
421 content,
422 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
423 ) + "</li>\n" 444 ) + "</li>\n"
424 currentListItemLines = [] 445 currentListItemLines = []
425 } 446 }
@@ -439,10 +460,12 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
439 460
440 func flushTable() { 461 func flushTable() {
441 guard !tableRows.isEmpty else { return } 462 guard !tableRows.isEmpty else { return }
463 beginPendingBlockWrapperIfNeeded()
442 html += renderHTMLTable( 464 html += renderHTMLTable(
443 rows: tableRows, 465 rows: tableRows,
444 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } 466 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
445 ) 467 )
468 closePendingBlockWrapper()
446 tableRows = [] 469 tableRows = []
447 } 470 }
448 471
@@ -469,6 +492,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
469 if srcLanguage != nil { 492 if srcLanguage != nil {
470 html += "</code></pre>\n" 493 html += "</code></pre>\n"
471 srcLanguage = nil 494 srcLanguage = nil
495 closePendingBlockWrapper()
472 } 496 }
473 } 497 }
474 498
@@ -476,6 +500,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
476 if inExampleBlock { 500 if inExampleBlock {
477 html += "</code></pre>\n" 501 html += "</code></pre>\n"
478 inExampleBlock = false 502 inExampleBlock = false
503 closePendingBlockWrapper()
479 } 504 }
480 } 505 }
481 506
@@ -484,6 +509,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
484 flushParagraph() 509 flushParagraph()
485 html += "</div>\n" 510 html += "</div>\n"
486 inCenterBlock = false 511 inCenterBlock = false
512 closePendingBlockWrapper()
513 }
514 }
515
516 func closeVerseBlock() {
517 if inVerseBlock {
518 let content = verseLines
519 .map { processOrgInline($0, imageURLResolver: imageURLResolver) }
520 .joined(separator: "\n")
521 html += #"<blockquote class="org-verse">"# + "\n"
522 html += content + "\n"
523 html += "</blockquote>\n"
524 verseLines = []
525 inVerseBlock = false
526 closePendingBlockWrapper()
487 } 527 }
488 } 528 }
489 529
@@ -529,6 +569,15 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
529 continue 569 continue
530 } 570 }
531 571
572 if inVerseBlock {
573 if trimmed.lowercased() == "#+end_verse" {
574 closeVerseBlock()
575 } else {
576 verseLines.append(line)
577 }
578 continue
579 }
580
532 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { 581 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
533 closeQuoteBlock() 582 closeQuoteBlock()
534 continue 583 continue
@@ -549,9 +598,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
549 continue 598 continue
550 } 599 }
551 600
601 if let directive = orgKeywordDirective(in: trimmed) {
602 switch directive.keyword {
603 case "caption":
604 pendingBlockCaption = directive.value
605 continue
606 case "name":
607 pendingBlockName = directive.value
608 continue
609 case "options", "property":
610 continue
611 default:
612 break
613 }
614 }
615
552 if trimmed.lowercased().hasPrefix("#+begin_src") { 616 if trimmed.lowercased().hasPrefix("#+begin_src") {
553 closeQuoteBlock() 617 closeQuoteBlock()
554 flushBlockState() 618 flushBlockState()
619 beginPendingBlockWrapperIfNeeded()
555 let language = trimmed 620 let language = trimmed
556 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) 621 .split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
557 .dropFirst() 622 .dropFirst()
@@ -567,6 +632,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
567 if trimmed.lowercased() == "#+begin_example" { 632 if trimmed.lowercased() == "#+begin_example" {
568 closeQuoteBlock() 633 closeQuoteBlock()
569 flushBlockState() 634 flushBlockState()
635 beginPendingBlockWrapperIfNeeded()
570 html += "<pre><code>" 636 html += "<pre><code>"
571 inExampleBlock = true 637 inExampleBlock = true
572 continue 638 continue
@@ -574,6 +640,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
574 640
575 if trimmed.lowercased() == "#+begin_quote" { 641 if trimmed.lowercased() == "#+begin_quote" {
576 flushBlockState() 642 flushBlockState()
643 beginPendingBlockWrapperIfNeeded()
577 html += "<blockquote>\n" 644 html += "<blockquote>\n"
578 inQuoteBlock = true 645 inQuoteBlock = true
579 continue 646 continue
@@ -582,11 +649,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
582 if trimmed.lowercased() == "#+begin_center" { 649 if trimmed.lowercased() == "#+begin_center" {
583 closeQuoteBlock() 650 closeQuoteBlock()
584 flushBlockState() 651 flushBlockState()
652 beginPendingBlockWrapperIfNeeded()
585 html += "<div style=\"text-align:center\">\n" 653 html += "<div style=\"text-align:center\">\n"
586 inCenterBlock = true 654 inCenterBlock = true
587 continue 655 continue
588 } 656 }
589 657
658 if trimmed.lowercased() == "#+begin_verse" {
659 closeQuoteBlock()
660 flushBlockState()
661 beginPendingBlockWrapperIfNeeded()
662 verseLines = []
663 inVerseBlock = true
664 continue
665 }
666
590 if trimmed == ":PROPERTIES:" { 667 if trimmed == ":PROPERTIES:" {
591 closeQuoteBlock() 668 closeQuoteBlock()
592 flushBlockState() 669 flushBlockState()
@@ -640,8 +717,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
640 continue 717 continue
641 } 718 }
642 719
720 if listType != nil && isIndentedContinuationLine(line) {
721 currentListItemLines.append(line)
722 continue
723 }
724
643 // List items: - item 725 // List items: - item
644 if trimmed.hasPrefix("- ") { 726 if !isIndentedContinuationLine(line), trimmed.hasPrefix("- ") {
645 flushParagraph() 727 flushParagraph()
646 flushPropertyDrawer() 728 flushPropertyDrawer()
647 if listType != .unordered { 729 if listType != .unordered {
@@ -654,7 +736,7 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
654 continue 736 continue
655 } 737 }
656 738
657 if let orderedItem = orderedListItem(in: trimmed) { 739 if !isIndentedContinuationLine(line), let orderedItem = orderedListItem(in: trimmed) {
658 flushParagraph() 740 flushParagraph()
659 flushPropertyDrawer() 741 flushPropertyDrawer()
660 if listType != .ordered { 742 if listType != .ordered {
@@ -667,11 +749,6 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
667 continue 749 continue
668 } 750 }
669 751
670 if listType != nil && isIndentedContinuationLine(line) {
671 currentListItemLines.append(trimmed)
672 continue
673 }
674
675 // Blank line 752 // Blank line
676 if trimmed.isEmpty { 753 if trimmed.isEmpty {
677 if inQuoteBlock { 754 if inQuoteBlock {
@@ -683,12 +760,17 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
683 } 760 }
684 761
685 // Regular text 762 // Regular text
763 if pendingBlockName != nil || pendingBlockCaption != nil {
764 pendingBlockName = nil
765 pendingBlockCaption = nil
766 }
686 paragraph.append(line) 767 paragraph.append(line)
687 } 768 }
688 769
689 closeSourceBlock() 770 closeSourceBlock()
690 closeExampleBlock() 771 closeExampleBlock()
691 closeCenterBlock() 772 closeCenterBlock()
773 closeVerseBlock()
692 closeQuoteBlock() 774 closeQuoteBlock()
693 flushBlockState() 775 flushBlockState()
694 776
@@ -701,41 +783,25 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
701 783
702 result = protectMatches( 784 result = protectMatches(
703 in: result, 785 in: result,
704 pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, 786 pattern: #"\[\[([^\]]+)\]\[\[([^\]]+)\]\]\]"#,
705 protectedFragments: &protectedFragments
706 ) { match, nsText in
707 let url = nsText.substring(with: match.range(at: 1))
708 let label = nsText.substring(with: match.range(at: 2))
709 if let imageHTML = makeOrgImageHTML(
710 source: url,
711 alt: label,
712 imageURLResolver: imageURLResolver
713 ) {
714 return imageHTML
715 }
716 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else {
717 return label
718 }
719 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
720 }
721 result = protectMatches(
722 in: result,
723 pattern: #"\[\[([^\]]+)\]\]"#,
724 protectedFragments: &protectedFragments 787 protectedFragments: &protectedFragments
725 ) { match, nsText in 788 ) { match, nsText in
726 let url = nsText.substring(with: match.range(at: 1)) 789 let destination = nsText.substring(with: match.range(at: 1))
727 if let imageHTML = makeOrgImageHTML( 790 let source = nsText.substring(with: match.range(at: 2))
728 source: url, 791 guard let imageHTML = makeOrgImageHTML(
792 source: source,
729 alt: nil, 793 alt: nil,
730 imageURLResolver: imageURLResolver 794 imageURLResolver: imageURLResolver
731 ) { 795 ) else {
732 return imageHTML 796 return source
733 } 797 }
734 guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { 798 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
735 return url 799 return imageHTML
736 } 800 }
737 return #"<a href="\#(sanitizedURL)">\#(url)</a>"# 801 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
738 } 802 }
803
804 result = protectOrgLinks(in: result, protectedFragments: &protectedFragments, imageURLResolver: imageURLResolver)
739 result = protectMatches( 805 result = protectMatches(
740 in: result, 806 in: result,
741 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#, 807 pattern: #"(?<!\S)~(.+?)~(?=\s|$|[.,;:!?])|(?<!\S)=(.+?)=(?=\s|$|[.,;:!?])"#,
@@ -875,9 +941,44 @@ nonisolated private func parseTableRow(_ line: String) -> [String] {
875 .map { String($0).trimmingCharacters(in: .whitespaces) } 941 .map { String($0).trimmingCharacters(in: .whitespaces) }
876} 942}
877 943
944nonisolated private func parseOrgTableSeparatorRow(_ line: String) -> [String] {
945 var content = line.trimmingCharacters(in: .whitespaces)
946 if content.hasPrefix("|") {
947 content.removeFirst()
948 }
949 if content.hasSuffix("|") {
950 content.removeLast()
951 }
952 return content
953 .split(separator: "+", omittingEmptySubsequences: false)
954 .map { String($0).trimmingCharacters(in: .whitespaces) }
955}
956
878nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { 957nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
958 tableAlignment(for: cell) != nil
959}
960
961nonisolated private func tableAlignment(for cell: String) -> String? {
879 let trimmed = cell.trimmingCharacters(in: .whitespaces) 962 let trimmed = cell.trimmingCharacters(in: .whitespaces)
880 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } 963 guard !trimmed.isEmpty else { return nil }
964
965 let core = trimmed.replacingOccurrences(of: ":", with: "")
966 guard !core.isEmpty, core.allSatisfy({ $0 == "-" || $0 == "+" }) else {
967 return nil
968 }
969
970 let isLeftAligned = trimmed.hasPrefix(":")
971 let isRightAligned = trimmed.hasSuffix(":")
972 switch (isLeftAligned, isRightAligned) {
973 case (true, true):
974 return "center"
975 case (true, false):
976 return "left"
977 case (false, true):
978 return "right"
979 case (false, false):
980 return ""
981 }
881} 982}
882 983
883nonisolated private func renderHTMLTable( 984nonisolated private func renderHTMLTable(
@@ -885,15 +986,22 @@ nonisolated private func renderHTMLTable(
885 inlineRenderer: (String) -> String 986 inlineRenderer: (String) -> String
886) -> String { 987) -> String {
887 guard !rows.isEmpty else { return "" } 988 guard !rows.isEmpty else { return "" }
888 let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell) 989 let separatorCells: [String]
990 if rows.count > 1, rows[1].count == 1 {
991 separatorCells = parseOrgTableSeparatorRow(rows[1][0])
992 } else {
993 separatorCells = rows.count > 1 ? rows[1] : []
994 }
995 let hasHeaderSeparator = rows.count > 1 && !separatorCells.isEmpty && separatorCells.allSatisfy(isTableSeparatorCell)
889 let headerRow = rows.first ?? [] 996 let headerRow = rows.first ?? []
890 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows 997 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
998 let columnAlignments = hasHeaderSeparator ? separatorCells.map(tableAlignment) : []
891 var html = "<table>\n" 999 var html = "<table>\n"
892 1000
893 if hasHeaderSeparator { 1001 if hasHeaderSeparator {
894 html += "<thead><tr>" 1002 html += "<thead><tr>"
895 for cell in headerRow { 1003 for (index, cell) in headerRow.enumerated() {
896 html += "<th>" + inlineRenderer(cell) + "</th>" 1004 html += "<th" + tableAlignmentStyleAttribute(columnAlignments[safe: index] ?? nil) + ">" + inlineRenderer(cell) + "</th>"
897 } 1005 }
898 html += "</tr></thead>\n" 1006 html += "</tr></thead>\n"
899 } 1007 }
@@ -901,8 +1009,8 @@ nonisolated private func renderHTMLTable(
901 html += "<tbody>\n" 1009 html += "<tbody>\n"
902 for row in bodyRows { 1010 for row in bodyRows {
903 html += "<tr>" 1011 html += "<tr>"
904 for cell in row { 1012 for (index, cell) in row.enumerated() {
905 html += "<td>" + inlineRenderer(cell) + "</td>" 1013 html += "<td" + tableAlignmentStyleAttribute(columnAlignments[safe: index] ?? nil) + ">" + inlineRenderer(cell) + "</td>"
906 } 1014 }
907 html += "</tr>\n" 1015 html += "</tr>\n"
908 } 1016 }
@@ -911,6 +1019,11 @@ nonisolated private func renderHTMLTable(
911 return html 1019 return html
912} 1020}
913 1021
1022nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String {
1023 guard let alignment, !alignment.isEmpty else { return "" }
1024 return #" style="text-align: \#(alignment);""#
1025}
1026
914private enum OrgListType: Equatable { 1027private enum OrgListType: Equatable {
915 case unordered 1028 case unordered
916 case ordered 1029 case ordered
@@ -921,6 +1034,200 @@ nonisolated private func orderedListItem(in line: String) -> String? {
921 return String(match.2) 1034 return String(match.2)
922} 1035}
923 1036
1037nonisolated private func renderOrgListItemBody(
1038 _ lines: [String],
1039 imageURLResolver: ((String) -> String?)? = nil
1040) -> String {
1041 guard let firstLine = lines.first else { return "" }
1042
1043 var contentLines: [String] = [firstLine.trimmingCharacters(in: .whitespaces)]
1044 var nestedLines: [String] = []
1045
1046 for line in lines.dropFirst() {
1047 let trimmed = line.trimmingCharacters(in: .whitespaces)
1048 if trimmed.isEmpty {
1049 continue
1050 }
1051
1052 if isIndentedListItemLine(line) {
1053 nestedLines.append(outdentOrgListLine(line))
1054 } else {
1055 contentLines.append(trimmed)
1056 }
1057 }
1058
1059 var html = renderTaskListItem(
1060 contentLines.joined(separator: " "),
1061 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
1062 )
1063 if !nestedLines.isEmpty {
1064 html += "\n" + renderNestedOrgListHTML(nestedLines, imageURLResolver: imageURLResolver)
1065 }
1066 return html
1067}
1068
1069nonisolated private func renderNestedOrgListHTML(
1070 _ lines: [String],
1071 imageURLResolver: ((String) -> String?)? = nil
1072) -> String {
1073 var html = ""
1074 var listType: OrgListType?
1075 var currentItemLines: [String] = []
1076
1077 func flushNestedItem() {
1078 guard !currentItemLines.isEmpty else { return }
1079 html += "<li>" + renderOrgListItemBody(currentItemLines, imageURLResolver: imageURLResolver) + "</li>\n"
1080 currentItemLines = []
1081 }
1082
1083 func closeNestedList() {
1084 flushNestedItem()
1085 switch listType {
1086 case .unordered:
1087 html += "</ul>\n"
1088 case .ordered:
1089 html += "</ol>\n"
1090 case nil:
1091 break
1092 }
1093 listType = nil
1094 }
1095
1096 for line in lines {
1097 let trimmed = line.trimmingCharacters(in: .whitespaces)
1098 if trimmed.hasPrefix("- ") {
1099 if listType != .unordered {
1100 closeNestedList()
1101 html += "<ul>\n"
1102 listType = .unordered
1103 }
1104 flushNestedItem()
1105 currentItemLines = [String(trimmed.dropFirst(2))]
1106 continue
1107 }
1108
1109 if let orderedItem = orderedListItem(in: trimmed) {
1110 if listType != .ordered {
1111 closeNestedList()
1112 html += "<ol>\n"
1113 listType = .ordered
1114 }
1115 flushNestedItem()
1116 currentItemLines = [orderedItem]
1117 continue
1118 }
1119
1120 if listType != nil {
1121 currentItemLines.append(line)
1122 }
1123 }
1124
1125 closeNestedList()
1126 return html
1127}
1128
1129nonisolated private func protectOrgLinks(
1130 in text: String,
1131 protectedFragments: inout [String: String],
1132 imageURLResolver: ((String) -> String?)? = nil
1133) -> String {
1134 var result = text
1135
1136 while let range = result.range(of: "[[") {
1137 guard let parsed = parseOrgLink(in: result, from: range.lowerBound) else {
1138 break
1139 }
1140 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
1141 protectedFragments[token] = renderOrgLink(
1142 destination: parsed.destination,
1143 label: parsed.label,
1144 imageURLResolver: imageURLResolver
1145 )
1146 result.replaceSubrange(parsed.range, with: token)
1147 }
1148
1149 return result
1150}
1151
1152nonisolated private func parseOrgLink(
1153 in text: String,
1154 from start: String.Index
1155) -> (range: Range<String.Index>, destination: String, label: String?)? {
1156 guard text[start...].hasPrefix("[[") else { return nil }
1157
1158 var index = text.index(start, offsetBy: 2)
1159 guard let destinationEnd = text[index...].range(of: "][" )?.lowerBound else {
1160 guard let end = text[index...].range(of: "]]")?.lowerBound else { return nil }
1161 return (start..<text.index(end, offsetBy: 2), String(text[index..<end]), nil)
1162 }
1163
1164 let destination = String(text[index..<destinationEnd])
1165 index = text.index(destinationEnd, offsetBy: 2)
1166 let labelStart = index
1167 var depth = 0
1168
1169 while index < text.endIndex {
1170 if text[index...].hasPrefix("[[") {
1171 depth += 1
1172 index = text.index(index, offsetBy: 2)
1173 continue
1174 }
1175 if text[index...].hasPrefix("]]") {
1176 if depth == 0 {
1177 let end = text.index(index, offsetBy: 2)
1178 return (start..<end, destination, String(text[labelStart..<index]))
1179 }
1180 depth -= 1
1181 index = text.index(index, offsetBy: 2)
1182 continue
1183 }
1184 index = text.index(after: index)
1185 }
1186
1187 return nil
1188}
1189
1190nonisolated private func renderOrgLink(
1191 destination: String,
1192 label: String?,
1193 imageURLResolver: ((String) -> String?)? = nil
1194) -> String {
1195 if let label, label.hasPrefix("[["), label.hasSuffix("]]") {
1196 let source = String(label.dropFirst(2).dropLast(2))
1197 if let imageHTML = makeOrgImageHTML(source: source, alt: nil, imageURLResolver: imageURLResolver) {
1198 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1199 return imageHTML
1200 }
1201 return #"<a href="\#(sanitizedURL)">\#(imageHTML)</a>"#
1202 }
1203 }
1204
1205 if let imageHTML = makeOrgImageHTML(
1206 source: destination,
1207 alt: label,
1208 imageURLResolver: imageURLResolver
1209 ) {
1210 return imageHTML
1211 }
1212
1213 guard let sanitizedURL = sanitizedReadmeLinkURLString(destination) else {
1214 return label ?? destination
1215 }
1216
1217 let renderedLabel = label.map { processOrgInline($0, imageURLResolver: imageURLResolver) } ?? destination
1218 return #"<a href="\#(sanitizedURL)">\#(renderedLabel)</a>"#
1219}
1220
1221nonisolated private func orgKeywordDirective(in line: String) -> (keyword: String, value: String)? {
1222 guard let match = line.firstMatch(of: /^#\+([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/) else {
1223 return nil
1224 }
1225 return (
1226 keyword: String(match.1).lowercased(),
1227 value: String(match.2).trimmingCharacters(in: .whitespaces)
1228 )
1229}
1230
924 1231
925nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { 1232nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
926 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#) 1233 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
@@ -946,6 +1253,27 @@ nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
946 return first == " " || first == "\t" 1253 return first == " " || first == "\t"
947} 1254}
948 1255
1256nonisolated private func isIndentedListItemLine(_ line: String) -> Bool {
1257 guard isIndentedContinuationLine(line) else { return false }
1258 let trimmed = line.trimmingCharacters(in: .whitespaces)
1259 return trimmed.hasPrefix("- ") || orderedListItem(in: trimmed) != nil
1260}
1261
1262nonisolated private func outdentOrgListLine(_ line: String) -> String {
1263 var result = line
1264 while result.first == " " || result.first == "\t" {
1265 result.removeFirst()
1266 }
1267 return result
1268}
1269
1270private extension Array {
1271 subscript(safe index: Int) -> Element? {
1272 guard indices.contains(index) else { return nil }
1273 return self[index]
1274 }
1275}
1276
949 1277
950nonisolated func decodeHTMLEntities(_ text: String) -> String { 1278nonisolated func decodeHTMLEntities(_ text: String) -> String {
951 text 1279 text
@@ -1334,6 +1662,9 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1334 color: inherit; 1662 color: inherit;
1335 opacity: 0.85; 1663 opacity: 0.85;
1336 } 1664 }
1665 .org-verse {
1666 white-space: pre-wrap;
1667 }
1337 hr { 1668 hr {
1338 border: none; 1669 border: none;
1339 border-top: 1px solid rgba(128, 128, 128, 0.35); 1670 border-top: 1px solid rgba(128, 128, 128, 0.35);
@@ -1367,6 +1698,14 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1367 } 1698 }
1368 dd { margin: 0; } 1699 dd { margin: 0; }
1369 .org-metadata { margin-bottom: 1em; } 1700 .org-metadata { margin-bottom: 1em; }
1701 figure.org-block {
1702 margin: 0.75em 0;
1703 }
1704 figure.org-block figcaption {
1705 margin-top: 0.4em;
1706 color: rgba(128, 128, 128, 0.85);
1707 font-size: 0.9em;
1708 }
1370 .org-title { margin: 0 0 0.25em; } 1709 .org-title { margin: 0 0 0.25em; }
1371 .org-author, .org-date { 1710 .org-author, .org-date {
1372 margin: 0; 1711 margin: 0;
HutchTests/ReadmeViewTests.swift +102
@@ -231,6 +231,108 @@ struct OrgRenderingTests {
231 231
232 #expect(html.contains("<li>First line continues here</li>")) 232 #expect(html.contains("<li>First line continues here</li>"))
233 } 233 }
234
235 @Test
236 func orgHeaderKeywordsIgnored() {
237 let html = orgToHTML("""
238 #+OPTIONS: toc:nil
239 #+PROPERTY: header-args :results output
240 Body text
241 """)
242
243 #expect(!html.contains("#+OPTIONS"))
244 #expect(!html.contains("#+PROPERTY"))
245 #expect(html.contains("<p>Body text</p>"))
246 }
247
248 @Test
249 func orgVerseBlock() {
250 let html = orgToHTML("""
251 #+begin_verse
252 There is a line.
253 And an indented line.
254 #+end_verse
255 """)
256
257 #expect(html.contains(#"<blockquote class="org-verse">"#))
258 #expect(html.contains("There is a line."))
259 #expect(html.contains("And an indented line."))
260 #expect(!html.contains("#+begin_verse"))
261 }
262
263 @Test
264 func orgNamedBlockRendersCaption() {
265 let html = orgToHTML("""
266 #+CAPTION: Build output
267 #+NAME: build-log
268 #+begin_example
269 hello world
270 #+end_example
271 """)
272
273 #expect(html.contains(#"<figure class="org-block" id="build-log">"#))
274 #expect(html.contains("<figcaption>Build output</figcaption>"))
275 #expect(!html.contains("#+CAPTION"))
276 #expect(!html.contains("#+NAME"))
277 }
278
279 @Test
280 func orgLinkedImageRenders() {
281 let html = orgToHTML("[[https://example.com][[https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg]]]")
282
283 #expect(html.contains(#"<a href="https://example.com">"#))
284 #expect(html.contains(#"<img src="https://img.cleberg.net/apps/hutch/screenshots/ipad/01_patch.jpg" alt="">"#))
285 }
286
287 @Test
288 func orgLinkedRelativeImageRenders() {
289 let html = orgToHTML(
290 "[[https://example.com/docs][[./images/badge.svg]]]",
291 imageURLResolver: { source in
292 source == "./images/badge.svg" ? "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" : nil
293 }
294 )
295
296 #expect(html.contains(#"<a href="https://example.com/docs">"#))
297 #expect(html.contains(#"<img src="https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/badge.svg" alt="">"#))
298 }
299
300 @Test
301 func orgNestedBulletListRendersNestedMarkup() {
302 let html = orgToHTML("""
303 * Lists
304 ** Unordered
305 - First bullet
306 - Second bullet
307 - Third bullet with /italic/ and *bold*
308 - Bullet with wrapped
309 continuation line
310 - Bullet with nested list
311 - Nested child one
312 - Nested child two
313 - Bullet with inline code ~let x = 1~
314 """)
315
316 #expect(html.contains("<ul>"))
317 #expect(html.contains("<li>Bullet with nested list\n<ul>"))
318 #expect(html.contains("<li>Nested child one</li>"))
319 #expect(html.contains("<li>Nested child two</li>"))
320 #expect(html.contains("<li>Bullet with wrapped continuation line</li>"))
321 }
322
323 @Test
324 func orgTableAlignmentRendersStyles() {
325 let html = orgToHTML("""
326 | Left | Center | Right |
327 |:-----+:-----:+------:|
328 | a | b | c |
329 | 1 | 2 | 3 |
330 """)
331
332 #expect(html.contains("text-align: left;"))
333 #expect(html.contains("text-align: center;"))
334 #expect(html.contains("text-align: right;"))
335 }
234} 336}
235 337
236struct RepositoryAssetURLTests { 338struct RepositoryAssetURLTests {