Commit a34fb9e738

a34fb9e7381e8ff210d1ea289ccd403f12112c11

parent: aaba253bce

Unsigned

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

partial extended support for markdown and org-mode

Layout: unified · split

Hutch.xcodeproj/project.pbxproj +8 −8
@@ -459,7 +459,7 @@
459 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 459 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
460 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 460 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
461 CODE_SIGN_STYLE = Automatic; 461 CODE_SIGN_STYLE = Automatic;
462 CURRENT_PROJECT_VERSION = 11; 462 CURRENT_PROJECT_VERSION = 12;
463 DEVELOPMENT_TEAM = ZCNAX3VL9D; 463 DEVELOPMENT_TEAM = ZCNAX3VL9D;
464 ENABLE_PREVIEWS = YES; 464 ENABLE_PREVIEWS = YES;
465 GENERATE_INFOPLIST_FILE = YES; 465 GENERATE_INFOPLIST_FILE = YES;
@@ -476,7 +476,7 @@
476 "$(inherited)", 476 "$(inherited)",
477 "@executable_path/Frameworks", 477 "@executable_path/Frameworks",
478 ); 478 );
479 MARKETING_VERSION = 2.4.1; 479 MARKETING_VERSION = 2.4.2;
480 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 480 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
481 PRODUCT_NAME = "$(TARGET_NAME)"; 481 PRODUCT_NAME = "$(TARGET_NAME)";
482 STRING_CATALOG_GENERATE_SYMBOLS = YES; 482 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -496,7 +496,7 @@
496 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 496 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
497 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 497 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
498 CODE_SIGN_STYLE = Automatic; 498 CODE_SIGN_STYLE = Automatic;
499 CURRENT_PROJECT_VERSION = 11; 499 CURRENT_PROJECT_VERSION = 12;
500 DEVELOPMENT_TEAM = ZCNAX3VL9D; 500 DEVELOPMENT_TEAM = ZCNAX3VL9D;
501 ENABLE_PREVIEWS = YES; 501 ENABLE_PREVIEWS = YES;
502 GENERATE_INFOPLIST_FILE = YES; 502 GENERATE_INFOPLIST_FILE = YES;
@@ -513,7 +513,7 @@
513 "$(inherited)", 513 "$(inherited)",
514 "@executable_path/Frameworks", 514 "@executable_path/Frameworks",
515 ); 515 );
516 MARKETING_VERSION = 2.4.1; 516 MARKETING_VERSION = 2.4.2;
517 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 517 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
518 PRODUCT_NAME = "$(TARGET_NAME)"; 518 PRODUCT_NAME = "$(TARGET_NAME)";
519 STRING_CATALOG_GENERATE_SYMBOLS = YES; 519 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
576 APPLICATION_EXTENSION_API_ONLY = YES; 576 APPLICATION_EXTENSION_API_ONLY = YES;
577 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 577 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
578 CODE_SIGN_STYLE = Automatic; 578 CODE_SIGN_STYLE = Automatic;
579 CURRENT_PROJECT_VERSION = 11; 579 CURRENT_PROJECT_VERSION = 12;
580 DEVELOPMENT_TEAM = ZCNAX3VL9D; 580 DEVELOPMENT_TEAM = ZCNAX3VL9D;
581 GENERATE_INFOPLIST_FILE = NO; 581 GENERATE_INFOPLIST_FILE = NO;
582 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 582 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -586,7 +586,7 @@
586 "@executable_path/Frameworks", 586 "@executable_path/Frameworks",
587 "@executable_path/../../Frameworks", 587 "@executable_path/../../Frameworks",
588 ); 588 );
589 MARKETING_VERSION = 2.4.1; 589 MARKETING_VERSION = 2.4.2;
590 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 590 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
591 PRODUCT_NAME = "$(TARGET_NAME)"; 591 PRODUCT_NAME = "$(TARGET_NAME)";
592 SKIP_INSTALL = YES; 592 SKIP_INSTALL = YES;
@@ -605,7 +605,7 @@
605 APPLICATION_EXTENSION_API_ONLY = YES; 605 APPLICATION_EXTENSION_API_ONLY = YES;
606 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 606 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
607 CODE_SIGN_STYLE = Automatic; 607 CODE_SIGN_STYLE = Automatic;
608 CURRENT_PROJECT_VERSION = 11; 608 CURRENT_PROJECT_VERSION = 12;
609 DEVELOPMENT_TEAM = ZCNAX3VL9D; 609 DEVELOPMENT_TEAM = ZCNAX3VL9D;
610 GENERATE_INFOPLIST_FILE = NO; 610 GENERATE_INFOPLIST_FILE = NO;
611 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 611 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -615,7 +615,7 @@
615 "@executable_path/Frameworks", 615 "@executable_path/Frameworks",
616 "@executable_path/../../Frameworks", 616 "@executable_path/../../Frameworks",
617 ); 617 );
618 MARKETING_VERSION = 2.4.1; 618 MARKETING_VERSION = 2.4.2;
619 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 619 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
620 PRODUCT_NAME = "$(TARGET_NAME)"; 620 PRODUCT_NAME = "$(TARGET_NAME)";
621 SKIP_INSTALL = YES; 621 SKIP_INSTALL = YES;
Hutch/Views/Repositories/ReadmeView.swift +630 −69
@@ -283,8 +283,15 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) 283 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
284 var html = "" 284 var html = ""
285 var inCodeBlock = false 285 var inCodeBlock = false
286 var inList = false 286 var codeBlockInListItem = false
287 var codeBlockLines: [String] = []
288 var listType: MarkupListType?
289 var inBlockquote = false
290 var pendingListItemBreak = false
291 var currentListItemLines: [String] = []
292 var currentListItemBlocks: [String] = []
287 var paragraph: [String] = [] 293 var paragraph: [String] = []
294 var tableRows: [[String]] = []
288 295
289 func flushParagraph() { 296 func flushParagraph() {
290 if !paragraph.isEmpty { 297 if !paragraph.isEmpty {
@@ -296,72 +303,262 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
296 } 303 }
297 } 304 }
298 305
306 func flushListItem() {
307 guard !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty else { return }
308 let itemContent = currentListItemLines
309 .map { $0.trimmingCharacters(in: .whitespaces) }
310 .joined(separator: " ")
311
312 if currentListItemBlocks.isEmpty {
313 html += "<li>" + renderTaskListItem(
314 itemContent,
315 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
316 ) + "</li>\n"
317 } else {
318 if !itemContent.isEmpty {
319 currentListItemBlocks.append(
320 "<p>" + renderTaskListItem(
321 itemContent,
322 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
323 ) + "</p>"
324 )
325 }
326 html += "<li>" + currentListItemBlocks.joined(separator: "\n") + "</li>\n"
327 }
328 currentListItemLines = []
329 currentListItemBlocks = []
330 }
331
332 func flushListItemParagraphIntoBlocks() {
333 guard !currentListItemLines.isEmpty else { return }
334 let itemContent = currentListItemLines
335 .map { $0.trimmingCharacters(in: .whitespaces) }
336 .joined(separator: " ")
337 currentListItemBlocks.append(
338 "<p>" + renderTaskListItem(
339 itemContent,
340 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
341 ) + "</p>"
342 )
343 currentListItemLines = []
344 }
345
346 func flushCodeBlock() {
347 let content = codeBlockLines.joined(separator: "\n")
348 let blockHTML = "<pre><code>" + content + "</code></pre>\n"
349 if codeBlockInListItem {
350 currentListItemBlocks.append(blockHTML)
351 } else {
352 html += blockHTML
353 }
354 codeBlockLines = []
355 codeBlockInListItem = false
356 }
357
299 func closeList() { 358 func closeList() {
300 if inList { 359 flushListItem()
360 switch listType {
361 case .unordered:
301 html += "</ul>\n" 362 html += "</ul>\n"
302 inList = false 363 case .ordered:
364 html += "</ol>\n"
365 case nil:
366 break
367 }
368 listType = nil
369 }
370
371 func flushTable() {
372 guard !tableRows.isEmpty else { return }
373 html += renderHTMLTable(
374 rows: tableRows,
375 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) }
376 )
377 tableRows = []
378 }
379
380 func closeBlockquote() {
381 if inBlockquote {
382 flushParagraph()
383 html += "</blockquote>\n"
384 inBlockquote = false
303 } 385 }
304 } 386 }
305 387
306 for line in lines { 388 for line in lines {
389 let trimmed = line.trimmingCharacters(in: .whitespaces)
390
391 if pendingListItemBreak, listType != nil {
392 if trimmed.isEmpty {
393 continue
394 }
395 if isIndentedContinuationLine(line) || trimmed.hasPrefix("```") {
396 pendingListItemBreak = false
397 } else if isMarkdownUnorderedListItem(trimmed) || orderedListItem(in: trimmed) != nil {
398 flushListItem()
399 pendingListItemBreak = false
400 } else {
401 flushListItem()
402 closeList()
403 pendingListItemBreak = false
404 }
405 }
406
407 if let rawHTML = sanitizedMarkdownHTMLLine(from: trimmed) {
408 closeBlockquote()
409 flushParagraph()
410 flushTable()
411 if listType != nil {
412 flushListItemParagraphIntoBlocks()
413 currentListItemBlocks.append(rawHTML)
414 } else {
415 closeList()
416 html += rawHTML + "\n"
417 }
418 continue
419 }
420
307 // Fenced code blocks 421 // Fenced code blocks
308 if line.hasPrefix("```") { 422 if trimmed.hasPrefix("```") {
309 if inCodeBlock { 423 if inCodeBlock {
310 html += "</code></pre>\n" 424 flushCodeBlock()
311 inCodeBlock = false 425 inCodeBlock = false
312 } else { 426 } else {
427 closeBlockquote()
313 flushParagraph() 428 flushParagraph()
314 closeList() 429 flushTable()
315 html += "<pre><code>" 430 codeBlockInListItem = listType != nil && (!currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty)
431 if !codeBlockInListItem {
432 closeList()
433 } else {
434 flushListItemParagraphIntoBlocks()
435 }
316 inCodeBlock = true 436 inCodeBlock = true
437 codeBlockLines = []
317 } 438 }
318 continue 439 continue
319 } 440 }
320 441
321 if inCodeBlock { 442 if inCodeBlock {
322 html += escapeHTML(line) + "\n" 443 codeBlockLines.append(escapeHTML(line))
323 continue 444 continue
324 } 445 }
325 446
447 if isTableLine(trimmed) {
448 closeBlockquote()
449 flushParagraph()
450 closeList()
451 tableRows.append(parseTableRow(trimmed))
452 continue
453 } else {
454 flushTable()
455 }
456
326 // Headings 457 // Headings
458 if line.hasPrefix("###### ") {
459 closeBlockquote()
460 flushParagraph()
461 closeList()
462 html += "<h6>" + processInline(String(line.dropFirst(7)), imageURLResolver: imageURLResolver) + "</h6>\n"
463 continue
464 }
465 if line.hasPrefix("##### ") {
466 closeBlockquote()
467 flushParagraph()
468 closeList()
469 html += "<h5>" + processInline(String(line.dropFirst(6)), imageURLResolver: imageURLResolver) + "</h5>\n"
470 continue
471 }
472 if line.hasPrefix("#### ") {
473 closeBlockquote()
474 flushParagraph()
475 closeList()
476 html += "<h4>" + processInline(String(line.dropFirst(5)), imageURLResolver: imageURLResolver) + "</h4>\n"
477 continue
478 }
327 if line.hasPrefix("### ") { 479 if line.hasPrefix("### ") {
480 closeBlockquote()
328 flushParagraph() 481 flushParagraph()
329 closeList() 482 closeList()
330 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n" 483 html += "<h3>" + processInline(String(line.dropFirst(4)), imageURLResolver: imageURLResolver) + "</h3>\n"
331 continue 484 continue
332 } 485 }
333 if line.hasPrefix("## ") { 486 if line.hasPrefix("## ") {
487 closeBlockquote()
334 flushParagraph() 488 flushParagraph()
335 closeList() 489 closeList()
336 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n" 490 html += "<h2>" + processInline(String(line.dropFirst(3)), imageURLResolver: imageURLResolver) + "</h2>\n"
337 continue 491 continue
338 } 492 }
339 if line.hasPrefix("# ") { 493 if line.hasPrefix("# ") {
494 closeBlockquote()
340 flushParagraph() 495 flushParagraph()
341 closeList() 496 closeList()
342 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n" 497 html += "<h1>" + processInline(String(line.dropFirst(2)), imageURLResolver: imageURLResolver) + "</h1>\n"
343 continue 498 continue
344 } 499 }
345 500
501 if isMarkdownHorizontalRule(trimmed) {
502 closeBlockquote()
503 flushParagraph()
504 closeList()
505 html += "<hr>\n"
506 continue
507 }
508
509 if trimmed.hasPrefix("> ") {
510 flushTable()
511 closeList()
512 if !inBlockquote {
513 flushParagraph()
514 html += "<blockquote>\n"
515 inBlockquote = true
516 }
517 paragraph.append(processInline(String(trimmed.dropFirst(2)), imageURLResolver: imageURLResolver))
518 continue
519 } else {
520 closeBlockquote()
521 }
522
346 // List items 523 // List items
347 let trimmed = line.trimmingCharacters(in: .whitespaces)
348 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") { 524 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") {
349 flushParagraph() 525 flushParagraph()
350 if !inList { 526 if listType != .unordered {
527 closeList()
351 html += "<ul>\n" 528 html += "<ul>\n"
352 inList = true 529 listType = .unordered
353 } 530 }
354 html += "<li>" + renderTaskListItem( 531 flushListItem()
355 String(trimmed.dropFirst(2)), 532 currentListItemLines = [String(trimmed.dropFirst(2))]
356 inlineRenderer: { processInline($0, imageURLResolver: imageURLResolver) } 533 continue
357 ) + "</li>\n" 534 }
535 if let orderedItem = orderedListItem(in: trimmed) {
536 flushParagraph()
537 if listType != .ordered {
538 closeList()
539 html += "<ol>\n"
540 listType = .ordered
541 }
542 flushListItem()
543 currentListItemLines = [orderedItem]
544 continue
545 }
546
547 if listType != nil && isIndentedContinuationLine(line) {
548 currentListItemLines.append(trimmed)
358 continue 549 continue
359 } 550 }
360 551
361 // Blank line 552 // Blank line
362 if trimmed.isEmpty { 553 if trimmed.isEmpty {
363 flushParagraph() 554 if inBlockquote {
364 closeList() 555 closeBlockquote()
556 } else if listType != nil, !currentListItemLines.isEmpty || !currentListItemBlocks.isEmpty {
557 pendingListItemBreak = true
558 } else {
559 flushParagraph()
560 closeList()
561 }
365 continue 562 continue
366 } 563 }
367 564
@@ -371,21 +568,33 @@ nonisolated func markdownToHTML(_ text: String, imageURLResolver: ((String) -> S
371 568
372 // Flush remaining state 569 // Flush remaining state
373 if inCodeBlock { 570 if inCodeBlock {
374 html += "</code></pre>\n" 571 flushCodeBlock()
375 } 572 }
573 closeBlockquote()
376 flushParagraph() 574 flushParagraph()
575 flushTable()
377 closeList() 576 closeList()
378 577
379 return html 578 return html
380} 579}
381 580
382nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String { 581nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> String?)? = nil) -> String {
383 var result = escapeHTML(text) 582 var protectedFragments: [String: String] = [:]
583 var result = protectMatches(
584 in: text,
585 pattern: #"</?[A-Za-z][^>]*?>"#,
586 protectedFragments: &protectedFragments
587 ) { match, nsText in
588 let rawTag = nsText.substring(with: match.range)
589 return sanitizedMarkdownHTMLTag(rawTag) ?? escapeHTML(rawTag)
590 }
591
592 result = escapeHTML(result)
384 593
385 // Images: ![alt](url) 594 // Images: ![alt](url)
386 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in 595 result = replaceMatches(in: result, pattern: #"!\[([^\]]*)\]\(([^)]+)\)"#) { match, nsText in
387 let alt = nsText.substring(with: match.range(at: 1)) 596 let alt = nsText.substring(with: match.range(at: 1))
388 let source = nsText.substring(with: match.range(at: 2)) 597 let source = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
389 let resolvedSource = imageURLResolver?(source) ?? source 598 let resolvedSource = imageURLResolver?(source) ?? source
390 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { 599 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else {
391 return escapeHTML(alt) 600 return escapeHTML(alt)
@@ -395,12 +604,30 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
395 // Links: [text](url) 604 // Links: [text](url)
396 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in 605 result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in
397 let label = nsText.substring(with: match.range(at: 1)) 606 let label = nsText.substring(with: match.range(at: 1))
398 let rawURL = nsText.substring(with: match.range(at: 2)) 607 let rawURL = decodeHTMLEntities(nsText.substring(with: match.range(at: 2)))
399 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { 608 guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else {
400 return label 609 return label
401 } 610 }
402 return #"<a href="\#(sanitizedURL)">\#(label)</a>"# 611 return #"<a href="\#(sanitizedURL)">\#(label)</a>"#
403 } 612 }
613 // Plain email autolinks
614 result = replaceMatches(
615 in: result,
616 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
617 ) { match, nsText in
618 guard !isInsideHTMLTag(nsText, range: match.range) else {
619 return nsText.substring(with: match.range)
620 }
621 let email = nsText.substring(with: match.range(at: 1))
622 let href = escapeHTMLAttribute("mailto:\(email)")
623 return #"<a href="\#(href)">\#(email)</a>"#
624 }
625 // Strikethrough: ~~text~~
626 result = result.replacingOccurrences(
627 of: #"~~(.+?)~~"#,
628 with: "<del>$1</del>",
629 options: .regularExpression
630 )
404 // Bold: **text** 631 // Bold: **text**
405 result = result.replacingOccurrences( 632 result = result.replacingOccurrences(
406 of: #"\*\*(.+?)\*\*"#, 633 of: #"\*\*(.+?)\*\*"#,
@@ -409,7 +636,13 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
409 ) 636 )
410 // Italic: *text* 637 // Italic: *text*
411 result = result.replacingOccurrences( 638 result = result.replacingOccurrences(
412 of: #"\*(.+?)\*"#, 639 of: #"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)"#,
640 with: "<em>$1</em>",
641 options: .regularExpression
642 )
643 // Italic: _text_
644 result = result.replacingOccurrences(
645 of: #"(?<!\w)_(.+?)_(?!\w)"#,
413 with: "<em>$1</em>", 646 with: "<em>$1</em>",
414 options: .regularExpression 647 options: .regularExpression
415 ) 648 )
@@ -420,6 +653,10 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St
420 options: .regularExpression 653 options: .regularExpression
421 ) 654 )
422 655
656 for (token, fragment) in protectedFragments {
657 result = result.replacingOccurrences(of: token, with: fragment)
658 }
659
423 return result 660 return result
424} 661}
425 662
@@ -429,12 +666,39 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
429 let normalizedText = text 666 let normalizedText = text
430 .replacingOccurrences(of: "\r\n", with: "\n") 667 .replacingOccurrences(of: "\r\n", with: "\n")
431 .replacingOccurrences(of: "\r", with: "\n") 668 .replacingOccurrences(of: "\r", with: "\n")
432 let lines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) 669 let rawLines = normalizedText.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
670 var title: String?
671 var author: String?
672 var date: String?
673 let lines = rawLines.filter { line in
674 let trimmed = line.trimmingCharacters(in: .whitespaces)
675 guard let keywordMatch = trimmed.firstMatch(of: /^#\+([A-Za-z]+):\s*(.*)$/) else {
676 return true
677 }
678 let keyword = String(keywordMatch.1).lowercased()
679 let value = String(keywordMatch.2).trimmingCharacters(in: .whitespaces)
680 switch keyword {
681 case "title":
682 title = value
683 return false
684 case "author":
685 author = value
686 return false
687 case "date":
688 date = value
689 return false
690 default:
691 return true
692 }
693 }
433 var html = "" 694 var html = ""
434 var listType: OrgListType? 695 var listType: OrgListType?
435 var inQuoteBlock = false 696 var inQuoteBlock = false
436 var inPropertyDrawer = false 697 var inPropertyDrawer = false
437 var srcLanguage: String? 698 var srcLanguage: String?
699 var inExampleBlock = false
700 var inCenterBlock = false
701 var currentListItemLines: [String] = []
438 var paragraph: [String] = [] 702 var paragraph: [String] = []
439 var tableRows: [[String]] = [] 703 var tableRows: [[String]] = []
440 var propertyRows: [(String, String)] = [] 704 var propertyRows: [(String, String)] = []
@@ -449,7 +713,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
449 } 713 }
450 } 714 }
451 715
716 func flushListItem() {
717 guard !currentListItemLines.isEmpty else { return }
718 let content = currentListItemLines
719 .map { $0.trimmingCharacters(in: .whitespaces) }
720 .joined(separator: " ")
721 html += "<li>" + renderTaskListItem(
722 content,
723 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
724 ) + "</li>\n"
725 currentListItemLines = []
726 }
727
452 func closeList() { 728 func closeList() {
729 flushListItem()
453 switch listType { 730 switch listType {
454 case .unordered: 731 case .unordered:
455 html += "</ul>\n" 732 html += "</ul>\n"
@@ -463,34 +740,10 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
463 740
464 func flushTable() { 741 func flushTable() {
465 guard !tableRows.isEmpty else { return } 742 guard !tableRows.isEmpty else { return }
466 let hasHeaderSeparator = tableRows.count > 1 && tableRows[1].allSatisfy(isOrgTableSeparatorCell) 743 html += renderHTMLTable(
467 let headerRow = tableRows.first ?? [] 744 rows: tableRows,
468 let bodyRows: [[String]] 745 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
469 746 )
470 html += "<table>\n"
471 if hasHeaderSeparator {
472 html += "<thead><tr>"
473 for cell in headerRow {
474 html += "<th>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</th>"
475 }
476 html += "</tr></thead>\n<tbody>\n"
477 bodyRows = Array(tableRows.dropFirst(2))
478 } else {
479 bodyRows = tableRows
480 }
481
482 for row in bodyRows {
483 html += "<tr>"
484 for cell in row {
485 html += "<td>" + processOrgInline(cell, imageURLResolver: imageURLResolver) + "</td>"
486 }
487 html += "</tr>\n"
488 }
489
490 if hasHeaderSeparator {
491 html += "</tbody>\n"
492 }
493 html += "</table>\n"
494 tableRows = [] 747 tableRows = []
495 } 748 }
496 749
@@ -520,6 +773,21 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
520 } 773 }
521 } 774 }
522 775
776 func closeExampleBlock() {
777 if inExampleBlock {
778 html += "</code></pre>\n"
779 inExampleBlock = false
780 }
781 }
782
783 func closeCenterBlock() {
784 if inCenterBlock {
785 flushParagraph()
786 html += "</div>\n"
787 inCenterBlock = false
788 }
789 }
790
523 func flushBlockState() { 791 func flushBlockState() {
524 flushParagraph() 792 flushParagraph()
525 closeList() 793 closeList()
@@ -527,6 +795,20 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
527 flushPropertyDrawer() 795 flushPropertyDrawer()
528 } 796 }
529 797
798 if title != nil || author != nil || date != nil {
799 html += "<div class=\"org-metadata\">\n"
800 if let title {
801 html += "<h1 class=\"org-title\">" + escapeHTML(title) + "</h1>\n"
802 }
803 if let author {
804 html += "<p class=\"org-author\">" + escapeHTML(author) + "</p>\n"
805 }
806 if let date {
807 html += "<p class=\"org-date\">" + escapeHTML(date) + "</p>\n"
808 }
809 html += "</div>\n"
810 }
811
530 for line in lines { 812 for line in lines {
531 let trimmed = line.trimmingCharacters(in: .whitespaces) 813 let trimmed = line.trimmingCharacters(in: .whitespaces)
532 814
@@ -539,11 +821,35 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
539 continue 821 continue
540 } 822 }
541 823
824 if inExampleBlock {
825 if trimmed.lowercased() == "#+end_example" {
826 closeExampleBlock()
827 } else {
828 html += escapeHTML(line) + "\n"
829 }
830 continue
831 }
832
542 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" { 833 if inQuoteBlock, trimmed.lowercased() == "#+end_quote" {
543 closeQuoteBlock() 834 closeQuoteBlock()
544 continue 835 continue
545 } 836 }
546 837
838 if inCenterBlock {
839 if trimmed.lowercased() == "#+end_center" {
840 closeCenterBlock()
841 } else if trimmed.isEmpty {
842 flushParagraph()
843 } else {
844 paragraph.append(line)
845 }
846 continue
847 }
848
849 if trimmed == "#" || trimmed.hasPrefix("# ") {
850 continue
851 }
852
547 if trimmed.lowercased().hasPrefix("#+begin_src") { 853 if trimmed.lowercased().hasPrefix("#+begin_src") {
548 closeQuoteBlock() 854 closeQuoteBlock()
549 flushBlockState() 855 flushBlockState()
@@ -559,6 +865,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
559 continue 865 continue
560 } 866 }
561 867
868 if trimmed.lowercased() == "#+begin_example" {
869 closeQuoteBlock()
870 flushBlockState()
871 html += "<pre><code>"
872 inExampleBlock = true
873 continue
874 }
875
562 if trimmed.lowercased() == "#+begin_quote" { 876 if trimmed.lowercased() == "#+begin_quote" {
563 flushBlockState() 877 flushBlockState()
564 html += "<blockquote>\n" 878 html += "<blockquote>\n"
@@ -566,6 +880,14 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
566 continue 880 continue
567 } 881 }
568 882
883 if trimmed.lowercased() == "#+begin_center" {
884 closeQuoteBlock()
885 flushBlockState()
886 html += "<div style=\"text-align:center\">\n"
887 inCenterBlock = true
888 continue
889 }
890
569 if trimmed == ":PROPERTIES:" { 891 if trimmed == ":PROPERTIES:" {
570 closeQuoteBlock() 892 closeQuoteBlock()
571 flushBlockState() 893 flushBlockState()
@@ -592,18 +914,25 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
592 } 914 }
593 } 915 }
594 916
595 if isOrgTableLine(trimmed) { 917 if isTableLine(trimmed) {
596 closeQuoteBlock() 918 closeQuoteBlock()
597 flushParagraph() 919 flushParagraph()
598 closeList() 920 closeList()
599 tableRows.append(parseOrgTableRow(trimmed)) 921 tableRows.append(parseTableRow(trimmed))
600 continue 922 continue
601 } else { 923 } else {
602 flushTable() 924 flushTable()
603 } 925 }
604 926
927 if isOrgHorizontalRule(trimmed) {
928 closeQuoteBlock()
929 flushBlockState()
930 html += "<hr>\n"
931 continue
932 }
933
605 // Org headings: * heading, ** heading, *** heading 934 // Org headings: * heading, ** heading, *** heading
606 if let match = trimmed.firstMatch(of: /^(\*{1,3})\s+(.+)$/) { 935 if let match = trimmed.firstMatch(of: /^(\*{1,6})\s+(.+)$/) {
607 closeQuoteBlock() 936 closeQuoteBlock()
608 flushBlockState() 937 flushBlockState()
609 let level = match.1.count 938 let level = match.1.count
@@ -621,10 +950,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
621 html += "<ul>\n" 950 html += "<ul>\n"
622 listType = .unordered 951 listType = .unordered
623 } 952 }
624 html += "<li>" + renderTaskListItem( 953 flushListItem()
625 String(trimmed.dropFirst(2)), 954 currentListItemLines = [String(trimmed.dropFirst(2))]
626 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) }
627 ) + "</li>\n"
628 continue 955 continue
629 } 956 }
630 957
@@ -636,10 +963,13 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
636 html += "<ol>\n" 963 html += "<ol>\n"
637 listType = .ordered 964 listType = .ordered
638 } 965 }
639 html += "<li>" + renderTaskListItem( 966 flushListItem()
640 orderedItem, 967 currentListItemLines = [orderedItem]
641 inlineRenderer: { processOrgInline($0, imageURLResolver: imageURLResolver) } 968 continue
642 ) + "</li>\n" 969 }
970
971 if listType != nil && isIndentedContinuationLine(line) {
972 currentListItemLines.append(trimmed)
643 continue 973 continue
644 } 974 }
645 975
@@ -658,6 +988,8 @@ nonisolated func orgToHTML(_ text: String, imageURLResolver: ((String) -> String
658 } 988 }
659 989
660 closeSourceBlock() 990 closeSourceBlock()
991 closeExampleBlock()
992 closeCenterBlock()
661 closeQuoteBlock() 993 closeQuoteBlock()
662 flushBlockState() 994 flushBlockState()
663 995
@@ -720,6 +1052,22 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
720 } 1052 }
721 return "<code>\(codeText)</code>" 1053 return "<code>\(codeText)</code>"
722 } 1054 }
1055 result = protectMatches(
1056 in: result,
1057 pattern: #"(?<!\S)\+(.+?)\+(?=\s|$|[.,;:!?])"#,
1058 protectedFragments: &protectedFragments
1059 ) { match, nsText in
1060 let value = nsText.substring(with: match.range(at: 1))
1061 return "<del>\(value)</del>"
1062 }
1063 result = protectMatches(
1064 in: result,
1065 pattern: #"(?<!\S)_(.+?)_(?=\s|$|[.,;:!?])"#,
1066 protectedFragments: &protectedFragments
1067 ) { match, nsText in
1068 let value = nsText.substring(with: match.range(at: 1))
1069 return "<u>\(value)</u>"
1070 }
723 1071
724 // Bold: *text* 1072 // Bold: *text*
725 result = result.replacingOccurrences( 1073 result = result.replacingOccurrences(
@@ -733,6 +1081,17 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St
733 with: "<em>$1</em>", 1081 with: "<em>$1</em>",
734 options: .regularExpression 1082 options: .regularExpression
735 ) 1083 )
1084 result = replaceMatches(
1085 in: result,
1086 pattern: #"(?i)(?<![\w.%+\-])([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})(?![\w\-])"#
1087 ) { match, nsText in
1088 guard !isInsideHTMLTag(nsText, range: match.range) else {
1089 return nsText.substring(with: match.range)
1090 }
1091 let email = nsText.substring(with: match.range(at: 1))
1092 let href = escapeHTMLAttribute("mailto:\(email)")
1093 return #"<a href="\#(href)">\#(email)</a>"#
1094 }
736 1095
737 for (token, fragment) in protectedFragments { 1096 for (token, fragment) in protectedFragments {
738 result = result.replacingOccurrences(of: token, with: fragment) 1097 result = result.replacingOccurrences(of: token, with: fragment)
@@ -805,11 +1164,11 @@ nonisolated private func sanitizeReadmeURLString(
805 return escapeHTMLAttribute(sanitizedURL) 1164 return escapeHTMLAttribute(sanitizedURL)
806} 1165}
807 1166
808nonisolated private func isOrgTableLine(_ line: String) -> Bool { 1167nonisolated private func isTableLine(_ line: String) -> Bool {
809 line.hasPrefix("|") && line.hasSuffix("|") 1168 line.hasPrefix("|") && line.hasSuffix("|")
810} 1169}
811 1170
812nonisolated private func parseOrgTableRow(_ line: String) -> [String] { 1171nonisolated private func parseTableRow(_ line: String) -> [String] {
813 line 1172 line
814 .split(separator: "|", omittingEmptySubsequences: false) 1173 .split(separator: "|", omittingEmptySubsequences: false)
815 .dropFirst() 1174 .dropFirst()
@@ -817,21 +1176,163 @@ nonisolated private func parseOrgTableRow(_ line: String) -> [String] {
817 .map { String($0).trimmingCharacters(in: .whitespaces) } 1176 .map { String($0).trimmingCharacters(in: .whitespaces) }
818} 1177}
819 1178
820nonisolated private func isOrgTableSeparatorCell(_ cell: String) -> Bool { 1179nonisolated private func isTableSeparatorCell(_ cell: String) -> Bool {
821 let trimmed = cell.trimmingCharacters(in: .whitespaces) 1180 let trimmed = cell.trimmingCharacters(in: .whitespaces)
822 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" } 1181 return !trimmed.isEmpty && trimmed.allSatisfy { $0 == "-" || $0 == "+" }
823} 1182}
824 1183
825private enum OrgListType { 1184nonisolated private func renderHTMLTable(
1185 rows: [[String]],
1186 inlineRenderer: (String) -> String
1187) -> String {
1188 guard !rows.isEmpty else { return "" }
1189 let hasHeaderSeparator = rows.count > 1 && rows[1].allSatisfy(isTableSeparatorCell)
1190 let headerRow = rows.first ?? []
1191 let bodyRows = hasHeaderSeparator ? Array(rows.dropFirst(2)) : rows
1192 var html = "<table>\n"
1193
1194 if hasHeaderSeparator {
1195 html += "<thead><tr>"
1196 for cell in headerRow {
1197 html += "<th>" + inlineRenderer(cell) + "</th>"
1198 }
1199 html += "</tr></thead>\n"
1200 }
1201
1202 html += "<tbody>\n"
1203 for row in bodyRows {
1204 html += "<tr>"
1205 for cell in row {
1206 html += "<td>" + inlineRenderer(cell) + "</td>"
1207 }
1208 html += "</tr>\n"
1209 }
1210 html += "</tbody>\n"
1211 html += "</table>\n"
1212 return html
1213}
1214
1215private enum MarkupListType: Equatable {
826 case unordered 1216 case unordered
827 case ordered 1217 case ordered
828} 1218}
829 1219
1220private typealias OrgListType = MarkupListType
1221
830nonisolated private func orderedListItem(in line: String) -> String? { 1222nonisolated private func orderedListItem(in line: String) -> String? {
831 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil } 1223 guard let match = line.firstMatch(of: /^(\d+)\.\s+(.+)$/) else { return nil }
832 return String(match.2) 1224 return String(match.2)
833} 1225}
834 1226
1227nonisolated private func isMarkdownHorizontalRule(_ line: String) -> Bool {
1228 matchesRegex(line, pattern: #"^\s*([*\-_])(?:\s*\1){2,}\s*$"#)
1229}
1230
1231nonisolated private func isOrgHorizontalRule(_ line: String) -> Bool {
1232 matchesRegex(line, pattern: #"^\s*-{5,}\s*$"#)
1233}
1234
1235nonisolated private func matchesRegex(_ text: String, pattern: String) -> Bool {
1236 guard let regex = try? NSRegularExpression(pattern: pattern) else { return false }
1237 let range = NSRange(location: 0, length: (text as NSString).length)
1238 return regex.firstMatch(in: text, range: range) != nil
1239}
1240
1241nonisolated private func isInsideHTMLTag(_ text: NSString, range: NSRange) -> Bool {
1242 guard range.location != NSNotFound else { return false }
1243 let prefix = text.substring(to: range.location)
1244 guard let lastOpen = prefix.lastIndex(of: "<") else { return false }
1245 guard let lastClose = prefix.lastIndex(of: ">") else { return true }
1246 return lastOpen > lastClose
1247}
1248
1249nonisolated private func isIndentedContinuationLine(_ line: String) -> Bool {
1250 guard !line.trimmingCharacters(in: .whitespaces).isEmpty else { return false }
1251 guard let first = line.first else { return false }
1252 return first == " " || first == "\t"
1253}
1254
1255nonisolated private func isMarkdownUnorderedListItem(_ line: String) -> Bool {
1256 line.hasPrefix("- ") || line.hasPrefix("* ")
1257}
1258
1259nonisolated private func decodeHTMLEntities(_ text: String) -> String {
1260 text
1261 .replacingOccurrences(of: "&amp;", with: "&")
1262 .replacingOccurrences(of: "&quot;", with: "\"")
1263 .replacingOccurrences(of: "&#39;", with: "'")
1264 .replacingOccurrences(of: "&lt;", with: "<")
1265 .replacingOccurrences(of: "&gt;", with: ">")
1266}
1267
1268nonisolated private func sanitizedMarkdownHTMLLine(from line: String) -> String? {
1269 guard line.hasPrefix("<"), line.hasSuffix(">") else { return nil }
1270 return sanitizedMarkdownHTMLTag(line)
1271}
1272
1273nonisolated private func sanitizedMarkdownHTMLTag(_ rawTag: String) -> String? {
1274 let trimmed = rawTag.trimmingCharacters(in: .whitespacesAndNewlines)
1275 guard trimmed.hasPrefix("<"), trimmed.hasSuffix(">") else { return nil }
1276 guard !trimmed.lowercased().hasPrefix("<!--") else { return nil }
1277
1278 let selfClosing = trimmed.hasSuffix("/>")
1279 let contentStart = trimmed.index(after: trimmed.startIndex)
1280 let contentEnd = trimmed.index(trimmed.endIndex, offsetBy: selfClosing ? -2 : -1)
1281 let inner = trimmed[contentStart..<contentEnd].trimmingCharacters(in: .whitespacesAndNewlines)
1282 let isClosing = inner.hasPrefix("/")
1283 let body = isClosing ? inner.dropFirst().trimmingCharacters(in: .whitespacesAndNewlines) : inner
1284 let parts = body.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true)
1285 guard let rawName = parts.first else { return nil }
1286 let tagName = rawName.lowercased()
1287 let allowedTags: Set<String> = [
1288 "a", "abbr", "b", "blockquote", "br", "code", "del", "div", "em",
1289 "hr", "i", "img", "li", "ol", "p", "pre", "span", "strong", "sub",
1290 "sup", "u", "ul"
1291 ]
1292 guard allowedTags.contains(tagName) else { return nil }
1293
1294 if isClosing {
1295 return "</\(tagName)>"
1296 }
1297
1298 let attributePortion = parts.count > 1 ? String(parts[1]) : ""
1299 let attributes = parseHTMLAttributes(attributePortion)
1300 var renderedAttributes: [String] = []
1301
1302 for (name, value) in attributes {
1303 switch (tagName, name.lowercased()) {
1304 case ("a", "href"):
1305 if let sanitized = sanitizedReadmeLinkURLString(decodeHTMLEntities(value)) {
1306 renderedAttributes.append(#"href="\#(sanitized)""#)
1307 }
1308 case ("img", "src"):
1309 if let sanitized = sanitizedReadmeImageURLString(decodeHTMLEntities(value)) {
1310 renderedAttributes.append(#"src="\#(sanitized)""#)
1311 }
1312 case ("img", "alt"), (_, "title"), (_, "class"):
1313 renderedAttributes.append(#"\#(name)="\#(escapeHTMLAttribute(value))""#)
1314 default:
1315 continue
1316 }
1317 }
1318
1319 let suffix = selfClosing || tagName == "br" || tagName == "hr" || tagName == "img" ? " /" : ""
1320 let attributeText = renderedAttributes.isEmpty ? "" : " " + renderedAttributes.joined(separator: " ")
1321 return "<\(tagName)\(attributeText)\(suffix)>"
1322}
1323
1324nonisolated private func parseHTMLAttributes(_ text: String) -> [(String, String)] {
1325 guard let regex = try? NSRegularExpression(pattern: #"([A-Za-z_:][A-Za-z0-9:._-]*)\s*=\s*"([^"]*)""#) else {
1326 return []
1327 }
1328 let nsText = text as NSString
1329 return regex.matches(in: text, range: NSRange(location: 0, length: nsText.length)).map { match in
1330 let name = nsText.substring(with: match.range(at: 1))
1331 let value = nsText.substring(with: match.range(at: 2))
1332 return (name, value)
1333 }
1334}
1335
835nonisolated private func protectMatches( 1336nonisolated private func protectMatches(
836 in text: String, 1337 in text: String,
837 pattern: String, 1338 pattern: String,
@@ -843,7 +1344,7 @@ nonisolated private func protectMatches(
843 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length)) 1344 let matches = regex.matches(in: result, range: NSRange(location: 0, length: (result as NSString).length))
844 1345
845 for match in matches.reversed() { 1346 for match in matches.reversed() {
846 let token = "__ORG_PROTECTED_\(protectedFragments.count)__" 1347 let token = "ZZPROTECTED\(protectedFragments.count)ZZ"
847 let nsText = result as NSString 1348 let nsText = result as NSString
848 protectedFragments[token] = transform(match, nsText) 1349 protectedFragments[token] = transform(match, nsText)
849 result = nsText.replacingCharacters(in: match.range, with: token) 1350 result = nsText.replacingCharacters(in: match.range, with: token)
@@ -877,8 +1378,9 @@ nonisolated private func makeOrgImageHTML(
877) -> String? { 1378) -> String? {
878 guard isRenderableImageSource(source) else { return nil } 1379 guard isRenderableImageSource(source) else { return nil }
879 let resolvedSource = imageURLResolver?(source) ?? source 1380 let resolvedSource = imageURLResolver?(source) ?? source
1381 guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { return nil }
880 let altText = escapeHTMLAttribute(alt ?? "") 1382 let altText = escapeHTMLAttribute(alt ?? "")
881 return #"<img src="\#(resolvedSource)" alt="\#(altText)">"# 1383 return #"<img src="\#(sanitizedSource)" alt="\#(altText)">"#
882} 1384}
883 1385
884nonisolated private func isRenderableImageSource(_ source: String) -> Bool { 1386nonisolated private func isRenderableImageSource(_ source: String) -> Bool {
@@ -911,7 +1413,19 @@ nonisolated func resolveRepositoryAssetURL(
911 } 1413 }
912 1414
913 guard !relativePath.isEmpty else { return nil } 1415 guard !relativePath.isEmpty else { return nil }
914 return "https://git.sr.ht/\(owner)/\(repositoryName)/blob/HEAD/\(relativePath)" 1416 var components = URLComponents()
1417 components.scheme = "https"
1418 components.host = "git.sr.ht"
1419 let encodedOwner = owner.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? owner
1420 let encodedRepository = repositoryName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? repositoryName
1421 let encodedRelativePath = relativePath
1422 .split(separator: "/", omittingEmptySubsequences: false)
1423 .map { segment in
1424 String(segment).addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? String(segment)
1425 }
1426 .joined(separator: "/")
1427 components.percentEncodedPath = "/\(encodedOwner)/\(encodedRepository)/blob/HEAD/\(encodedRelativePath)"
1428 return components.string
915} 1429}
916 1430
917nonisolated private func normalizeRepositoryPath(_ path: String) -> String { 1431nonisolated private func normalizeRepositoryPath(_ path: String) -> String {
@@ -1099,6 +1613,53 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
1099 a { color: \(linkColor); } 1613 a { color: \(linkColor); }
1100 table { border-collapse: collapse; width: 100%; } 1614 table { border-collapse: collapse; width: 100%; }
1101 td, th { border: 1px solid #ccc; padding: 4px 8px; } 1615 td, th { border: 1px solid #ccc; padding: 4px 8px; }
1616 blockquote {
1617 border-left: 3px solid rgba(128, 128, 128, 0.5);
1618 margin: 0.5em 0;
1619 padding: 0.25em 0 0.25em 1em;
1620 color: inherit;
1621 opacity: 0.85;
1622 }
1623 hr {
1624 border: none;
1625 border-top: 1px solid rgba(128, 128, 128, 0.35);
1626 margin: 1em 0;
1627 }
1628 table {
1629 border-collapse: collapse;
1630 width: 100%;
1631 margin: 0.75em 0;
1632 font-size: 0.95em;
1633 }
1634 th {
1635 background: rgba(128, 128, 128, 0.15);
1636 font-weight: 600;
1637 text-align: left;
1638 }
1639 td, th {
1640 border: 1px solid rgba(128, 128, 128, 0.3);
1641 padding: 6px 10px;
1642 }
1643 dl.org-properties {
1644 margin: 0.5em 0;
1645 display: grid;
1646 grid-template-columns: max-content 1fr;
1647 gap: 2px 12px;
1648 }
1649 dt {
1650 font-weight: 600;
1651 font-family: ui-monospace, Menlo, monospace;
1652 font-size: 0.9em;
1653 }
1654 dd { margin: 0; }
1655 .org-metadata { margin-bottom: 1em; }
1656 .org-title { margin: 0 0 0.25em; }
1657 .org-author, .org-date {
1658 margin: 0;
1659 color: rgba(128, 128, 128, 0.85);
1660 font-size: 0.9em;
1661 }
1662 del { opacity: 0.7; }
1102 </style> 1663 </style>
1103 </head> 1664 </head>
1104 <body>\(html)</body> 1665 <body>\(html)</body>
HutchTests/ReadmeViewTests.swift +211
@@ -27,3 +27,214 @@ struct ReadmeViewTests {
27 #expect(sanitizedReadmeLinkURLString("#readme") == "#readme") 27 #expect(sanitizedReadmeLinkURLString("#readme") == "#readme")
28 } 28 }
29} 29}
30
31struct MarkdownRenderingTests {
32
33 @Test
34 func markdownOrderedList() {
35 let html = markdownToHTML("1. First\n2. Second\n3. Third")
36
37 #expect(html.contains("<ol>"))
38 #expect(html.contains("<li>First</li>"))
39 #expect(html.contains("<li>Third</li>"))
40 #expect(html.contains("</ol>"))
41 }
42
43 @Test
44 func markdownBlockquote() {
45 let html = markdownToHTML("> This is a quote")
46
47 #expect(html.contains("<blockquote>"))
48 #expect(html.contains("This is a quote"))
49 #expect(html.contains("</blockquote>"))
50 }
51
52 @Test
53 func markdownTable() {
54 let input = "| A | B |\n|---|---|\n| 1 | 2 |"
55 let html = markdownToHTML(input)
56
57 #expect(html.contains("<table>"))
58 #expect(html.contains("<th>"))
59 #expect(html.contains("<td>"))
60 }
61
62 @Test
63 func markdownHorizontalRule() {
64 let html = markdownToHTML("---")
65
66 #expect(html.contains("<hr>"))
67 }
68
69 @Test
70 func markdownDeepHeadings() {
71 let html = markdownToHTML("#### Level 4\n##### Level 5\n###### Level 6")
72
73 #expect(html.contains("<h4>"))
74 #expect(html.contains("<h5>"))
75 #expect(html.contains("<h6>"))
76 }
77
78 @Test
79 func markdownStrikethrough() {
80 let html = markdownToHTML("~~deleted~~")
81
82 #expect(html.contains("<del>deleted</del>"))
83 }
84
85 @Test
86 func markdownInlineCodeEscaping() {
87 let html = processInline("`<b>`")
88
89 #expect(html.contains("<code>"))
90 #expect(html.contains("&lt;b&gt;"))
91 #expect(!html.contains("<b>"))
92 }
93
94 @Test
95 func markdownWrappedBulletNormalizesLines() {
96 let html = markdownToHTML("- First line\n continues here")
97
98 #expect(html.contains("<li>First line continues here</li>"))
99 }
100
101 @Test
102 func markdownPlainEmailAutolinks() {
103 let html = processInline("Contact hello@cleberg.net")
104
105 #expect(html.contains(#"href="mailto:hello@cleberg.net""#))
106 #expect(html.contains(">hello@cleberg.net</a>"))
107 }
108
109 @Test
110 func markdownListContinuesAfterCodeBlock() {
111 let html = markdownToHTML("""
112 1. Clone the repository:
113 ```sh
114 git clone https://git.sr.ht/~ccleberg/Hutch
115 ```
116 2. Open the project in Xcode.
117 """)
118
119 #expect(html.contains("<ol>"))
120 #expect(html.contains("<pre><code>"))
121 #expect(html.contains("<li><p>Clone the repository:</p>"))
122 #expect(html.contains("<li>Open the project in Xcode.</li>"))
123 #expect(html.contains("</ol>"))
124 }
125
126 @Test
127 func markdownListContinuesAfterBlankLineIndentedCodeBlock() {
128 let html = markdownToHTML("""
129 1. Clone the repository:
130
131 ```sh
132 git clone https://git.sr.ht/~ccleberg/Hutch
133 ```
134
135 2. Open the project in Xcode.
136 """)
137
138 #expect(html.contains("<li><p>Clone the repository:</p>"))
139 #expect(html.contains("<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>"))
140 #expect(html.contains("<li>Open the project in Xcode.</li>"))
141 #expect(!html.contains("<ol>\n<li>Open the project in Xcode.</li>\n</ol>\n<ol>"))
142 #expect(html.firstRange(of: "<p>Clone the repository:</p>")!.lowerBound < html.firstRange(of: "<pre><code>git clone https://git.sr.ht/~ccleberg/Hutch</code></pre>")!.lowerBound)
143 }
144
145 @Test
146 func markdownImageQueryStringPreservesAmpersands() {
147 let html = processInline("![badge](https://sonarcloud.io/api/project_badges/measure?project=ccleberg_Hutch&metric=security_rating)")
148
149 #expect(html.contains("metric=security_rating"))
150 #expect(!html.contains("amp;metric"))
151 #expect(html.contains("<img"))
152 }
153
154 @Test
155 func markdownAllowsSafeInlineHTML() {
156 let html = processInline(#"<strong>Bold</strong> <a href="https://example.com">Link</a>"#)
157
158 #expect(html.contains("<strong>Bold</strong>"))
159 #expect(html.contains(#"<a href="https://example.com">Link</a>"#))
160 }
161
162 @Test
163 func markdownProtectedTokensDoNotLeak() {
164 let html = processInline(#"<a href="https://example.com">Back to top</a> `yoshi [ARG] <FILE>`"#)
165
166 #expect(!html.contains("ZZPROTECTED"))
167 #expect(html.contains(#"<a href="https://example.com">Back to top</a>"#))
168 #expect(html.contains("<code>yoshi [ARG] &lt;FILE&gt;</code>"))
169 }
170}
171
172struct OrgRenderingTests {
173
174 @Test
175 func orgDeepHeading() {
176 let html = orgToHTML("**** Level 4 Heading")
177
178 #expect(html.contains("<h4>"))
179 }
180
181 @Test
182 func orgCommentLinesIgnored() {
183 let html = orgToHTML("# This is a comment\nNormal text")
184
185 #expect(!html.contains("This is a comment"))
186 #expect(html.contains("Normal text"))
187 }
188
189 @Test
190 func orgStrikethrough() {
191 let html = orgToHTML("+deleted text+")
192
193 #expect(html.contains("<del>"))
194 }
195
196 @Test
197 func orgExampleBlock() {
198 let html = orgToHTML("#+begin_example\nhello world\n#+end_example")
199
200 #expect(html.contains("<pre><code>"))
201 #expect(html.contains("hello world"))
202 }
203
204 @Test
205 func orgTitleKeyword() {
206 let html = orgToHTML("#+TITLE: My Document\nBody text")
207
208 #expect(html.contains("org-title"))
209 #expect(html.contains("My Document"))
210 }
211
212 @Test
213 func orgItalicDoesNotMatchURLPaths() {
214 let html = orgToHTML("[[https://example.com/path/to/file]]")
215
216 #expect(!html.contains("<em>"))
217 }
218
219 @Test
220 func orgWrappedBulletNormalizesLines() {
221 let html = orgToHTML("- First line\n continues here")
222
223 #expect(html.contains("<li>First line continues here</li>"))
224 }
225}
226
227struct RepositoryAssetURLTests {
228
229 @Test
230 func repositoryAssetURLPercentEncodesImagePaths() {
231 let url = resolveRepositoryAssetURL(
232 "images/My Logo.png",
233 owner: "~ccleberg",
234 repositoryName: "Hutch",
235 readmePath: "README.md"
236 )
237
238 #expect(url == "https://git.sr.ht/~ccleberg/Hutch/blob/HEAD/images/My%20Logo.png")
239 }
240}