| @@ -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 | |
| |
944 | nonisolated 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 | |
| 878 | nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { |
957 | nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool { |
| |
958 | tableAlignment(for: cell) != nil |
| |
959 | } |
| |
960 | |
| |
961 | nonisolated 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 | |
| 883 | nonisolated private func renderHTMLTable( |
984 | nonisolated 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 | |
| |
1022 | nonisolated private func tableAlignmentStyleAttribute(_ alignment: String?) -> String { |
| |
1023 | guard let alignment, !alignment.isEmpty else { return "" } |
| |
1024 | return #" style="text-align: \#(alignment);""# |
| |
1025 | } |
| |
1026 | |
| 914 | private enum OrgListType: Equatable { |
1027 | private 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 | |
| |
1037 | nonisolated 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 | |
| |
1069 | nonisolated 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 | |
| |
1129 | nonisolated 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 | |
| |
1152 | nonisolated 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 | |
| |
1190 | nonisolated 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 | |
| |
1221 | nonisolated 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 | |
| 925 | nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool { |
1232 | nonisolated 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 | |
| |
1256 | nonisolated 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 | |
| |
1262 | nonisolated 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 | |
| |
1270 | private 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 | |
| 950 | nonisolated func decodeHTMLEntities(_ text: String) -> String { |
1278 | nonisolated 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; |